Merge pull request #462 from HDDen/sync-progressbar

Onstart syncronization progressbar
This commit is contained in:
zjs81
2026-05-26 10:15:00 -07:00
committed by GitHub
14 changed files with 333 additions and 98 deletions
+202 -70
View File
@@ -199,6 +199,7 @@ class MeshCoreConnector extends ChangeNotifier {
double? _selfLongitude; double? _selfLongitude;
final List<DirectRepeater> _directRepeaters = List.empty(growable: true); final List<DirectRepeater> _directRepeaters = List.empty(growable: true);
bool _isLoadingContacts = false; bool _isLoadingContacts = false;
bool _hasLoadedContacts = false;
bool _isLoadingChannels = false; bool _isLoadingChannels = false;
bool _hasLoadedChannels = false; bool _hasLoadedChannels = false;
TimeoutPredictionService? _timeoutPredictionService; TimeoutPredictionService? _timeoutPredictionService;
@@ -220,10 +221,13 @@ class MeshCoreConnector extends ChangeNotifier {
bool _batteryRequested = false; bool _batteryRequested = false;
bool _awaitingSelfInfo = false; bool _awaitingSelfInfo = false;
bool _hasReceivedDeviceInfo = 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 _pendingInitialChannelSync = false;
bool _pendingInitialContactsSync = false; bool _pendingInitialContactsSync = false;
bool _pendingInitialQueuedMessageSync = false;
bool _bleInitialSyncStarted = false; bool _bleInitialSyncStarted = false;
bool _pendingDeferredChannelSyncAfterContacts = false;
bool _webInitialHandshakeRequestSent = false; bool _webInitialHandshakeRequestSent = false;
bool _preserveContactsOnRefresh = false; bool _preserveContactsOnRefresh = false;
bool _autoAddUsers = false; bool _autoAddUsers = false;
@@ -238,13 +242,18 @@ class MeshCoreConnector extends ChangeNotifier {
int _advertLocPolicy = 0; int _advertLocPolicy = 0;
int _multiAcks = 0; int _multiAcks = 0;
static const int _defaultMaxContacts = 32; static const int _defaultMaxContacts = 350;
static const int _defaultMaxChannels = 8; static const int _defaultMaxChannels = 40;
int _maxContacts = _defaultMaxContacts; int _maxContacts = _defaultMaxContacts;
int _maxChannels = _defaultMaxChannels; int _maxChannels = _defaultMaxChannels;
int? _contactSyncTotal;
int _contactSyncReceived = 0;
bool _contactSyncUsesSinceFilter = false;
bool _isSyncingQueuedMessages = false; bool _isSyncingQueuedMessages = false;
bool _deferQueuedContactMessagesUntilContacts = false;
bool _isProcessingDeferredQueuedContactMessages = false;
bool _queuedMessageSyncInFlight = false; bool _queuedMessageSyncInFlight = false;
bool _didInitialQueueSync = false; final List<Uint8List> _deferredQueuedContactMessageFrames = [];
bool _pendingQueueSync = false; bool _pendingQueueSync = false;
Timer? _queueSyncTimeout; Timer? _queueSyncTimeout;
int _queueSyncRetries = 0; int _queueSyncRetries = 0;
@@ -373,7 +382,9 @@ class MeshCoreConnector extends ChangeNotifier {
List<Channel> get channels => List.unmodifiable(_channels); List<Channel> get channels => List.unmodifiable(_channels);
bool get isConnected => _state == MeshCoreConnectionState.connected; bool get isConnected => _state == MeshCoreConnectionState.connected;
bool get isLoadingContacts => _isLoadingContacts; bool get isLoadingContacts => _isLoadingContacts;
bool get hasLoadedContacts => _hasLoadedContacts;
bool get isLoadingChannels => _isLoadingChannels; bool get isLoadingChannels => _isLoadingChannels;
bool get hasLoadedChannels => _hasLoadedChannels;
Stream<Uint8List> get receivedFrames => _receivedFramesController.stream; Stream<Uint8List> get receivedFrames => _receivedFramesController.stream;
Uint8List? get selfPublicKey => _selfPublicKey; Uint8List? get selfPublicKey => _selfPublicKey;
String get selfPublicKeyHex => pubKeyToHex(_selfPublicKey ?? Uint8List(0)); String get selfPublicKeyHex => pubKeyToHex(_selfPublicKey ?? Uint8List(0));
@@ -436,7 +447,16 @@ class MeshCoreConnector extends ChangeNotifier {
int get maxContacts => _maxContacts; int get maxContacts => _maxContacts;
int get maxChannels => _maxChannels; int get maxChannels => _maxChannels;
Set<String> get knownContactKeys => Set.unmodifiable(_knownContactKeys); Set<String> 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; bool get isSyncingChannels => _isSyncingChannels;
int get channelSyncProgress => int get channelSyncProgress =>
_isSyncingChannels && _totalChannelsToRequest > 0 _isSyncingChannels && _totalChannelsToRequest > 0
@@ -1548,6 +1568,8 @@ class MeshCoreConnector extends ChangeNotifier {
_setState(MeshCoreConnectionState.connected); _setState(MeshCoreConnectionState.connected);
_pendingInitialChannelSync = true; _pendingInitialChannelSync = true;
_pendingInitialQueuedMessageSync = true;
_pendingInitialContactsSync = true;
_appDebugLogService?.info( _appDebugLogService?.info(
'connectUsb: requesting device info…', 'connectUsb: requesting device info…',
tag: 'USB', tag: 'USB',
@@ -1658,6 +1680,8 @@ class MeshCoreConnector extends ChangeNotifier {
_setState(MeshCoreConnectionState.connected); _setState(MeshCoreConnectionState.connected);
_pendingInitialChannelSync = true; _pendingInitialChannelSync = true;
_pendingInitialQueuedMessageSync = true;
_pendingInitialContactsSync = true;
await _requestDeviceInfo(); await _requestDeviceInfo();
_startBatteryPolling(); _startBatteryPolling();
if (_radioStatsPollRefCount > 0) _startRadioStatsPolling(); if (_radioStatsPollRefCount > 0) _startRadioStatsPolling();
@@ -2292,7 +2316,9 @@ class MeshCoreConnector extends ChangeNotifier {
return; return;
} }
_bleInitialSyncStarted = true; _bleInitialSyncStarted = true;
_pendingInitialChannelSync = true;
_pendingInitialContactsSync = true; _pendingInitialContactsSync = true;
_pendingInitialQueuedMessageSync = true;
await _requestDeviceInfo(); await _requestDeviceInfo();
_startBatteryPolling(); _startBatteryPolling();
@@ -2307,7 +2333,7 @@ class MeshCoreConnector extends ChangeNotifier {
} }
await syncTime(); await syncTime();
unawaited(getChannels()); _maybeStartInitialChannelSync();
} }
void _resetConnectionHandshakeState() { void _resetConnectionHandshakeState() {
@@ -2320,11 +2346,39 @@ class MeshCoreConnector extends ChangeNotifier {
_selfInfoRetryTimer?.cancel(); _selfInfoRetryTimer?.cancel();
_selfInfoRetryTimer = null; _selfInfoRetryTimer = null;
_hasReceivedDeviceInfo = false; _hasReceivedDeviceInfo = false;
_resetSyncProgressState();
_bleInitialSyncStarted = false;
_pathHashByteWidth = 1;
}
void _resetSyncProgressState() {
_pendingInitialChannelSync = false; _pendingInitialChannelSync = false;
_pendingInitialContactsSync = false; _pendingInitialContactsSync = false;
_bleInitialSyncStarted = false; _pendingInitialQueuedMessageSync = false;
_pendingDeferredChannelSyncAfterContacts = false; _contactSyncTotal = null;
_pathHashByteWidth = 1; _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 => bool get _shouldAutoReconnect =>
@@ -2461,17 +2515,9 @@ class MeshCoreConnector extends ChangeNotifier {
_batteryRequested = false; _batteryRequested = false;
_awaitingSelfInfo = false; _awaitingSelfInfo = false;
_hasReceivedDeviceInfo = false; _hasReceivedDeviceInfo = false;
_pendingInitialChannelSync = false;
_pendingInitialContactsSync = false;
_maxContacts = _defaultMaxContacts; _maxContacts = _defaultMaxContacts;
_maxChannels = _defaultMaxChannels; _maxChannels = _defaultMaxChannels;
_isSyncingQueuedMessages = false; _resetSyncProgressState();
_queuedMessageSyncInFlight = false;
_didInitialQueueSync = false;
_pendingQueueSync = false;
_isSyncingChannels = false;
_channelSyncInFlight = false;
_hasLoadedChannels = false;
_pendingChannelSentQueue.clear(); _pendingChannelSentQueue.clear();
_pendingGenericAckQueue.clear(); _pendingGenericAckQueue.clear();
_reactionSendQueueSequence = 0; _reactionSendQueueSequence = 0;
@@ -2724,10 +2770,14 @@ class MeshCoreConnector extends ChangeNotifier {
_isLoadingContacts = true; _isLoadingContacts = true;
_preserveContactsOnRefresh = preserveExisting; _preserveContactsOnRefresh = preserveExisting;
_contactSyncTotal = null;
_contactSyncReceived = 0;
_contactSyncUsesSinceFilter = since != null;
if (!preserveExisting) { if (!preserveExisting) {
_hasLoadedContacts = false;
_contacts.clear(); _contacts.clear();
notifyListeners();
} }
notifyListeners();
await sendFrame(buildGetContactsFrame(since: since)); await sendFrame(buildGetContactsFrame(since: since));
} }
@@ -3325,11 +3375,20 @@ class MeshCoreConnector extends ChangeNotifier {
Future<void> syncQueuedMessages({bool force = false}) async { Future<void> syncQueuedMessages({bool force = false}) async {
if (!isConnected) return; if (!isConnected) return;
if (!force && _isSyncingQueuedMessages) return; if (!force && _isSyncingQueuedMessages) return;
if (_isProcessingDeferredQueuedContactMessages) {
_pendingQueueSync = true;
return;
}
if (_awaitingSelfInfo || _isLoadingContacts) { if (_awaitingSelfInfo || _isLoadingContacts) {
_pendingQueueSync = true; _pendingQueueSync = true;
return; return;
} }
if (_isSyncingChannels || _channelSyncInFlight) {
_pendingQueueSync = true;
return;
}
_isSyncingQueuedMessages = true; _isSyncingQueuedMessages = true;
notifyListeners();
await _requestNextQueuedMessage(); await _requestNextQueuedMessage();
} }
@@ -3363,6 +3422,8 @@ class MeshCoreConnector extends ChangeNotifier {
_isSyncingQueuedMessages = false; _isSyncingQueuedMessages = false;
_queueSyncTimeout?.cancel(); _queueSyncTimeout?.cancel();
_queueSyncRetries = 0; _queueSyncRetries = 0;
notifyListeners();
_continueAfterQueuedMessageSync();
} }
} }
@@ -3382,6 +3443,8 @@ class MeshCoreConnector extends ChangeNotifier {
_queuedMessageSyncInFlight = false; _queuedMessageSyncInFlight = false;
_isSyncingQueuedMessages = false; _isSyncingQueuedMessages = false;
_queueSyncRetries = 0; _queueSyncRetries = 0;
notifyListeners();
_continueAfterQueuedMessageSync();
} }
} }
@@ -3481,6 +3544,7 @@ class MeshCoreConnector extends ChangeNotifier {
_isLoadingChannels = true; _isLoadingChannels = true;
_isSyncingChannels = true; _isSyncingChannels = true;
_hasLoadedChannels = false;
_previousChannelsCache = List<Channel>.from(_channels); _previousChannelsCache = List<Channel>.from(_channels);
_channels.clear(); _channels.clear();
_nextChannelIndexToRequest = 0; _nextChannelIndexToRequest = 0;
@@ -3570,6 +3634,7 @@ class MeshCoreConnector extends ChangeNotifier {
_nextChannelIndexToRequest++; _nextChannelIndexToRequest++;
_channelSyncRetries = 0; _channelSyncRetries = 0;
_channelSyncInFlight = false; _channelSyncInFlight = false;
notifyListeners();
unawaited(_requestNextChannel()); unawaited(_requestNextChannel());
} }
} }
@@ -3605,16 +3670,31 @@ class MeshCoreConnector extends ChangeNotifier {
if (completed) { if (completed) {
_hasLoadedChannels = true; _hasLoadedChannels = true;
_previousChannelsCache.clear(); _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();
} }
// Fallback: if contact sync was deferred waiting for channel 0 but if (isConnected) {
// channel sync finished without triggering it, start contacts now. _startPostChannelInitialQueuedMessageSync();
if (_pendingInitialContactsSync && isConnected) {
_pendingInitialContactsSync = false;
unawaited(getContacts());
} }
// Keep cache on failure/disconnection for future attempts // Keep cache on failure/disconnection for future attempts
if (!completed) {
notifyListeners();
}
}
void _startPostChannelInitialQueuedMessageSync() {
if (_pendingInitialQueuedMessageSync || _pendingQueueSync) {
_deferQueuedContactMessagesUntilContacts = _pendingInitialContactsSync;
_pendingInitialQueuedMessageSync = false;
_pendingQueueSync = false;
unawaited(syncQueuedMessages(force: true));
}
} }
Future<void> setChannel(int index, String name, Uint8List psk) async { Future<void> setChannel(int index, String name, Uint8List psk) async {
@@ -3666,6 +3746,20 @@ class MeshCoreConnector extends ChangeNotifier {
_contacts.clear(); _contacts.clear();
} }
_isLoadingContacts = true; _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(); notifyListeners();
break; break;
case pushCodeAdvert: case pushCodeAdvert:
@@ -3683,7 +3777,9 @@ class MeshCoreConnector extends ChangeNotifier {
case respCodeEndOfContacts: case respCodeEndOfContacts:
debugPrint('Got END_OF_CONTACTS'); debugPrint('Got END_OF_CONTACTS');
_isLoadingContacts = false; _isLoadingContacts = false;
_hasLoadedContacts = true;
_preserveContactsOnRefresh = false; _preserveContactsOnRefresh = false;
_contactSyncUsesSinceFilter = false;
unawaited(updateKnownDiscovered()); unawaited(updateKnownDiscovered());
notifyListeners(); notifyListeners();
unawaited(_persistContacts()); unawaited(_persistContacts());
@@ -3693,23 +3789,21 @@ class MeshCoreConnector extends ChangeNotifier {
!_channelSyncInFlight) { !_channelSyncInFlight) {
unawaited(_requestNextChannel()); unawaited(_requestNextChannel());
} }
if (!_didInitialQueueSync || _pendingQueueSync) { if (_deferQueuedContactMessagesUntilContacts) {
_didInitialQueueSync = true; unawaited(_processDeferredQueuedContactMessages());
} else if (_pendingQueueSync) {
_pendingQueueSync = false; _pendingQueueSync = false;
unawaited(syncQueuedMessages(force: true)); unawaited(syncQueuedMessages(force: true));
} }
if (_pendingDeferredChannelSyncAfterContacts &&
(_activeTransport == MeshCoreTransportType.bluetooth ||
_activeTransport == MeshCoreTransportType.usb ||
_activeTransport == MeshCoreTransportType.tcp)) {
_pendingDeferredChannelSyncAfterContacts = false;
_pendingInitialChannelSync = false;
unawaited(getChannels());
}
break; break;
case respCodeContactMsgRecv: case respCodeContactMsgRecv:
case respCodeContactMsgRecvV3: case respCodeContactMsgRecvV3:
_handleIncomingMessage(frame); if (_shouldDeferQueuedContactMessage(frame)) {
_deferredQueuedContactMessageFrames.add(Uint8List.fromList(frame));
_handleQueuedMessageReceived();
} else {
unawaited(_handleIncomingMessage(frame));
}
break; break;
case respCodeChannelMsgRecv: case respCodeChannelMsgRecv:
case respCodeChannelMsgRecvV3: case respCodeChannelMsgRecvV3:
@@ -3893,23 +3987,8 @@ class MeshCoreConnector extends ChangeNotifier {
_selfInfoRetryTimer = null; _selfInfoRetryTimer = null;
notifyListeners(); notifyListeners();
// Auto-fetch contacts after getting self info. On web BLE, defer this // Start the serialized initial sync pipeline after SELF_INFO.
// until after channel 0 so startup writes stay serialized. _maybeStartInitialChannelSync();
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();
}
} }
void _handleDeviceInfo(Uint8List frame) { void _handleDeviceInfo(Uint8List frame) {
@@ -3949,7 +4028,7 @@ class MeshCoreConnector extends ChangeNotifier {
unawaited(loadAllChannelMessages(maxChannels: nextMaxChannels)); unawaited(loadAllChannelMessages(maxChannels: nextMaxChannels));
if (isConnected && if (isConnected &&
_selfPublicKey != null && _selfPublicKey != null &&
(!_shouldGateInitialChannelSync || !_pendingInitialChannelSync)) { !_pendingInitialChannelSync) {
unawaited(getChannels(maxChannels: nextMaxChannels)); unawaited(getChannels(maxChannels: nextMaxChannels));
} }
} }
@@ -3964,12 +4043,13 @@ class MeshCoreConnector extends ChangeNotifier {
if (!_pendingInitialChannelSync || !isConnected) { if (!_pendingInitialChannelSync || !isConnected) {
return; return;
} }
if (_selfPublicKey == null || !_hasReceivedDeviceInfo) { if (_selfPublicKey == null ||
(_shouldGateInitialChannelSync && !_hasReceivedDeviceInfo)) {
return; return;
} }
_pendingInitialChannelSync = false; _pendingInitialChannelSync = false;
unawaited(getChannels(maxChannels: _maxChannels)); unawaited(getChannels(maxChannels: _maxChannels, force: true));
} }
void _handleNoMoreMessages() { void _handleNoMoreMessages() {
@@ -3978,6 +4058,64 @@ class MeshCoreConnector extends ChangeNotifier {
_isSyncingQueuedMessages = false; _isSyncingQueuedMessages = false;
_queuedMessageSyncInFlight = false; _queuedMessageSyncInFlight = false;
_queueSyncRetries = 0; // Reset retry counter on successful completion _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<void> _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() { void _handleQueuedMessageReceived() {
@@ -3986,6 +4124,7 @@ class MeshCoreConnector extends ChangeNotifier {
_queueSyncTimeout?.cancel(); // Cancel timeout - message arrived _queueSyncTimeout?.cancel(); // Cancel timeout - message arrived
_queuedMessageSyncInFlight = false; _queuedMessageSyncInFlight = false;
_queueSyncRetries = 0; // Reset retry counter on successful message _queueSyncRetries = 0; // Reset retry counter on successful message
notifyListeners();
unawaited(_requestNextQueuedMessage()); unawaited(_requestNextQueuedMessage());
} }
@@ -4127,11 +4266,15 @@ class MeshCoreConnector extends ChangeNotifier {
void _handleContact(Uint8List frame, {bool isContact = true}) { void _handleContact(Uint8List frame, {bool isContact = true}) {
final contactTmp = Contact.fromFrame(frame); final contactTmp = Contact.fromFrame(frame);
if (contactTmp != null) { if (contactTmp != null) {
if (isContact && _isLoadingContacts) {
_contactSyncReceived++;
}
if (listEquals(contactTmp.publicKey, _selfPublicKey)) { if (listEquals(contactTmp.publicKey, _selfPublicKey)) {
appLogger.info( appLogger.info(
'Ignoring contact with self public key: ${contactTmp.name}', 'Ignoring contact with self public key: ${contactTmp.name}',
tag: 'Connector', tag: 'Connector',
); );
notifyListeners();
removeContact(contactTmp); removeContact(contactTmp);
return; return;
} }
@@ -4195,6 +4338,7 @@ class MeshCoreConnector extends ChangeNotifier {
"Discovered contact ${contact.name} (type ${contact.typeLabelRaw}) not added due to auto-add settings", "Discovered contact ${contact.name} (type ${contact.typeLabelRaw}) not added due to auto-add settings",
tag: 'Connector', tag: 'Connector',
); );
notifyListeners();
return; return;
} }
} }
@@ -4403,7 +4547,7 @@ class MeshCoreConnector extends ChangeNotifier {
return false; return false;
} }
void _handleIncomingMessage(Uint8List frame) async { Future<void> _handleIncomingMessage(Uint8List frame) async {
if (_selfPublicKey == null) return; if (_selfPublicKey == null) return;
// If we're syncing the queued messages, advance the queue immediately // If we're syncing the queued messages, advance the queue immediately
@@ -5173,14 +5317,7 @@ class MeshCoreConnector extends ChangeNotifier {
// Move to next channel // Move to next channel
_nextChannelIndexToRequest++; _nextChannelIndexToRequest++;
if (PlatformInfo.isWeb && notifyListeners();
_activeTransport == MeshCoreTransportType.bluetooth &&
channel.index == 0 &&
_pendingInitialContactsSync) {
_pendingInitialContactsSync = false;
unawaited(getContacts());
return;
}
unawaited(_requestNextChannel()); unawaited(_requestNextChannel());
return; return;
} else { } else {
@@ -5880,14 +6017,9 @@ class MeshCoreConnector extends ChangeNotifier {
// Preserve deviceId and displayName for UI display during reconnection // Preserve deviceId and displayName for UI display during reconnection
// They're only cleared on manual disconnect via disconnect() method // They're only cleared on manual disconnect via disconnect() method
_hasReceivedDeviceInfo = false; _hasReceivedDeviceInfo = false;
_pendingInitialChannelSync = false;
_pendingInitialContactsSync = false;
_maxContacts = _defaultMaxContacts; _maxContacts = _defaultMaxContacts;
_maxChannels = _defaultMaxChannels; _maxChannels = _defaultMaxChannels;
_isSyncingQueuedMessages = false; _resetSyncProgressState();
_queuedMessageSyncInFlight = false;
_isSyncingChannels = false;
_channelSyncInFlight = false;
_pendingChannelSentQueue.clear(); _pendingChannelSentQueue.clear();
_pendingGenericAckQueue.clear(); _pendingGenericAckQueue.clear();
_reactionSendQueueSequence = 0; _reactionSendQueueSequence = 0;
+23 -1
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'l10n/app_localizations.dart'; import 'l10n/app_localizations.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -214,7 +215,10 @@ class MeshCoreApp extends StatelessWidget {
// Update notification service with resolved locale // Update notification service with resolved locale
final locale = Localizations.localeOf(context); final locale = Localizations.localeOf(context);
NotificationService().setLocale(locale); NotificationService().setLocale(locale);
return child ?? const SizedBox.shrink(); return AnnotatedRegion<SystemUiOverlayStyle>(
value: _systemUiOverlayStyle(context),
child: child ?? const SizedBox.shrink(),
);
}, },
home: (PlatformInfo.isWeb && !PlatformInfo.isChrome) home: (PlatformInfo.isWeb && !PlatformInfo.isChrome)
? const ChromeRequiredScreen() ? 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) { Locale? _localeFromSetting(String? languageCode) {
if (languageCode == null) return null; if (languageCode == null) return null;
return Locale(languageCode); return Locale(languageCode);
+2
View File
@@ -11,6 +11,7 @@ import '../services/app_settings_service.dart';
import '../services/notification_service.dart'; import '../services/notification_service.dart';
import '../services/translation_service.dart'; import '../services/translation_service.dart';
import '../widgets/adaptive_app_bar_title.dart'; import '../widgets/adaptive_app_bar_title.dart';
import '../widgets/sync_progress_overlay.dart';
import '../helpers/snack_bar_builder.dart'; import '../helpers/snack_bar_builder.dart';
import 'map_cache_screen.dart'; import 'map_cache_screen.dart';
@@ -23,6 +24,7 @@ class AppSettingsScreen extends StatelessWidget {
appBar: AppBar( appBar: AppBar(
title: AdaptiveAppBarTitle(context.l10n.appSettings_title), title: AdaptiveAppBarTitle(context.l10n.appSettings_title),
centerTitle: true, centerTitle: true,
bottom: const SyncProgressAppBarBottom(),
), ),
body: SafeArea( body: SafeArea(
top: false, top: false,
+2
View File
@@ -35,6 +35,7 @@ import '../widgets/gif_picker.dart';
import '../widgets/message_translation_button.dart'; import '../widgets/message_translation_button.dart';
import '../widgets/message_status_icon.dart'; import '../widgets/message_status_icon.dart';
import '../widgets/radio_stats_entry.dart'; import '../widgets/radio_stats_entry.dart';
import '../widgets/sync_progress_overlay.dart';
import '../widgets/translated_message_content.dart'; import '../widgets/translated_message_content.dart';
import '../widgets/unread_divider.dart'; import '../widgets/unread_divider.dart';
import 'channel_message_path_screen.dart'; import 'channel_message_path_screen.dart';
@@ -303,6 +304,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
], ],
), ),
centerTitle: false, centerTitle: false,
bottom: const SyncProgressAppBarBottom(),
actions: [ actions: [
const RadioStatsIconButton(), const RadioStatsIconButton(),
PopupMenuButton<String>( PopupMenuButton<String>(
+12 -4
View File
@@ -23,6 +23,7 @@ import '../widgets/list_filter_widget.dart';
import '../widgets/empty_state.dart'; import '../widgets/empty_state.dart';
import '../widgets/qr_code_display.dart'; import '../widgets/qr_code_display.dart';
import '../widgets/quick_switch_bar.dart'; import '../widgets/quick_switch_bar.dart';
import '../widgets/sync_progress_overlay.dart';
import '../widgets/unread_badge.dart'; import '../widgets/unread_badge.dart';
import '../helpers/snack_bar_builder.dart'; import '../helpers/snack_bar_builder.dart';
import 'channel_chat_screen.dart'; import 'channel_chat_screen.dart';
@@ -103,6 +104,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
title: AppBarTitle(context.l10n.channels_title), title: AppBarTitle(context.l10n.channels_title),
centerTitle: true, centerTitle: true,
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
bottom: const SyncProgressAppBarBottom(),
actions: [ actions: [
PopupMenuButton( PopupMenuButton(
itemBuilder: (context) => [ itemBuilder: (context) => [
@@ -152,12 +154,17 @@ class _ChannelsScreenState extends State<ChannelsScreen>
await context.read<MeshCoreConnector>().getChannels(force: true); await context.read<MeshCoreConnector>().getChannels(force: true);
}, },
child: () { child: () {
if (connector.isLoadingChannels) { final channels = connector.channels;
final waitingForFirstChannel =
connector.isLoadingChannels && channels.isEmpty;
// 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()); return const Center(child: CircularProgressIndicator());
} }
final channels = connector.channels;
if (channels.isEmpty) { if (channels.isEmpty) {
return ListView( return ListView(
children: [ children: [
@@ -274,7 +281,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
buildDefaultDragHandles: false, buildDefaultDragHandles: false,
itemCount: filteredChannels.length, itemCount: filteredChannels.length,
onReorderItem: (oldIndex, newIndex) { onReorderItem: (oldIndex, newIndex) {
if (newIndex > oldIndex) newIndex -= 1; // onReorderItem already adjusts newIndex after the
// removed item, unlike the deprecated onReorder.
final reordered = List<Channel>.from( final reordered = List<Channel>.from(
filteredChannels, filteredChannels,
); );
+2
View File
@@ -41,6 +41,7 @@ import '../widgets/gif_picker.dart';
import '../widgets/message_translation_button.dart'; import '../widgets/message_translation_button.dart';
import '../widgets/path_selection_dialog.dart'; import '../widgets/path_selection_dialog.dart';
import '../widgets/radio_stats_entry.dart'; import '../widgets/radio_stats_entry.dart';
import '../widgets/sync_progress_overlay.dart';
import '../widgets/translated_message_content.dart'; import '../widgets/translated_message_content.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
@@ -216,6 +217,7 @@ class _ChatScreenState extends State<ChatScreen> {
}, },
), ),
centerTitle: false, centerTitle: false,
bottom: const SyncProgressAppBarBottom(),
actions: [ actions: [
Consumer<MeshCoreConnector>( Consumer<MeshCoreConnector>(
builder: (context, connector, _) { builder: (context, connector, _) {
+8 -7
View File
@@ -27,6 +27,7 @@ import '../widgets/empty_state.dart';
import '../widgets/quick_switch_bar.dart'; import '../widgets/quick_switch_bar.dart';
import '../widgets/repeater_login_dialog.dart'; import '../widgets/repeater_login_dialog.dart';
import '../widgets/room_login_dialog.dart'; import '../widgets/room_login_dialog.dart';
import '../widgets/sync_progress_overlay.dart';
import '../widgets/unread_badge.dart'; import '../widgets/unread_badge.dart';
import '../helpers/snack_bar_builder.dart'; import '../helpers/snack_bar_builder.dart';
import 'channels_screen.dart'; import 'channels_screen.dart';
@@ -318,6 +319,7 @@ class _ContactsScreenState extends State<ContactsScreen>
appBar: AppBar( appBar: AppBar(
title: AppBarTitle(context.l10n.contacts_title), title: AppBarTitle(context.l10n.contacts_title),
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
bottom: const SyncProgressAppBarBottom(),
actions: [ actions: [
PopupMenuButton( PopupMenuButton(
itemBuilder: (context) => [ itemBuilder: (context) => [
@@ -606,15 +608,14 @@ class _ContactsScreenState extends State<ContactsScreen>
Widget _buildContactsBody(BuildContext context, MeshCoreConnector connector) { Widget _buildContactsBody(BuildContext context, MeshCoreConnector connector) {
final viewState = context.watch<UiViewStateService>(); final viewState = context.watch<UiViewStateService>();
final contacts = connector.contacts; final contacts = connector.contacts;
final shouldShowStartupSpinner = final waitingForInitialContacts =
contacts.isEmpty &&
_groups.isEmpty &&
connector.isConnected && connector.isConnected &&
(connector.isLoadingContacts || !connector.hasLoadedContacts &&
connector.isLoadingChannels || !connector.isLoadingContacts;
connector.selfPublicKey == null); final waitingForFirstContact =
connector.isLoadingContacts && contacts.isEmpty;
if (shouldShowStartupSpinner) { if (waitingForInitialContacts || waitingForFirstContact) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
+2
View File
@@ -23,6 +23,7 @@ import '../services/map_tile_cache_service.dart';
import '../utils/contact_search.dart'; import '../utils/contact_search.dart';
import '../utils/route_transitions.dart'; import '../utils/route_transitions.dart';
import '../widgets/quick_switch_bar.dart'; import '../widgets/quick_switch_bar.dart';
import '../widgets/sync_progress_overlay.dart';
import '../icons/los_icon.dart'; import '../icons/los_icon.dart';
import 'channels_screen.dart'; import 'channels_screen.dart';
import 'chat_screen.dart'; import 'chat_screen.dart';
@@ -414,6 +415,7 @@ class _MapScreenState extends State<MapScreen> {
title: AppBarTitle(context.l10n.map_title), title: AppBarTitle(context.l10n.map_title),
centerTitle: true, centerTitle: true,
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
bottom: const SyncProgressAppBarBottom(),
actions: [ actions: [
if (!_isBuildingPathTrace) if (!_isBuildingPathTrace)
IconButton( IconButton(
+2 -2
View File
@@ -11,7 +11,7 @@ import '../utils/app_logger.dart';
import '../widgets/adaptive_app_bar_title.dart'; import '../widgets/adaptive_app_bar_title.dart';
import '../widgets/device_tile.dart'; import '../widgets/device_tile.dart';
import '../helpers/snack_bar_builder.dart'; import '../helpers/snack_bar_builder.dart';
import 'contacts_screen.dart'; import 'channels_screen.dart';
import 'tcp_screen.dart'; import 'tcp_screen.dart';
import 'usb_screen.dart'; import 'usb_screen.dart';
@@ -46,7 +46,7 @@ class _ScannerScreenState extends State<ScannerScreen> {
_changedNavigation = true; _changedNavigation = true;
if (mounted) { if (mounted) {
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const ContactsScreen()), MaterialPageRoute(builder: (context) => const ChannelsScreen()),
); );
} }
} }
+2
View File
@@ -16,6 +16,7 @@ import 'app_settings_screen.dart';
import 'app_debug_log_screen.dart'; import 'app_debug_log_screen.dart';
import 'ble_debug_log_screen.dart'; import 'ble_debug_log_screen.dart';
import '../widgets/radio_stats_entry.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) /// Convert device coding-rate value (1-4 on some firmware, 5-8 on others)
/// to the UI enum range (always 5-8). /// to the UI enum range (always 5-8).
@@ -67,6 +68,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
indicators: false, indicators: false,
subtitle: false, subtitle: false,
), ),
bottom: const SyncProgressAppBarBottom(),
), ),
body: SafeArea( body: SafeArea(
top: false, top: false,
+7 -7
View File
@@ -9,7 +9,7 @@ import '../services/app_settings_service.dart';
import '../utils/platform_info.dart'; import '../utils/platform_info.dart';
import '../widgets/adaptive_app_bar_title.dart'; import '../widgets/adaptive_app_bar_title.dart';
import '../helpers/snack_bar_builder.dart'; import '../helpers/snack_bar_builder.dart';
import 'contacts_screen.dart'; import 'channels_screen.dart';
import 'usb_screen.dart'; import 'usb_screen.dart';
class TcpScreen extends StatefulWidget { class TcpScreen extends StatefulWidget {
@@ -24,7 +24,7 @@ class _TcpScreenState extends State<TcpScreen> {
late final TextEditingController _portController; late final TextEditingController _portController;
late final MeshCoreConnector _connector; late final MeshCoreConnector _connector;
late final VoidCallback _connectionListener; late final VoidCallback _connectionListener;
bool _navigatedToContacts = false; bool _navigatedToChannels = false;
@override @override
void initState() { void initState() {
@@ -42,20 +42,20 @@ class _TcpScreenState extends State<TcpScreen> {
_connectionListener = () { _connectionListener = () {
if (!mounted) return; if (!mounted) return;
if (_connector.state == MeshCoreConnectionState.disconnected) { if (_connector.state == MeshCoreConnectionState.disconnected) {
_navigatedToContacts = false; _navigatedToChannels = false;
} }
if (_connector.state == MeshCoreConnectionState.connected && if (_connector.state == MeshCoreConnectionState.connected &&
_connector.isTcpTransportConnected && _connector.isTcpTransportConnected &&
!_navigatedToContacts) { !_navigatedToChannels) {
context.read<AppSettingsService>().setTcpServerAddress( context.read<AppSettingsService>().setTcpServerAddress(
_hostController.text, _hostController.text,
); );
context.read<AppSettingsService>().setTcpServerPort( context.read<AppSettingsService>().setTcpServerPort(
int.tryParse(_portController.text) ?? 0, int.tryParse(_portController.text) ?? 0,
); );
_navigatedToContacts = true; _navigatedToChannels = true;
Navigator.of(context).pushReplacement( Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const ContactsScreen()), MaterialPageRoute(builder: (_) => const ChannelsScreen()),
); );
} }
}; };
@@ -67,7 +67,7 @@ class _TcpScreenState extends State<TcpScreen> {
_hostController.dispose(); _hostController.dispose();
_portController.dispose(); _portController.dispose();
_connector.removeListener(_connectionListener); _connector.removeListener(_connectionListener);
if (!_navigatedToContacts && if (!_navigatedToChannels &&
_connector.activeTransport == MeshCoreTransportType.tcp && _connector.activeTransport == MeshCoreTransportType.tcp &&
_connector.state != MeshCoreConnectionState.disconnected) { _connector.state != MeshCoreConnectionState.disconnected) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
+2
View File
@@ -15,6 +15,7 @@ import '../widgets/path_management_dialog.dart';
import '../helpers/cayenne_lpp.dart'; import '../helpers/cayenne_lpp.dart';
import '../utils/battery_utils.dart'; import '../utils/battery_utils.dart';
import '../helpers/snack_bar_builder.dart'; import '../helpers/snack_bar_builder.dart';
import '../widgets/sync_progress_overlay.dart';
class TelemetryScreen extends StatefulWidget { class TelemetryScreen extends StatefulWidget {
final Contact contact; final Contact contact;
@@ -239,6 +240,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
], ],
), ),
centerTitle: false, centerTitle: false,
bottom: const SyncProgressAppBarBottom(),
actions: [ actions: [
PopupMenuButton<String>( PopupMenuButton<String>(
icon: Icon(isFloodMode ? Icons.waves : Icons.route), icon: Icon(isFloodMode ? Icons.waves : Icons.route),
+7 -7
View File
@@ -11,7 +11,7 @@ import '../utils/platform_info.dart';
import '../utils/usb_port_labels.dart'; import '../utils/usb_port_labels.dart';
import '../widgets/adaptive_app_bar_title.dart'; import '../widgets/adaptive_app_bar_title.dart';
import '../helpers/snack_bar_builder.dart'; import '../helpers/snack_bar_builder.dart';
import 'contacts_screen.dart'; import 'channels_screen.dart';
import 'scanner_screen.dart'; import 'scanner_screen.dart';
import 'tcp_screen.dart'; import 'tcp_screen.dart';
@@ -25,7 +25,7 @@ class UsbScreen extends StatefulWidget {
class _UsbScreenState extends State<UsbScreen> { class _UsbScreenState extends State<UsbScreen> {
final List<String> _ports = <String>[]; final List<String> _ports = <String>[];
bool _isLoadingPorts = true; bool _isLoadingPorts = true;
bool _navigatedToContacts = false; bool _navigatedToChannels = false;
bool _didScheduleInitialLoad = false; bool _didScheduleInitialLoad = false;
Timer? _hotPlugTimer; Timer? _hotPlugTimer;
late final MeshCoreConnector _connector; late final MeshCoreConnector _connector;
@@ -41,14 +41,14 @@ class _UsbScreenState extends State<UsbScreen> {
_connectionListener = () { _connectionListener = () {
if (!mounted) return; if (!mounted) return;
if (_connector.state == MeshCoreConnectionState.disconnected) { if (_connector.state == MeshCoreConnectionState.disconnected) {
_navigatedToContacts = false; _navigatedToChannels = false;
} }
if (_connector.state == MeshCoreConnectionState.connected && if (_connector.state == MeshCoreConnectionState.connected &&
_connector.isUsbTransportConnected && _connector.isUsbTransportConnected &&
!_navigatedToContacts) { !_navigatedToChannels) {
_navigatedToContacts = true; _navigatedToChannels = true;
Navigator.of(context).pushReplacement( Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const ContactsScreen()), MaterialPageRoute(builder: (_) => const ChannelsScreen()),
); );
} }
}; };
@@ -72,7 +72,7 @@ class _UsbScreenState extends State<UsbScreen> {
_hotPlugTimer?.cancel(); _hotPlugTimer?.cancel();
_hotPlugTimer = null; _hotPlugTimer = null;
_connector.removeListener(_connectionListener); _connector.removeListener(_connectionListener);
if (!_navigatedToContacts && if (!_navigatedToChannels &&
_connector.activeTransport == MeshCoreTransportType.usb && _connector.activeTransport == MeshCoreTransportType.usb &&
_connector.state != MeshCoreConnectionState.disconnected) { _connector.state != MeshCoreConnectionState.disconnected) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
+60
View File
@@ -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<MeshCoreConnector>(
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;
}
}