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; + } +}