From 6d258154a02d939473dfac43d0e964841bc659b1 Mon Sep 17 00:00:00 2001 From: HDDen <62592944+HDDen@users.noreply.github.com> Date: Sat, 23 May 2026 18:26:45 +0300 Subject: [PATCH 1/4] fix Flutter SDK update PR #458 included --- lib/main.dart | 24 +++++++++++++++++++++++- lib/screens/channels_screen.dart | 5 +++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index cd622811..37aa29ff 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'l10n/app_localizations.dart'; import 'package:provider/provider.dart'; @@ -214,7 +215,10 @@ class MeshCoreApp extends StatelessWidget { // Update notification service with resolved locale final locale = Localizations.localeOf(context); NotificationService().setLocale(locale); - return child ?? const SizedBox.shrink(); + return AnnotatedRegion( + value: _systemUiOverlayStyle(context), + child: child ?? const SizedBox.shrink(), + ); }, home: (PlatformInfo.isWeb && !PlatformInfo.isChrome) ? const ChromeRequiredScreen() @@ -236,6 +240,24 @@ class MeshCoreApp extends StatelessWidget { } } + SystemUiOverlayStyle _systemUiOverlayStyle(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final isDark = theme.brightness == Brightness.dark; + final iconBrightness = isDark ? Brightness.light : Brightness.dark; + + // Keep Android system bars aligned with the resolved Flutter theme. + return SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: iconBrightness, + statusBarBrightness: isDark ? Brightness.dark : Brightness.light, + systemNavigationBarColor: colorScheme.surface, + systemNavigationBarIconBrightness: iconBrightness, + systemNavigationBarDividerColor: colorScheme.surface, + systemNavigationBarContrastEnforced: false, + ); + } + Locale? _localeFromSetting(String? languageCode) { if (languageCode == null) return null; return Locale(languageCode); diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 8b05b8ce..d305a6f3 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -273,8 +273,9 @@ class _ChannelsScreenState extends State ), buildDefaultDragHandles: false, itemCount: filteredChannels.length, - onReorder: (oldIndex, newIndex) { - if (newIndex > oldIndex) newIndex -= 1; + onReorderItem: (oldIndex, newIndex) { + // onReorderItem already adjusts newIndex after the + // removed item, unlike the deprecated onReorder. final reordered = List.from( filteredChannels, ); From ed0e6b6554f16b427498ef85d2baba555b7647fd Mon Sep 17 00:00:00 2001 From: HDDen <62592944+HDDen@users.noreply.github.com> Date: Sat, 23 May 2026 21:06:52 +0300 Subject: [PATCH 2/4] Onstart sync progressbar: init --- lib/connector/meshcore_connector.dart | 263 ++++++++++++++++++------- lib/screens/app_settings_screen.dart | 2 + lib/screens/channel_chat_screen.dart | 2 + lib/screens/channels_screen.dart | 12 +- lib/screens/chat_screen.dart | 2 + lib/screens/contacts_screen.dart | 15 +- lib/screens/map_screen.dart | 2 + lib/screens/settings_screen.dart | 2 + lib/screens/telemetry_screen.dart | 2 + lib/widgets/sync_progress_overlay.dart | 60 ++++++ 10 files changed, 282 insertions(+), 80 deletions(-) create mode 100644 lib/widgets/sync_progress_overlay.dart diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 7cbc8ff0..d1581e44 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -199,6 +199,7 @@ class MeshCoreConnector extends ChangeNotifier { double? _selfLongitude; final List _directRepeaters = List.empty(growable: true); bool _isLoadingContacts = false; + bool _hasLoadedContacts = false; bool _isLoadingChannels = false; bool _hasLoadedChannels = false; TimeoutPredictionService? _timeoutPredictionService; @@ -220,10 +221,13 @@ class MeshCoreConnector extends ChangeNotifier { bool _batteryRequested = false; bool _awaitingSelfInfo = false; bool _hasReceivedDeviceInfo = false; + // Initial sync is serialized for predictable progress. Firmware exposes one + // FIFO queued-message stream, so direct/room frames are buffered until after + // contacts are known. bool _pendingInitialChannelSync = false; bool _pendingInitialContactsSync = false; + bool _pendingInitialQueuedMessageSync = false; bool _bleInitialSyncStarted = false; - bool _pendingDeferredChannelSyncAfterContacts = false; bool _webInitialHandshakeRequestSent = false; bool _preserveContactsOnRefresh = false; bool _autoAddUsers = false; @@ -238,13 +242,18 @@ class MeshCoreConnector extends ChangeNotifier { int _advertLocPolicy = 0; int _multiAcks = 0; - static const int _defaultMaxContacts = 32; - static const int _defaultMaxChannels = 8; + static const int _defaultMaxContacts = 350; + static const int _defaultMaxChannels = 40; int _maxContacts = _defaultMaxContacts; int _maxChannels = _defaultMaxChannels; + int? _contactSyncTotal; + int _contactSyncReceived = 0; + bool _contactSyncUsesSinceFilter = false; bool _isSyncingQueuedMessages = false; + bool _deferQueuedContactMessagesUntilContacts = false; + bool _isProcessingDeferredQueuedContactMessages = false; bool _queuedMessageSyncInFlight = false; - bool _didInitialQueueSync = false; + final List _deferredQueuedContactMessageFrames = []; bool _pendingQueueSync = false; Timer? _queueSyncTimeout; int _queueSyncRetries = 0; @@ -373,7 +382,9 @@ class MeshCoreConnector extends ChangeNotifier { List get channels => List.unmodifiable(_channels); bool get isConnected => _state == MeshCoreConnectionState.connected; bool get isLoadingContacts => _isLoadingContacts; + bool get hasLoadedContacts => _hasLoadedContacts; bool get isLoadingChannels => _isLoadingChannels; + bool get hasLoadedChannels => _hasLoadedChannels; Stream get receivedFrames => _receivedFramesController.stream; Uint8List? get selfPublicKey => _selfPublicKey; String get selfPublicKeyHex => pubKeyToHex(_selfPublicKey ?? Uint8List(0)); @@ -436,7 +447,16 @@ class MeshCoreConnector extends ChangeNotifier { int get maxContacts => _maxContacts; int get maxChannels => _maxChannels; Set get knownContactKeys => Set.unmodifiable(_knownContactKeys); - bool get isSyncingQueuedMessages => _isSyncingQueuedMessages; + double? get contactSyncProgress { + final total = _contactSyncTotal; + if (!_isLoadingContacts || total == null || total <= 0) return null; + return (_contactSyncReceived / total).clamp(0.0, 1.0).toDouble(); + } + + bool get isSyncingQueuedMessages => + _isSyncingQueuedMessages || _isProcessingDeferredQueuedContactMessages; + bool get isShowingQueuedMessageSyncProgress => + _deferQueuedContactMessagesUntilContacts && isSyncingQueuedMessages; bool get isSyncingChannels => _isSyncingChannels; int get channelSyncProgress => _isSyncingChannels && _totalChannelsToRequest > 0 @@ -1515,6 +1535,8 @@ class MeshCoreConnector extends ChangeNotifier { _setState(MeshCoreConnectionState.connected); _pendingInitialChannelSync = true; + _pendingInitialQueuedMessageSync = true; + _pendingInitialContactsSync = true; _appDebugLogService?.info( 'connectUsb: requesting device info…', tag: 'USB', @@ -1625,6 +1647,8 @@ class MeshCoreConnector extends ChangeNotifier { _setState(MeshCoreConnectionState.connected); _pendingInitialChannelSync = true; + _pendingInitialQueuedMessageSync = true; + _pendingInitialContactsSync = true; await _requestDeviceInfo(); _startBatteryPolling(); if (_radioStatsPollRefCount > 0) _startRadioStatsPolling(); @@ -2259,7 +2283,9 @@ class MeshCoreConnector extends ChangeNotifier { return; } _bleInitialSyncStarted = true; + _pendingInitialChannelSync = true; _pendingInitialContactsSync = true; + _pendingInitialQueuedMessageSync = true; await _requestDeviceInfo(); _startBatteryPolling(); @@ -2274,7 +2300,7 @@ class MeshCoreConnector extends ChangeNotifier { } await syncTime(); - unawaited(getChannels()); + _maybeStartInitialChannelSync(); } void _resetConnectionHandshakeState() { @@ -2287,11 +2313,39 @@ class MeshCoreConnector extends ChangeNotifier { _selfInfoRetryTimer?.cancel(); _selfInfoRetryTimer = null; _hasReceivedDeviceInfo = false; + _resetSyncProgressState(); + _bleInitialSyncStarted = false; + _pathHashByteWidth = 1; + } + + void _resetSyncProgressState() { _pendingInitialChannelSync = false; _pendingInitialContactsSync = false; - _bleInitialSyncStarted = false; - _pendingDeferredChannelSyncAfterContacts = false; - _pathHashByteWidth = 1; + _pendingInitialQueuedMessageSync = false; + _contactSyncTotal = null; + _contactSyncReceived = 0; + _contactSyncUsesSinceFilter = false; + _isLoadingContacts = false; + _hasLoadedContacts = false; + _isLoadingChannels = false; + _hasLoadedChannels = false; + _isSyncingQueuedMessages = false; + _deferQueuedContactMessagesUntilContacts = false; + _isProcessingDeferredQueuedContactMessages = false; + _queuedMessageSyncInFlight = false; + _deferredQueuedContactMessageFrames.clear(); + _pendingQueueSync = false; + _queueSyncTimeout?.cancel(); + _queueSyncTimeout = null; + _queueSyncRetries = 0; + _isSyncingChannels = false; + _channelSyncInFlight = false; + _channelSyncTimeout?.cancel(); + _channelSyncTimeout = null; + _channelSyncRetries = 0; + _nextChannelIndexToRequest = 0; + _totalChannelsToRequest = 0; + _previousChannelsCache.clear(); } bool get _shouldAutoReconnect => @@ -2428,17 +2482,9 @@ class MeshCoreConnector extends ChangeNotifier { _batteryRequested = false; _awaitingSelfInfo = false; _hasReceivedDeviceInfo = false; - _pendingInitialChannelSync = false; - _pendingInitialContactsSync = false; _maxContacts = _defaultMaxContacts; _maxChannels = _defaultMaxChannels; - _isSyncingQueuedMessages = false; - _queuedMessageSyncInFlight = false; - _didInitialQueueSync = false; - _pendingQueueSync = false; - _isSyncingChannels = false; - _channelSyncInFlight = false; - _hasLoadedChannels = false; + _resetSyncProgressState(); _pendingChannelSentQueue.clear(); _pendingGenericAckQueue.clear(); _reactionSendQueueSequence = 0; @@ -2691,10 +2737,14 @@ class MeshCoreConnector extends ChangeNotifier { _isLoadingContacts = true; _preserveContactsOnRefresh = preserveExisting; + _contactSyncTotal = null; + _contactSyncReceived = 0; + _contactSyncUsesSinceFilter = since != null; if (!preserveExisting) { + _hasLoadedContacts = false; _contacts.clear(); - notifyListeners(); } + notifyListeners(); await sendFrame(buildGetContactsFrame(since: since)); } @@ -3292,11 +3342,20 @@ class MeshCoreConnector extends ChangeNotifier { Future syncQueuedMessages({bool force = false}) async { if (!isConnected) return; if (!force && _isSyncingQueuedMessages) return; + if (_isProcessingDeferredQueuedContactMessages) { + _pendingQueueSync = true; + return; + } if (_awaitingSelfInfo || _isLoadingContacts) { _pendingQueueSync = true; return; } + if (_isSyncingChannels || _channelSyncInFlight) { + _pendingQueueSync = true; + return; + } _isSyncingQueuedMessages = true; + notifyListeners(); await _requestNextQueuedMessage(); } @@ -3330,6 +3389,8 @@ class MeshCoreConnector extends ChangeNotifier { _isSyncingQueuedMessages = false; _queueSyncTimeout?.cancel(); _queueSyncRetries = 0; + notifyListeners(); + _continueAfterQueuedMessageSync(); } } @@ -3349,6 +3410,8 @@ class MeshCoreConnector extends ChangeNotifier { _queuedMessageSyncInFlight = false; _isSyncingQueuedMessages = false; _queueSyncRetries = 0; + notifyListeners(); + _continueAfterQueuedMessageSync(); } } @@ -3448,6 +3511,7 @@ class MeshCoreConnector extends ChangeNotifier { _isLoadingChannels = true; _isSyncingChannels = true; + _hasLoadedChannels = false; _previousChannelsCache = List.from(_channels); _channels.clear(); _nextChannelIndexToRequest = 0; @@ -3537,6 +3601,7 @@ class MeshCoreConnector extends ChangeNotifier { _nextChannelIndexToRequest++; _channelSyncRetries = 0; _channelSyncInFlight = false; + notifyListeners(); unawaited(_requestNextChannel()); } } @@ -3574,16 +3639,22 @@ class MeshCoreConnector extends ChangeNotifier { _previousChannelsCache.clear(); } - // Fallback: if contact sync was deferred waiting for channel 0 but - // channel sync finished without triggering it, start contacts now. - if (_pendingInitialContactsSync && isConnected) { - _pendingInitialContactsSync = false; - unawaited(getContacts()); + if (isConnected) { + _startPostChannelInitialQueuedMessageSync(); } // Keep cache on failure/disconnection for future attempts } + void _startPostChannelInitialQueuedMessageSync() { + if (_pendingInitialQueuedMessageSync || _pendingQueueSync) { + _deferQueuedContactMessagesUntilContacts = _pendingInitialContactsSync; + _pendingInitialQueuedMessageSync = false; + _pendingQueueSync = false; + unawaited(syncQueuedMessages(force: true)); + } + } + Future setChannel(int index, String name, Uint8List psk) async { if (!isConnected) return; @@ -3633,6 +3704,20 @@ class MeshCoreConnector extends ChangeNotifier { _contacts.clear(); } _isLoadingContacts = true; + _contactSyncReceived = 0; + // Firmware v3+ includes total contacts after CONTACTS_START. + // Incremental sync reports total contacts, not filtered result count. + if (frame.length >= 5 && !_contactSyncUsesSinceFilter) { + final reader = BufferReader(frame); + reader.skipBytes(1); + _contactSyncTotal = reader.readUInt32LE(); + } else if (!_contactSyncUsesSinceFilter) { + // Older firmwares may omit the count; use the nRF node capacity as + // a conservative progress fallback instead of hiding the progress. + _contactSyncTotal = _defaultMaxContacts; + } else { + _contactSyncTotal = null; + } notifyListeners(); break; case pushCodeAdvert: @@ -3650,7 +3735,9 @@ class MeshCoreConnector extends ChangeNotifier { case respCodeEndOfContacts: debugPrint('Got END_OF_CONTACTS'); _isLoadingContacts = false; + _hasLoadedContacts = true; _preserveContactsOnRefresh = false; + _contactSyncUsesSinceFilter = false; unawaited(updateKnownDiscovered()); notifyListeners(); unawaited(_persistContacts()); @@ -3660,23 +3747,21 @@ class MeshCoreConnector extends ChangeNotifier { !_channelSyncInFlight) { unawaited(_requestNextChannel()); } - if (!_didInitialQueueSync || _pendingQueueSync) { - _didInitialQueueSync = true; + if (_deferQueuedContactMessagesUntilContacts) { + unawaited(_processDeferredQueuedContactMessages()); + } else if (_pendingQueueSync) { _pendingQueueSync = false; unawaited(syncQueuedMessages(force: true)); } - if (_pendingDeferredChannelSyncAfterContacts && - (_activeTransport == MeshCoreTransportType.bluetooth || - _activeTransport == MeshCoreTransportType.usb || - _activeTransport == MeshCoreTransportType.tcp)) { - _pendingDeferredChannelSyncAfterContacts = false; - _pendingInitialChannelSync = false; - unawaited(getChannels()); - } break; case respCodeContactMsgRecv: case respCodeContactMsgRecvV3: - _handleIncomingMessage(frame); + if (_shouldDeferQueuedContactMessage(frame)) { + _deferredQueuedContactMessageFrames.add(Uint8List.fromList(frame)); + _handleQueuedMessageReceived(); + } else { + unawaited(_handleIncomingMessage(frame)); + } break; case respCodeChannelMsgRecv: case respCodeChannelMsgRecvV3: @@ -3860,23 +3945,8 @@ class MeshCoreConnector extends ChangeNotifier { _selfInfoRetryTimer = null; notifyListeners(); - // Auto-fetch contacts after getting self info. On web BLE, defer this - // until after channel 0 so startup writes stay serialized. - if (PlatformInfo.isWeb && - _activeTransport == MeshCoreTransportType.bluetooth) { - _pendingInitialContactsSync = true; - } else if (_activeTransport == MeshCoreTransportType.usb || - _activeTransport == MeshCoreTransportType.tcp) { - _pendingDeferredChannelSyncAfterContacts = true; - getContacts(); - } else { - getContacts(); - } - if (_shouldGateInitialChannelSync && - _activeTransport != MeshCoreTransportType.usb && - _activeTransport != MeshCoreTransportType.tcp) { - _maybeStartInitialChannelSync(); - } + // Start the serialized initial sync pipeline after SELF_INFO. + _maybeStartInitialChannelSync(); } void _handleDeviceInfo(Uint8List frame) { @@ -3916,7 +3986,7 @@ class MeshCoreConnector extends ChangeNotifier { unawaited(loadAllChannelMessages(maxChannels: nextMaxChannels)); if (isConnected && _selfPublicKey != null && - (!_shouldGateInitialChannelSync || !_pendingInitialChannelSync)) { + !_pendingInitialChannelSync) { unawaited(getChannels(maxChannels: nextMaxChannels)); } } @@ -3931,12 +4001,13 @@ class MeshCoreConnector extends ChangeNotifier { if (!_pendingInitialChannelSync || !isConnected) { return; } - if (_selfPublicKey == null || !_hasReceivedDeviceInfo) { + if (_selfPublicKey == null || + (_shouldGateInitialChannelSync && !_hasReceivedDeviceInfo)) { return; } _pendingInitialChannelSync = false; - unawaited(getChannels(maxChannels: _maxChannels)); + unawaited(getChannels(maxChannels: _maxChannels, force: true)); } void _handleNoMoreMessages() { @@ -3945,6 +4016,64 @@ class MeshCoreConnector extends ChangeNotifier { _isSyncingQueuedMessages = false; _queuedMessageSyncInFlight = false; _queueSyncRetries = 0; // Reset retry counter on successful completion + notifyListeners(); + _continueAfterQueuedMessageSync(); + } + + bool _shouldDeferQueuedContactMessage(Uint8List frame) { + if (!_deferQueuedContactMessagesUntilContacts || + !_isSyncingQueuedMessages) { + return false; + } + if (frame.isEmpty) return false; + return frame[0] == respCodeContactMsgRecv || + frame[0] == respCodeContactMsgRecvV3; + } + + void _continueAfterQueuedMessageSync() { + if (!_deferQueuedContactMessagesUntilContacts) return; + if (_pendingInitialContactsSync && isConnected) { + _pendingInitialContactsSync = false; + unawaited(getContacts()); + return; + } + unawaited(_processDeferredQueuedContactMessages()); + } + + Future _processDeferredQueuedContactMessages() async { + if (!_deferQueuedContactMessagesUntilContacts || + _isProcessingDeferredQueuedContactMessages) { + return; + } + if (_deferredQueuedContactMessageFrames.isEmpty) { + _deferQueuedContactMessagesUntilContacts = false; + notifyListeners(); + if (_pendingQueueSync && isConnected) { + _pendingQueueSync = false; + unawaited(syncQueuedMessages(force: true)); + } + return; + } + + _isProcessingDeferredQueuedContactMessages = true; + notifyListeners(); + try { + // Replay direct/room queued messages only after contacts are loaded, so + // sender prefixes can be resolved against the current contact list. + while (_deferredQueuedContactMessageFrames.isNotEmpty) { + final frame = _deferredQueuedContactMessageFrames.removeAt(0); + await _handleIncomingMessage(frame); + } + } finally { + _deferQueuedContactMessagesUntilContacts = false; + _isProcessingDeferredQueuedContactMessages = false; + notifyListeners(); + } + + if (_pendingQueueSync && isConnected) { + _pendingQueueSync = false; + unawaited(syncQueuedMessages(force: true)); + } } void _handleQueuedMessageReceived() { @@ -3953,6 +4082,7 @@ class MeshCoreConnector extends ChangeNotifier { _queueSyncTimeout?.cancel(); // Cancel timeout - message arrived _queuedMessageSyncInFlight = false; _queueSyncRetries = 0; // Reset retry counter on successful message + notifyListeners(); unawaited(_requestNextQueuedMessage()); } @@ -4094,11 +4224,15 @@ class MeshCoreConnector extends ChangeNotifier { void _handleContact(Uint8List frame, {bool isContact = true}) { final contactTmp = Contact.fromFrame(frame); if (contactTmp != null) { + if (isContact && _isLoadingContacts) { + _contactSyncReceived++; + } if (listEquals(contactTmp.publicKey, _selfPublicKey)) { appLogger.info( 'Ignoring contact with self public key: ${contactTmp.name}', tag: 'Connector', ); + notifyListeners(); removeContact(contactTmp); return; } @@ -4162,6 +4296,7 @@ class MeshCoreConnector extends ChangeNotifier { "Discovered contact ${contact.name} (type ${contact.typeLabelRaw}) not added due to auto-add settings", tag: 'Connector', ); + notifyListeners(); return; } } @@ -4370,7 +4505,7 @@ class MeshCoreConnector extends ChangeNotifier { return false; } - void _handleIncomingMessage(Uint8List frame) async { + Future _handleIncomingMessage(Uint8List frame) async { if (_selfPublicKey == null) return; var message = _parseContactMessage(frame); @@ -5092,14 +5227,7 @@ class MeshCoreConnector extends ChangeNotifier { // Move to next channel _nextChannelIndexToRequest++; - if (PlatformInfo.isWeb && - _activeTransport == MeshCoreTransportType.bluetooth && - channel.index == 0 && - _pendingInitialContactsSync) { - _pendingInitialContactsSync = false; - unawaited(getContacts()); - return; - } + notifyListeners(); unawaited(_requestNextChannel()); return; } else { @@ -5799,14 +5927,9 @@ class MeshCoreConnector extends ChangeNotifier { // Preserve deviceId and displayName for UI display during reconnection // They're only cleared on manual disconnect via disconnect() method _hasReceivedDeviceInfo = false; - _pendingInitialChannelSync = false; - _pendingInitialContactsSync = false; _maxContacts = _defaultMaxContacts; _maxChannels = _defaultMaxChannels; - _isSyncingQueuedMessages = false; - _queuedMessageSyncInFlight = false; - _isSyncingChannels = false; - _channelSyncInFlight = false; + _resetSyncProgressState(); _pendingChannelSentQueue.clear(); _pendingGenericAckQueue.clear(); _reactionSendQueueSequence = 0; diff --git a/lib/screens/app_settings_screen.dart b/lib/screens/app_settings_screen.dart index 94b3efe8..aaae8011 100644 --- a/lib/screens/app_settings_screen.dart +++ b/lib/screens/app_settings_screen.dart @@ -11,6 +11,7 @@ import '../services/app_settings_service.dart'; import '../services/notification_service.dart'; import '../services/translation_service.dart'; import '../widgets/adaptive_app_bar_title.dart'; +import '../widgets/sync_progress_overlay.dart'; import '../helpers/snack_bar_builder.dart'; import 'map_cache_screen.dart'; @@ -23,6 +24,7 @@ class AppSettingsScreen extends StatelessWidget { appBar: AppBar( title: AdaptiveAppBarTitle(context.l10n.appSettings_title), centerTitle: true, + bottom: const SyncProgressAppBarBottom(), ), body: SafeArea( top: false, diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index be72eaa8..8562f7d1 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -34,6 +34,7 @@ import '../widgets/gif_picker.dart'; import '../widgets/message_translation_button.dart'; import '../widgets/message_status_icon.dart'; import '../widgets/radio_stats_entry.dart'; +import '../widgets/sync_progress_overlay.dart'; import '../widgets/translated_message_content.dart'; import '../widgets/unread_divider.dart'; import 'channel_message_path_screen.dart'; @@ -302,6 +303,7 @@ class _ChannelChatScreenState extends State { ], ), centerTitle: false, + bottom: const SyncProgressAppBarBottom(), actions: [ const RadioStatsIconButton(), PopupMenuButton( diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index d305a6f3..1475bdef 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -23,6 +23,7 @@ import '../widgets/list_filter_widget.dart'; import '../widgets/empty_state.dart'; import '../widgets/qr_code_display.dart'; import '../widgets/quick_switch_bar.dart'; +import '../widgets/sync_progress_overlay.dart'; import '../widgets/unread_badge.dart'; import '../helpers/snack_bar_builder.dart'; import 'channel_chat_screen.dart'; @@ -103,6 +104,7 @@ class _ChannelsScreenState extends State title: AppBarTitle(context.l10n.channels_title), centerTitle: true, automaticallyImplyLeading: false, + bottom: const SyncProgressAppBarBottom(), actions: [ PopupMenuButton( itemBuilder: (context) => [ @@ -152,12 +154,16 @@ class _ChannelsScreenState extends State await context.read().getChannels(force: true); }, child: () { - if (connector.isLoadingChannels) { + final channels = connector.channels; + final waitingForInitialChannels = + !connector.hasLoadedChannels && !connector.isLoadingChannels; + final waitingForFirstChannel = + connector.isLoadingChannels && channels.isEmpty; + + if (waitingForInitialChannels || waitingForFirstChannel) { return const Center(child: CircularProgressIndicator()); } - final channels = connector.channels; - if (channels.isEmpty) { return ListView( children: [ diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 1548b44e..a2747043 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -41,6 +41,7 @@ import '../widgets/gif_picker.dart'; import '../widgets/message_translation_button.dart'; import '../widgets/path_selection_dialog.dart'; import '../widgets/radio_stats_entry.dart'; +import '../widgets/sync_progress_overlay.dart'; import '../widgets/translated_message_content.dart'; import '../utils/app_logger.dart'; import '../l10n/l10n.dart'; @@ -216,6 +217,7 @@ class _ChatScreenState extends State { }, ), centerTitle: false, + bottom: const SyncProgressAppBarBottom(), actions: [ Consumer( builder: (context, connector, _) { diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index d5e1b79a..bdadc2b6 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -27,6 +27,7 @@ import '../widgets/empty_state.dart'; import '../widgets/quick_switch_bar.dart'; import '../widgets/repeater_login_dialog.dart'; import '../widgets/room_login_dialog.dart'; +import '../widgets/sync_progress_overlay.dart'; import '../widgets/unread_badge.dart'; import '../helpers/snack_bar_builder.dart'; import 'channels_screen.dart'; @@ -318,6 +319,7 @@ class _ContactsScreenState extends State appBar: AppBar( title: AppBarTitle(context.l10n.contacts_title), automaticallyImplyLeading: false, + bottom: const SyncProgressAppBarBottom(), actions: [ PopupMenuButton( itemBuilder: (context) => [ @@ -606,15 +608,14 @@ class _ContactsScreenState extends State Widget _buildContactsBody(BuildContext context, MeshCoreConnector connector) { final viewState = context.watch(); final contacts = connector.contacts; - final shouldShowStartupSpinner = - contacts.isEmpty && - _groups.isEmpty && + final waitingForInitialContacts = connector.isConnected && - (connector.isLoadingContacts || - connector.isLoadingChannels || - connector.selfPublicKey == null); + !connector.hasLoadedContacts && + !connector.isLoadingContacts; + final waitingForFirstContact = + connector.isLoadingContacts && contacts.isEmpty; - if (shouldShowStartupSpinner) { + if (waitingForInitialContacts || waitingForFirstContact) { return const Center(child: CircularProgressIndicator()); } diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index be133240..766b1852 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -23,6 +23,7 @@ import '../services/map_tile_cache_service.dart'; import '../utils/contact_search.dart'; import '../utils/route_transitions.dart'; import '../widgets/quick_switch_bar.dart'; +import '../widgets/sync_progress_overlay.dart'; import '../icons/los_icon.dart'; import 'channels_screen.dart'; import 'chat_screen.dart'; @@ -414,6 +415,7 @@ class _MapScreenState extends State { title: AppBarTitle(context.l10n.map_title), centerTitle: true, automaticallyImplyLeading: false, + bottom: const SyncProgressAppBarBottom(), actions: [ if (!_isBuildingPathTrace) IconButton( diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 5679f757..ead36eaf 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -16,6 +16,7 @@ import 'app_settings_screen.dart'; import 'app_debug_log_screen.dart'; import 'ble_debug_log_screen.dart'; import '../widgets/radio_stats_entry.dart'; +import '../widgets/sync_progress_overlay.dart'; /// Convert device coding-rate value (1-4 on some firmware, 5-8 on others) /// to the UI enum range (always 5-8). @@ -67,6 +68,7 @@ class _SettingsScreenState extends State { indicators: false, subtitle: false, ), + bottom: const SyncProgressAppBarBottom(), ), body: SafeArea( top: false, diff --git a/lib/screens/telemetry_screen.dart b/lib/screens/telemetry_screen.dart index 47593a3f..873ca3b1 100644 --- a/lib/screens/telemetry_screen.dart +++ b/lib/screens/telemetry_screen.dart @@ -15,6 +15,7 @@ import '../widgets/path_management_dialog.dart'; import '../helpers/cayenne_lpp.dart'; import '../utils/battery_utils.dart'; import '../helpers/snack_bar_builder.dart'; +import '../widgets/sync_progress_overlay.dart'; class TelemetryScreen extends StatefulWidget { final Contact contact; @@ -239,6 +240,7 @@ class _TelemetryScreenState extends State { ], ), centerTitle: false, + bottom: const SyncProgressAppBarBottom(), actions: [ PopupMenuButton( icon: Icon(isFloodMode ? Icons.waves : Icons.route), diff --git a/lib/widgets/sync_progress_overlay.dart b/lib/widgets/sync_progress_overlay.dart new file mode 100644 index 00000000..4aef90ea --- /dev/null +++ b/lib/widgets/sync_progress_overlay.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../connector/meshcore_connector.dart'; + +class SyncProgressAppBarBottom extends StatelessWidget + implements PreferredSizeWidget { + static const double height = 3; + + const SyncProgressAppBarBottom({super.key}); + + @override + Size get preferredSize => const Size.fromHeight(height); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, connector, _) { + final state = _SyncProgressState.fromConnector(connector); + if (state == null) return const SizedBox(height: height); + + return SizedBox( + height: height, + child: LinearProgressIndicator( + value: state.value, + minHeight: height, + color: state.color, + backgroundColor: state.color.withValues(alpha: 0.18), + ), + ); + }, + ); + } +} + +class _SyncProgressState { + final double? value; + final Color color; + + const _SyncProgressState({required this.value, required this.color}); + + static _SyncProgressState? fromConnector(MeshCoreConnector connector) { + if (connector.isLoadingContacts) { + return _SyncProgressState( + value: connector.contactSyncProgress, + color: Colors.red, + ); + } + if (connector.isSyncingChannels) { + return _SyncProgressState( + value: connector.channelSyncProgress / 100, + color: Colors.blue, + ); + } + if (connector.isShowingQueuedMessageSyncProgress) { + return const _SyncProgressState(value: null, color: Colors.green); + } + return null; + } +} From 4dd472e3c3a6fe6c4b841c8f722827ba81f8a716 Mon Sep 17 00:00:00 2001 From: HDDen <62592944+HDDen@users.noreply.github.com> Date: Sun, 24 May 2026 15:33:54 +0300 Subject: [PATCH 3/4] Onstart sync progressbar: changed default screen to channels Because it's synchronization is faster and first in line --- lib/screens/scanner_screen.dart | 4 ++-- lib/screens/tcp_screen.dart | 14 +++++++------- lib/screens/usb_screen.dart | 14 +++++++------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/lib/screens/scanner_screen.dart b/lib/screens/scanner_screen.dart index a503ec0e..ec148c5f 100644 --- a/lib/screens/scanner_screen.dart +++ b/lib/screens/scanner_screen.dart @@ -11,7 +11,7 @@ import '../utils/app_logger.dart'; import '../widgets/adaptive_app_bar_title.dart'; import '../widgets/device_tile.dart'; import '../helpers/snack_bar_builder.dart'; -import 'contacts_screen.dart'; +import 'channels_screen.dart'; import 'tcp_screen.dart'; import 'usb_screen.dart'; @@ -46,7 +46,7 @@ class _ScannerScreenState extends State { _changedNavigation = true; if (mounted) { Navigator.of(context).push( - MaterialPageRoute(builder: (context) => const ContactsScreen()), + MaterialPageRoute(builder: (context) => const ChannelsScreen()), ); } } diff --git a/lib/screens/tcp_screen.dart b/lib/screens/tcp_screen.dart index 3bd1b0bf..a0d71922 100644 --- a/lib/screens/tcp_screen.dart +++ b/lib/screens/tcp_screen.dart @@ -9,7 +9,7 @@ import '../services/app_settings_service.dart'; import '../utils/platform_info.dart'; import '../widgets/adaptive_app_bar_title.dart'; import '../helpers/snack_bar_builder.dart'; -import 'contacts_screen.dart'; +import 'channels_screen.dart'; import 'usb_screen.dart'; class TcpScreen extends StatefulWidget { @@ -24,7 +24,7 @@ class _TcpScreenState extends State { late final TextEditingController _portController; late final MeshCoreConnector _connector; late final VoidCallback _connectionListener; - bool _navigatedToContacts = false; + bool _navigatedToChannels = false; @override void initState() { @@ -42,20 +42,20 @@ class _TcpScreenState extends State { _connectionListener = () { if (!mounted) return; if (_connector.state == MeshCoreConnectionState.disconnected) { - _navigatedToContacts = false; + _navigatedToChannels = false; } if (_connector.state == MeshCoreConnectionState.connected && _connector.isTcpTransportConnected && - !_navigatedToContacts) { + !_navigatedToChannels) { context.read().setTcpServerAddress( _hostController.text, ); context.read().setTcpServerPort( int.tryParse(_portController.text) ?? 0, ); - _navigatedToContacts = true; + _navigatedToChannels = true; Navigator.of(context).pushReplacement( - MaterialPageRoute(builder: (_) => const ContactsScreen()), + MaterialPageRoute(builder: (_) => const ChannelsScreen()), ); } }; @@ -67,7 +67,7 @@ class _TcpScreenState extends State { _hostController.dispose(); _portController.dispose(); _connector.removeListener(_connectionListener); - if (!_navigatedToContacts && + if (!_navigatedToChannels && _connector.activeTransport == MeshCoreTransportType.tcp && _connector.state != MeshCoreConnectionState.disconnected) { WidgetsBinding.instance.addPostFrameCallback((_) { diff --git a/lib/screens/usb_screen.dart b/lib/screens/usb_screen.dart index 6b8fe9d9..25992de8 100644 --- a/lib/screens/usb_screen.dart +++ b/lib/screens/usb_screen.dart @@ -11,7 +11,7 @@ import '../utils/platform_info.dart'; import '../utils/usb_port_labels.dart'; import '../widgets/adaptive_app_bar_title.dart'; import '../helpers/snack_bar_builder.dart'; -import 'contacts_screen.dart'; +import 'channels_screen.dart'; import 'scanner_screen.dart'; import 'tcp_screen.dart'; @@ -25,7 +25,7 @@ class UsbScreen extends StatefulWidget { class _UsbScreenState extends State { final List _ports = []; bool _isLoadingPorts = true; - bool _navigatedToContacts = false; + bool _navigatedToChannels = false; bool _didScheduleInitialLoad = false; Timer? _hotPlugTimer; late final MeshCoreConnector _connector; @@ -41,14 +41,14 @@ class _UsbScreenState extends State { _connectionListener = () { if (!mounted) return; if (_connector.state == MeshCoreConnectionState.disconnected) { - _navigatedToContacts = false; + _navigatedToChannels = false; } if (_connector.state == MeshCoreConnectionState.connected && _connector.isUsbTransportConnected && - !_navigatedToContacts) { - _navigatedToContacts = true; + !_navigatedToChannels) { + _navigatedToChannels = true; Navigator.of(context).pushReplacement( - MaterialPageRoute(builder: (_) => const ContactsScreen()), + MaterialPageRoute(builder: (_) => const ChannelsScreen()), ); } }; @@ -72,7 +72,7 @@ class _UsbScreenState extends State { _hotPlugTimer?.cancel(); _hotPlugTimer = null; _connector.removeListener(_connectionListener); - if (!_navigatedToContacts && + if (!_navigatedToChannels && _connector.activeTransport == MeshCoreTransportType.usb && _connector.state != MeshCoreConnectionState.disconnected) { WidgetsBinding.instance.addPostFrameCallback((_) { From 2328848400b002e626363eea535c2b42f426504d Mon Sep 17 00:00:00 2001 From: HDDen <62592944+HDDen@users.noreply.github.com> Date: Tue, 26 May 2026 00:37:42 +0300 Subject: [PATCH 4/4] Onstart sync progressbar: fix potential bug with spinner on interrupt synchronization --- lib/connector/meshcore_connector.dart | 9 +++++++++ lib/screens/channels_screen.dart | 7 ++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index d1581e44..271ea069 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -3637,6 +3637,12 @@ class MeshCoreConnector extends ChangeNotifier { if (completed) { _hasLoadedChannels = true; _previousChannelsCache.clear(); + } else if (_channels.isEmpty && _previousChannelsCache.isNotEmpty) { + // A failed initial sync should not leave the UI empty/spinning forever. + // Restore the pre-sync list so cached channels remain usable. + _channels.addAll(_previousChannelsCache); + _applyChannelOrder(); + _recalculateCachedChannelsUnreadTotal(); } if (isConnected) { @@ -3644,6 +3650,9 @@ class MeshCoreConnector extends ChangeNotifier { } // Keep cache on failure/disconnection for future attempts + if (!completed) { + notifyListeners(); + } } void _startPostChannelInitialQueuedMessageSync() { diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 1475bdef..40726021 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -155,12 +155,13 @@ class _ChannelsScreenState extends State }, child: () { final channels = connector.channels; - final waitingForInitialChannels = - !connector.hasLoadedChannels && !connector.isLoadingChannels; final waitingForFirstChannel = connector.isLoadingChannels && channels.isEmpty; - if (waitingForInitialChannels || waitingForFirstChannel) { + // Only block the list while the first channel is actively loading. + // If the initial sync aborts, show cached/partial channels instead + // of trapping the user behind an idle spinner. + if (waitingForFirstChannel) { return const Center(child: CircularProgressIndicator()); }