Merge branch 'dev' into dev

This commit is contained in:
PacoX
2026-05-28 08:51:23 +02:00
committed by GitHub
60 changed files with 1450 additions and 325 deletions
+418 -155
View File
@@ -234,6 +234,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;
@@ -255,10 +256,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;
@@ -273,13 +277,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;
@@ -337,6 +346,8 @@ class MeshCoreConnector extends ChangeNotifier {
final Map<String, int> _contactUnreadCount = {}; final Map<String, int> _contactUnreadCount = {};
final Map<String, RepeaterBatterySnapshot> _repeaterBatterySnapshots = {}; final Map<String, RepeaterBatterySnapshot> _repeaterBatterySnapshots = {};
bool _unreadStateLoaded = false; bool _unreadStateLoaded = false;
int _cachedContactsUnreadTotal = 0;
int _cachedChannelsUnreadTotal = 0;
final Map<String, _RepeaterAckContext> _pendingRepeaterAcks = {}; final Map<String, _RepeaterAckContext> _pendingRepeaterAcks = {};
String? _activeContactKey; String? _activeContactKey;
int? _activeChannelIndex; int? _activeChannelIndex;
@@ -406,7 +417,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));
@@ -469,7 +482,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
@@ -641,16 +663,42 @@ class MeshCoreConnector extends ChangeNotifier {
int getTotalUnreadCount() { int getTotalUnreadCount() {
if (!_unreadStateLoaded) return 0; if (!_unreadStateLoaded) return 0;
var total = 0; return getTotalContactsUnreadCount() + getTotalChannelsUnreadCount();
// Count unread contact messages }
for (final contact in _contacts) {
total += getUnreadCountForContact(contact); int getTotalContactsUnreadCount() {
} if (!_unreadStateLoaded) return 0;
// Count unread channel messages return _cachedContactsUnreadTotal;
for (final channelIndex in _channelMessages.keys) { }
total += getUnreadCountForChannelIndex(channelIndex);
} int getTotalChannelsUnreadCount() {
return total; if (!_unreadStateLoaded) return 0;
return _cachedChannelsUnreadTotal;
}
/// Recalculates both cached unread totals from scratch.
/// Called when unread state is first loaded.
void _recalculateCachedUnreadTotals() {
_recalculateCachedContactsUnreadTotal();
_recalculateCachedChannelsUnreadTotal();
}
void _recalculateCachedContactsUnreadTotal() {
int total = 0;
_contactUnreadCount.forEach((contactKeyHex, count) {
if (_shouldTrackUnreadForContactKey(contactKeyHex)) {
total += count;
}
});
_cachedContactsUnreadTotal = total;
}
void _recalculateCachedChannelsUnreadTotal() {
final allChannels = _channels.isNotEmpty ? _channels : _cachedChannels;
_cachedChannelsUnreadTotal = allChannels.fold(
0,
(total, ch) => total + ch.unreadCount,
);
} }
bool isChannelSmazEnabled(int channelIndex) { bool isChannelSmazEnabled(int channelIndex) {
@@ -684,11 +732,13 @@ class MeshCoreConnector extends ChangeNotifier {
..clear() ..clear()
..addAll(await _unreadStore.loadContactUnreadCount()); ..addAll(await _unreadStore.loadContactUnreadCount());
_unreadStateLoaded = true; _unreadStateLoaded = true;
_recalculateCachedUnreadTotals();
notifyListeners(); notifyListeners();
} }
Future<void> loadCachedChannels() async { Future<void> loadCachedChannels() async {
_cachedChannels = await _channelStore.loadChannels(); _cachedChannels = await _channelStore.loadChannels();
_recalculateCachedChannelsUnreadTotal();
} }
void setActiveContact(String? contactKeyHex) { void setActiveContact(String? contactKeyHex) {
@@ -715,6 +765,8 @@ class MeshCoreConnector extends ChangeNotifier {
final previousCount = _contactUnreadCount[contactKeyHex] ?? 0; final previousCount = _contactUnreadCount[contactKeyHex] ?? 0;
if (previousCount > 0) { if (previousCount > 0) {
_contactUnreadCount[contactKeyHex] = 0; _contactUnreadCount[contactKeyHex] = 0;
_cachedContactsUnreadTotal = (_cachedContactsUnreadTotal - previousCount)
.clamp(0, _cachedContactsUnreadTotal);
_appDebugLogService?.info( _appDebugLogService?.info(
'Contact $contactKeyHex marked as read (was $previousCount unread)', 'Contact $contactKeyHex marked as read (was $previousCount unread)',
tag: 'Unread', tag: 'Unread',
@@ -756,6 +808,8 @@ class MeshCoreConnector extends ChangeNotifier {
if (channel != null && channel.unreadCount > 0) { if (channel != null && channel.unreadCount > 0) {
final previousCount = channel.unreadCount; final previousCount = channel.unreadCount;
channel.unreadCount = 0; channel.unreadCount = 0;
_cachedChannelsUnreadTotal = (_cachedChannelsUnreadTotal - previousCount)
.clamp(0, _cachedChannelsUnreadTotal);
_appDebugLogService?.info( _appDebugLogService?.info(
'Channel ${channel.name.isNotEmpty ? channel.name : channelIndex} marked as read (was $previousCount unread)', 'Channel ${channel.name.isNotEmpty ? channel.name : channelIndex} marked as read (was $previousCount unread)',
tag: 'Unread', tag: 'Unread',
@@ -1107,19 +1161,31 @@ class MeshCoreConnector extends ChangeNotifier {
} }
} }
Future<void> _translateIncomingContactMessage( Future<TranslationResult?> translateContactMessage(
String contactKeyHex, String contactKeyHex,
Message message, Message message, {
) async { bool manualTranslation = false,
}) async {
try { try {
if (message.translatedText?.trim().isNotEmpty == true ||
(!manualTranslation &&
message.translationStatus != MessageTranslationStatus.none)) {
return null;
}
final service = _translationService; final service = _translationService;
if (service == null || if (service == null ||
!service.shouldTranslateIncoming( !(manualTranslation
text: message.text, ? service.canTranslateIncoming(
isCli: message.isCli, text: message.text,
isOutgoing: message.isOutgoing, isCli: message.isCli,
)) { isOutgoing: message.isOutgoing,
return; )
: service.shouldAutoTranslateIncoming(
text: message.text,
isCli: message.isCli,
isOutgoing: message.isOutgoing,
))) {
return null;
} }
final targetLanguageCode = service.resolvedIncomingLanguageCode( final targetLanguageCode = service.resolvedIncomingLanguageCode(
_appSettingsService?.settings.languageOverride, _appSettingsService?.settings.languageOverride,
@@ -1129,7 +1195,7 @@ class MeshCoreConnector extends ChangeNotifier {
targetLanguageCode: targetLanguageCode, targetLanguageCode: targetLanguageCode,
); );
if (result == null) { if (result == null) {
return; return null;
} }
final translated = result.status == MessageTranslationStatus.completed final translated = result.status == MessageTranslationStatus.completed
? result.translatedText ? result.translatedText
@@ -1144,24 +1210,38 @@ class MeshCoreConnector extends ChangeNotifier {
translationModelId: result.modelId, translationModelId: result.modelId,
), ),
); );
return result;
} catch (error) { } catch (error) {
appLogger.warn('Translation failed for contact message: $error'); appLogger.warn('Translation failed for contact message: $error');
return null;
} }
} }
Future<void> _translateIncomingChannelMessage( Future<TranslationResult?> translateChannelMessage(
int channelIndex, int channelIndex,
ChannelMessage message, ChannelMessage message, {
) async { bool manualTranslation = false,
}) async {
try { try {
if (message.translatedText?.trim().isNotEmpty == true ||
(!manualTranslation &&
message.translationStatus != MessageTranslationStatus.none)) {
return null;
}
final service = _translationService; final service = _translationService;
if (service == null || if (service == null ||
!service.shouldTranslateIncoming( !(manualTranslation
text: message.text, ? service.canTranslateIncoming(
isCli: false, text: message.text,
isOutgoing: message.isOutgoing, isCli: false,
)) { isOutgoing: message.isOutgoing,
return; )
: service.shouldAutoTranslateIncoming(
text: message.text,
isCli: false,
isOutgoing: message.isOutgoing,
))) {
return null;
} }
final targetLanguageCode = service.resolvedIncomingLanguageCode( final targetLanguageCode = service.resolvedIncomingLanguageCode(
_appSettingsService?.settings.languageOverride, _appSettingsService?.settings.languageOverride,
@@ -1171,11 +1251,16 @@ class MeshCoreConnector extends ChangeNotifier {
targetLanguageCode: targetLanguageCode, targetLanguageCode: targetLanguageCode,
); );
if (result == null) { if (result == null) {
return; return null;
} }
final translated = result.status == MessageTranslationStatus.completed var translated = result.status == MessageTranslationStatus.completed
? result.translatedText ? result.translatedText
: null; : null;
// Strip replyInfo prefix from translated text to match stored message.text
if (translated != null) {
final regex = RegExp(r'^@\[[^\]]+\]\s+', dotAll: true);
translated = translated.replaceFirst(regex, '');
}
_updateStoredChannelMessage( _updateStoredChannelMessage(
channelIndex, channelIndex,
message.messageId, message.messageId,
@@ -1186,8 +1271,10 @@ class MeshCoreConnector extends ChangeNotifier {
translationModelId: result.modelId, translationModelId: result.modelId,
), ),
); );
return result;
} catch (error) { } catch (error) {
appLogger.warn('Translation failed for channel message: $error'); appLogger.warn('Translation failed for channel message: $error');
return null;
} }
} }
@@ -1516,6 +1603,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',
@@ -1626,6 +1715,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();
@@ -2260,7 +2351,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();
@@ -2275,7 +2368,7 @@ class MeshCoreConnector extends ChangeNotifier {
} }
await syncTime(); await syncTime();
unawaited(getChannels()); _maybeStartInitialChannelSync();
} }
void _resetConnectionHandshakeState() { void _resetConnectionHandshakeState() {
@@ -2288,11 +2381,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 =>
@@ -2429,17 +2550,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;
@@ -2717,10 +2830,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));
} }
@@ -3205,6 +3322,9 @@ class MeshCoreConnector extends ChangeNotifier {
unawaited(_persistContacts()); unawaited(_persistContacts());
_conversations.remove(contact.publicKeyHex); _conversations.remove(contact.publicKeyHex);
_loadedConversationKeys.remove(contact.publicKeyHex); _loadedConversationKeys.remove(contact.publicKeyHex);
final removedCount = _contactUnreadCount[contact.publicKeyHex] ?? 0;
_cachedContactsUnreadTotal = (_cachedContactsUnreadTotal - removedCount)
.clamp(0, _cachedContactsUnreadTotal);
_contactUnreadCount.remove(contact.publicKeyHex); _contactUnreadCount.remove(contact.publicKeyHex);
_unreadStore.saveContactUnreadCount( _unreadStore.saveContactUnreadCount(
Map<String, int>.from(_contactUnreadCount), Map<String, int>.from(_contactUnreadCount),
@@ -3342,11 +3462,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();
} }
@@ -3380,6 +3509,8 @@ class MeshCoreConnector extends ChangeNotifier {
_isSyncingQueuedMessages = false; _isSyncingQueuedMessages = false;
_queueSyncTimeout?.cancel(); _queueSyncTimeout?.cancel();
_queueSyncRetries = 0; _queueSyncRetries = 0;
notifyListeners();
_continueAfterQueuedMessageSync();
} }
} }
@@ -3399,6 +3530,8 @@ class MeshCoreConnector extends ChangeNotifier {
_queuedMessageSyncInFlight = false; _queuedMessageSyncInFlight = false;
_isSyncingQueuedMessages = false; _isSyncingQueuedMessages = false;
_queueSyncRetries = 0; _queueSyncRetries = 0;
notifyListeners();
_continueAfterQueuedMessageSync();
} }
} }
@@ -3498,6 +3631,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;
@@ -3587,6 +3721,7 @@ class MeshCoreConnector extends ChangeNotifier {
_nextChannelIndexToRequest++; _nextChannelIndexToRequest++;
_channelSyncRetries = 0; _channelSyncRetries = 0;
_channelSyncInFlight = false; _channelSyncInFlight = false;
notifyListeners();
unawaited(_requestNextChannel()); unawaited(_requestNextChannel());
} }
} }
@@ -3603,6 +3738,7 @@ class MeshCoreConnector extends ChangeNotifier {
// Cache channels for offline use // Cache channels for offline use
_cachedChannels = List<Channel>.from(_channels); _cachedChannels = List<Channel>.from(_channels);
unawaited(_channelStore.saveChannels(_channels)); unawaited(_channelStore.saveChannels(_channels));
_recalculateCachedChannelsUnreadTotal();
// Apply ordering and notify UI // Apply ordering and notify UI
_applyChannelOrder(); _applyChannelOrder();
@@ -3621,16 +3757,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 {
@@ -3682,6 +3833,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:
@@ -3699,7 +3864,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());
@@ -3709,23 +3876,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:
@@ -3909,23 +4074,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) {
@@ -3969,7 +4119,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));
} }
} }
@@ -3984,12 +4134,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() {
@@ -3998,6 +4149,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() {
@@ -4006,6 +4215,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());
} }
@@ -4147,11 +4357,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;
} }
@@ -4159,6 +4373,9 @@ class MeshCoreConnector extends ChangeNotifier {
_handleDiscovery(contact, frame, noNotify: true, addActive: true); _handleDiscovery(contact, frame, noNotify: true, addActive: true);
if (contact.type == advTypeRepeater) { if (contact.type == advTypeRepeater) {
final removedCount = _contactUnreadCount[contact.publicKeyHex] ?? 0;
_cachedContactsUnreadTotal = (_cachedContactsUnreadTotal - removedCount)
.clamp(0, _cachedContactsUnreadTotal);
_contactUnreadCount.remove(contact.publicKeyHex); _contactUnreadCount.remove(contact.publicKeyHex);
_unreadStore.saveContactUnreadCount( _unreadStore.saveContactUnreadCount(
Map<String, int>.from(_contactUnreadCount), Map<String, int>.from(_contactUnreadCount),
@@ -4212,6 +4429,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;
} }
} }
@@ -4249,6 +4467,9 @@ class MeshCoreConnector extends ChangeNotifier {
} }
if (contact.type == advTypeRepeater) { if (contact.type == advTypeRepeater) {
final removedCount = _contactUnreadCount[contact.publicKeyHex] ?? 0;
_cachedContactsUnreadTotal = (_cachedContactsUnreadTotal - removedCount)
.clamp(0, _cachedContactsUnreadTotal);
_contactUnreadCount.remove(contact.publicKeyHex); _contactUnreadCount.remove(contact.publicKeyHex);
_unreadStore.saveContactUnreadCount( _unreadStore.saveContactUnreadCount(
Map<String, int>.from(_contactUnreadCount), Map<String, int>.from(_contactUnreadCount),
@@ -4426,9 +4647,15 @@ 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
// before any potentially long async work (like translation/notifications).
if (_isSyncingQueuedMessages) {
_handleQueuedMessageReceived();
}
var message = _parseContactMessage(frame); var message = _parseContactMessage(frame);
// If message parsing failed due to unknown contact, refresh contacts and retry // If message parsing failed due to unknown contact, refresh contacts and retry
@@ -4494,37 +4721,52 @@ class MeshCoreConnector extends ChangeNotifier {
} }
} }
_addMessage(message.senderKeyHex, message); _addMessage(message.senderKeyHex, message);
if (!message.isOutgoing) {
unawaited(
_translateIncomingContactMessage(message.senderKeyHex, message),
);
}
_maybeIncrementContactUnread(message); _maybeIncrementContactUnread(message);
notifyListeners(); notifyListeners();
// Show notification for new incoming message // Show notification for new incoming message (run async with translation)
if (!message.isOutgoing && if (!message.isOutgoing &&
!message.isCli && !message.isCli &&
_appSettingsService != null) { _appSettingsService != null) {
final settings = _appSettingsService!.settings; final settings = _appSettingsService!.settings;
if (settings.notificationsEnabled && settings.notifyOnNewMessage) { if (settings.notificationsEnabled && settings.notifyOnNewMessage) {
if (contact?.type == advTypeChat) { final msg = message; // capture for closure
_notificationService.showMessageNotification( final c = contact; // capture contact reference
contactName: contact?.name ?? 'Unknown', unawaited(() async {
message: message.text, final translationResult = await translateContactMessage(
contactId: message.senderKeyHex, msg.senderKeyHex,
badgeCount: getTotalUnreadCount(), msg,
); );
} else if (contact?.type == advTypeRoom) { if (c?.type == advTypeChat) {
_notificationService.showMessageNotification( final resolvedText =
contactName: contact?.name ?? 'Unknown Room', (translationResult != null &&
message: message.text.length > 4 translationResult.status ==
? message.text.substring(4) MessageTranslationStatus.completed &&
: message.text, translationResult.translatedText.trim().isNotEmpty)
contactId: message.senderKeyHex, ? translationResult.translatedText.trim()
badgeCount: getTotalUnreadCount(), : msg.text.trim();
); await _notificationService.showMessageNotification(
} contactName: c?.name ?? 'Unknown',
message: resolvedText,
contactId: msg.senderKeyHex,
badgeCount: getTotalUnreadCount(),
);
} else if (c?.type == advTypeRoom) {
final resolvedText =
(translationResult != null &&
translationResult.status ==
MessageTranslationStatus.completed &&
translationResult.translatedText.trim().isNotEmpty)
? translationResult.translatedText.trim()
: msg.text.trim();
await _notificationService.showMessageNotification(
contactName: c?.name ?? 'Unknown Room',
message: resolvedText,
contactId: msg.senderKeyHex,
badgeCount: getTotalUnreadCount(),
);
}
}());
} }
} }
_handleQueuedMessageReceived(); _handleQueuedMessageReceived();
@@ -4567,16 +4809,24 @@ class MeshCoreConnector extends ChangeNotifier {
timestampRaw * 1000, timestampRaw * 1000,
); );
if (txtType == 2) { final flags = txtType;
reader.skipBytes(4); // Skip extra 4 bytes for signed/plain variants final shiftedType = flags >> 2;
final rawType = flags;
final isSigned = shiftedType == txtTypeSigned || rawType == txtTypeSigned;
final Uint8List? roomAuthorPrefix;
if (isSigned) {
// Room-server pushed posts use signed/plain contact messages where this
// 4-byte "signature" field is actually the original author's pubkey
// prefix. Keep it as metadata; the text starts after these bytes.
roomAuthorPrefix = reader.readBytes(4);
} else {
roomAuthorPrefix = null;
} }
final msgText = reader.readCString(); final msgText = reader.readCString();
final flags = txtType; final isPlain =
final shiftedType = flags >> 2; shiftedType == txtTypePlain || rawType == txtTypePlain || isSigned;
final rawType = flags;
final isPlain = shiftedType == txtTypePlain || rawType == txtTypePlain;
final isCli = shiftedType == txtTypeCliData || rawType == txtTypeCliData; final isCli = shiftedType == txtTypeCliData || rawType == txtTypeCliData;
if (!isPlain && !isCli) { if (!isPlain && !isCli) {
appLogger.warn( appLogger.warn(
@@ -4613,9 +4863,7 @@ class MeshCoreConnector extends ChangeNotifier {
status: MessageStatus.delivered, status: MessageStatus.delivered,
pathLength: pathLength == 0xFF ? -1 : (pathLength & 0x3F), pathLength: pathLength == 0xFF ? -1 : (pathLength & 0x3F),
pathBytes: Uint8List(0), pathBytes: Uint8List(0),
fourByteRoomContactKey: msgText.length >= 4 fourByteRoomContactKey: roomAuthorPrefix,
? Uint8List.fromList(msgText.substring(0, 4).codeUnits)
: null,
); );
} catch (e) { } catch (e) {
appLogger.warn('Error parsing contact direct message: $e'); appLogger.warn('Error parsing contact direct message: $e');
@@ -4792,6 +5040,7 @@ class MeshCoreConnector extends ChangeNotifier {
void _maybeNotifyChannelMessage( void _maybeNotifyChannelMessage(
ChannelMessage message, { ChannelMessage message, {
String? channelName, String? channelName,
TranslationResult? translationResult,
}) { }) {
if (message.isOutgoing || _appSettingsService == null) return; if (message.isOutgoing || _appSettingsService == null) return;
final channelIndex = message.channelIndex; final channelIndex = message.channelIndex;
@@ -4805,16 +5054,30 @@ class MeshCoreConnector extends ChangeNotifier {
final label = channelName ?? _channelDisplayName(channelIndex); final label = channelName ?? _channelDisplayName(channelIndex);
if (_appSettingsService!.isChannelMuted(label)) return; if (_appSettingsService!.isChannelMuted(label)) return;
_notificationService.showChannelMessageNotification( // Reuse translation result only if completed and non-empty; else use original text
channelName: label, final resolvedText =
senderName: message.senderName, (translationResult != null &&
message: message.text, translationResult.status == MessageTranslationStatus.completed &&
channelIndex: channelIndex, translationResult.translatedText.trim().isNotEmpty)
badgeCount: getTotalUnreadCount(), ? translationResult.translatedText.trim()
); : message.text.trim();
unawaited(() async {
await _notificationService.showChannelMessageNotification(
channelName: label,
senderName: message.senderName,
message: resolvedText,
channelIndex: message.channelIndex,
badgeCount: getTotalUnreadCount(),
);
}());
} }
void _handleIncomingChannelMessage(Uint8List frame) { void _handleIncomingChannelMessage(Uint8List frame) async {
// If we're syncing the queued messages, advance the queue immediately
// before any potentially long async work (like translation/notifications).
if (_isSyncingQueuedMessages) {
_handleQueuedMessageReceived();
}
final parsed = ChannelMessage.fromFrame(frame); final parsed = ChannelMessage.fromFrame(frame);
if (parsed != null && parsed.channelIndex != null) { if (parsed != null && parsed.channelIndex != null) {
if (_shouldDropSelfChannelMessage(parsed.senderName, parsed.pathBytes)) { if (_shouldDropSelfChannelMessage(parsed.senderName, parsed.pathBytes)) {
@@ -4834,15 +5097,17 @@ class MeshCoreConnector extends ChangeNotifier {
pathHashWidth: message.pathHashWidth, pathHashWidth: message.pathHashWidth,
); );
final isNew = _addChannelMessage(message.channelIndex!, message); final isNew = _addChannelMessage(message.channelIndex!, message);
if (isNew && !message.isOutgoing) {
unawaited(
_translateIncomingChannelMessage(message.channelIndex!, message),
);
}
_maybeIncrementChannelUnread(message, isNew: isNew); _maybeIncrementChannelUnread(message, isNew: isNew);
notifyListeners(); notifyListeners();
if (isNew) { if (isNew && !message.isOutgoing) {
_maybeNotifyChannelMessage(message); final msg = message; // capture for closure
unawaited(() async {
final translationResult = await translateChannelMessage(
msg.channelIndex!,
msg,
);
_maybeNotifyChannelMessage(msg, translationResult: translationResult);
}());
} }
_handleQueuedMessageReceived(); _handleQueuedMessageReceived();
} else if (_isSyncingQueuedMessages) { } else if (_isSyncingQueuedMessages) {
@@ -4850,7 +5115,7 @@ class MeshCoreConnector extends ChangeNotifier {
} }
} }
void _handleLogRxData(Uint8List frame) { void _handleLogRxData(Uint8List frame) async {
if (frame.length < 4) return; if (frame.length < 4) return;
try { try {
final reader = BufferReader(frame); final reader = BufferReader(frame);
@@ -4920,16 +5185,24 @@ class MeshCoreConnector extends ChangeNotifier {
pathHashWidth: message.pathHashWidth, pathHashWidth: message.pathHashWidth,
); );
final isNew = _addChannelMessage(channel.index, message); final isNew = _addChannelMessage(channel.index, message);
if (isNew && !message.isOutgoing) {
unawaited(_translateIncomingChannelMessage(channel.index, message));
}
_maybeIncrementChannelUnread(message, isNew: isNew); _maybeIncrementChannelUnread(message, isNew: isNew);
notifyListeners(); notifyListeners();
if (isNew) { if (isNew) {
final label = channel.name.isEmpty // Run translation + notification asynchronously to avoid blocking
? 'Channel ${channel.index}' unawaited(() async {
: channel.name; final translationResult = await translateChannelMessage(
_maybeNotifyChannelMessage(message, channelName: label); channel.index,
message,
);
final label = channel.name.isEmpty
? 'Channel ${channel.index}'
: channel.name;
_maybeNotifyChannelMessage(
message,
channelName: label,
translationResult: translationResult,
);
}());
} }
return; return;
} catch (e) { } catch (e) {
@@ -5147,14 +5420,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 {
@@ -5273,6 +5539,7 @@ class MeshCoreConnector extends ChangeNotifier {
final channel = _findChannelByIndex(channelIndex); final channel = _findChannelByIndex(channelIndex);
if (channel != null) { if (channel != null) {
channel.unreadCount++; channel.unreadCount++;
_cachedChannelsUnreadTotal++;
_appDebugLogService?.info( _appDebugLogService?.info(
'Channel ${channel.name.isNotEmpty ? channel.name : channelIndex} unread count incremented to ${channel.unreadCount}', 'Channel ${channel.name.isNotEmpty ? channel.name : channelIndex} unread count incremented to ${channel.unreadCount}',
tag: 'Unread', tag: 'Unread',
@@ -5317,6 +5584,7 @@ class MeshCoreConnector extends ChangeNotifier {
final currentCount = _contactUnreadCount[contactKey] ?? 0; final currentCount = _contactUnreadCount[contactKey] ?? 0;
_contactUnreadCount[contactKey] = currentCount + 1; _contactUnreadCount[contactKey] = currentCount + 1;
_cachedContactsUnreadTotal++;
_appDebugLogService?.info( _appDebugLogService?.info(
'Contact $contactKey unread count incremented to ${currentCount + 1}', 'Contact $contactKey unread count incremented to ${currentCount + 1}',
tag: 'Unread', tag: 'Unread',
@@ -5854,14 +6122,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;
+3
View File
@@ -2099,6 +2099,9 @@
"translation_composerTitle": "Преведете преди да изпратите", "translation_composerTitle": "Преведете преди да изпратите",
"translation_enableSubtitle": "Превеждайте входящите съобщения и позволявайте предварително превеждане преди изпращане.", "translation_enableSubtitle": "Превеждайте входящите съобщения и позволявайте предварително превеждане преди изпращане.",
"translation_composerSubtitle": "Контролира началния статус на иконата за превод, създадена от композитора.", "translation_composerSubtitle": "Контролира началния статус на иконата за превод, създадена от композитора.",
"translation_autoIncomingTitle": "Автоматичен превод на съобщения",
"translation_autoIncomingSubtitle": "Превежда автоматично съобщенията за известия, както и за чатове или канали.",
"translation_translateMessage": "Преведи съобщението",
"translation_targetLanguage": "Целеви език", "translation_targetLanguage": "Целеви език",
"translation_useAppLanguage": "Използвайте езика на приложението", "translation_useAppLanguage": "Използвайте езика на приложението",
"translation_downloadedModelLabel": "Изтегнат модел", "translation_downloadedModelLabel": "Изтегнат модел",
+3
View File
@@ -2127,6 +2127,9 @@
"translation_enableSubtitle": "Nachrichten empfangen und übersetzen sowie die Möglichkeit bieten, Nachrichten vor dem Versenden zu übersetzen.", "translation_enableSubtitle": "Nachrichten empfangen und übersetzen sowie die Möglichkeit bieten, Nachrichten vor dem Versenden zu übersetzen.",
"translation_enableTitle": "Aktivieren Sie die Übersetzung", "translation_enableTitle": "Aktivieren Sie die Übersetzung",
"translation_composerSubtitle": "Steuert den Standardzustand des Icons für die Übersetzung des Komponisten.", "translation_composerSubtitle": "Steuert den Standardzustand des Icons für die Übersetzung des Komponisten.",
"translation_autoIncomingTitle": "Nachrichten automatisch übersetzen",
"translation_autoIncomingSubtitle": "Übersetzt Nachrichten für Benachrichtigungen sowie für Chats oder Kanäle automatisch.",
"translation_translateMessage": "Nachricht übersetzen",
"translation_targetLanguage": "Zielsprache", "translation_targetLanguage": "Zielsprache",
"translation_useAppLanguage": "Verwenden Sie die App-Sprache", "translation_useAppLanguage": "Verwenden Sie die App-Sprache",
"translation_downloadedModelLabel": "Heruntergeladenes Modell", "translation_downloadedModelLabel": "Heruntergeladenes Modell",
+3
View File
@@ -2292,6 +2292,9 @@
"translation_enableSubtitle": "Translate incoming messages and allow pre-send translation.", "translation_enableSubtitle": "Translate incoming messages and allow pre-send translation.",
"translation_composerTitle": "Translate before sending", "translation_composerTitle": "Translate before sending",
"translation_composerSubtitle": "Controls the default state of the composer translation icon.", "translation_composerSubtitle": "Controls the default state of the composer translation icon.",
"translation_autoIncomingTitle": "Auto-translate incoming messages",
"translation_autoIncomingSubtitle": "Translates Messages for notification and for chat or channel automatically.",
"translation_translateMessage": "Translate message",
"translation_targetLanguage": "Target language", "translation_targetLanguage": "Target language",
"translation_useAppLanguage": "Use app language", "translation_useAppLanguage": "Use app language",
"translation_downloadedModelLabel": "Downloaded model", "translation_downloadedModelLabel": "Downloaded model",
+3
View File
@@ -2128,6 +2128,9 @@
"translation_enableTitle": "Habilitar la traducción", "translation_enableTitle": "Habilitar la traducción",
"translation_composerTitle": "Traducir antes de enviar", "translation_composerTitle": "Traducir antes de enviar",
"translation_composerSubtitle": "Controla el estado predeterminado del icono de traducción del compositor.", "translation_composerSubtitle": "Controla el estado predeterminado del icono de traducción del compositor.",
"translation_autoIncomingTitle": "Traducir mensajes automáticamente",
"translation_autoIncomingSubtitle": "Traduce mensajes para notificaciones y para chats o canales automáticamente.",
"translation_translateMessage": "Traducir mensaje",
"translation_targetLanguage": "Idioma de destino", "translation_targetLanguage": "Idioma de destino",
"translation_useAppLanguage": "Utilizar el idioma de la aplicación", "translation_useAppLanguage": "Utilizar el idioma de la aplicación",
"translation_downloadedModelLabel": "Modelo descargado", "translation_downloadedModelLabel": "Modelo descargado",
+3
View File
@@ -2099,6 +2099,9 @@
"translation_title": "Traduction", "translation_title": "Traduction",
"translation_enableSubtitle": "Traduire les messages entrants et permettre la traduction avant l'envoi.", "translation_enableSubtitle": "Traduire les messages entrants et permettre la traduction avant l'envoi.",
"translation_composerSubtitle": "Contrôle l'état par défaut de l'icône de traduction du composant.", "translation_composerSubtitle": "Contrôle l'état par défaut de l'icône de traduction du composant.",
"translation_autoIncomingTitle": "Traduire automatiquement les messages",
"translation_autoIncomingSubtitle": "Traduit automatiquement les messages pour les notifications et pour les discussions ou les canaux.",
"translation_translateMessage": "Traduire le message",
"translation_targetLanguage": "Langue cible", "translation_targetLanguage": "Langue cible",
"translation_useAppLanguage": "Utiliser la langue de l'application", "translation_useAppLanguage": "Utiliser la langue de l'application",
"translation_downloadedModelLabel": "Modèle téléchargé", "translation_downloadedModelLabel": "Modèle téléchargé",
+3
View File
@@ -2137,6 +2137,9 @@
"translation_enableSubtitle": "Fordítsa az érkező üzeneteket, és lehetővé tegye a küldés előtti fordítást.", "translation_enableSubtitle": "Fordítsa az érkező üzeneteket, és lehetővé tegye a küldés előtti fordítást.",
"translation_composerTitle": "Fordítsa el, mielőtt elküldi", "translation_composerTitle": "Fordítsa el, mielőtt elküldi",
"translation_composerSubtitle": "Ellenőrzi a zeneszerző fordítási ikon alapértékét.", "translation_composerSubtitle": "Ellenőrzi a zeneszerző fordítási ikon alapértékét.",
"translation_autoIncomingTitle": "Üzenetek automatikus fordítása",
"translation_autoIncomingSubtitle": "Automatikusan lefordítja az üzeneteket az értesítésekhez, valamint a csevegésekhez vagy csatornákhoz.",
"translation_translateMessage": "Üzenet fordítása",
"translation_targetLanguage": "Célnyelv", "translation_targetLanguage": "Célnyelv",
"translation_useAppLanguage": "Használja az alkalmazás nyelvének beállítását.", "translation_useAppLanguage": "Használja az alkalmazás nyelvének beállítását.",
"translation_downloadedModelLabel": "Letöltött modell", "translation_downloadedModelLabel": "Letöltött modell",
+3
View File
@@ -2100,6 +2100,9 @@
"translation_enableTitle": "Abilitare la traduzione", "translation_enableTitle": "Abilitare la traduzione",
"translation_title": "Traduzione", "translation_title": "Traduzione",
"translation_composerSubtitle": "Controlla lo stato predefinito dell'icona di traduzione del compositore.", "translation_composerSubtitle": "Controlla lo stato predefinito dell'icona di traduzione del compositore.",
"translation_autoIncomingTitle": "Traduci automaticamente i messaggi",
"translation_autoIncomingSubtitle": "Traduce automaticamente i messaggi per le notifiche e per le chat o i canali.",
"translation_translateMessage": "Traduci messaggio",
"translation_targetLanguage": "Lingua di destinazione", "translation_targetLanguage": "Lingua di destinazione",
"translation_useAppLanguage": "Utilizza la lingua dell'app", "translation_useAppLanguage": "Utilizza la lingua dell'app",
"translation_downloadedModelLabel": "Modello scaricato", "translation_downloadedModelLabel": "Modello scaricato",
+3
View File
@@ -2137,6 +2137,9 @@
"translation_composerTitle": "送信する前に翻訳する", "translation_composerTitle": "送信する前に翻訳する",
"translation_enableTitle": "翻訳機能を有効にする", "translation_enableTitle": "翻訳機能を有効にする",
"translation_composerSubtitle": "作曲家翻訳アイコンのデフォルト状態を制御する。", "translation_composerSubtitle": "作曲家翻訳アイコンのデフォルト状態を制御する。",
"translation_autoIncomingTitle": "メッセージを自動翻訳",
"translation_autoIncomingSubtitle": "通知やチャット、チャンネルのメッセージを自動的に翻訳します。",
"translation_translateMessage": "メッセージを翻訳",
"translation_targetLanguage": "翻訳対象言語", "translation_targetLanguage": "翻訳対象言語",
"translation_useAppLanguage": "アプリの言語設定", "translation_useAppLanguage": "アプリの言語設定",
"translation_downloadedModelLabel": "ダウンロードしたモデル", "translation_downloadedModelLabel": "ダウンロードしたモデル",
+3
View File
@@ -2137,6 +2137,9 @@
"translation_enableTitle": "번역 기능 활성화", "translation_enableTitle": "번역 기능 활성화",
"translation_composerTitle": "보내기 전에 번역", "translation_composerTitle": "보내기 전에 번역",
"translation_composerSubtitle": "컴포저 번역 아이콘의 기본 상태를 제어합니다.", "translation_composerSubtitle": "컴포저 번역 아이콘의 기본 상태를 제어합니다.",
"translation_autoIncomingTitle": "메시지 자동 번역",
"translation_autoIncomingSubtitle": "알림과 채팅 또는 채널의 메시지를 자동으로 번역합니다.",
"translation_translateMessage": "메시지 번역",
"translation_targetLanguage": "목표 언어", "translation_targetLanguage": "목표 언어",
"translation_useAppLanguage": "앱 언어 사용", "translation_useAppLanguage": "앱 언어 사용",
"translation_downloadedModelLabel": "다운로드한 모델", "translation_downloadedModelLabel": "다운로드한 모델",
+18
View File
@@ -7138,6 +7138,24 @@ abstract class AppLocalizations {
/// **'Controls the default state of the composer translation icon.'** /// **'Controls the default state of the composer translation icon.'**
String get translation_composerSubtitle; String get translation_composerSubtitle;
/// No description provided for @translation_autoIncomingTitle.
///
/// In en, this message translates to:
/// **'Auto-translate incoming messages'**
String get translation_autoIncomingTitle;
/// No description provided for @translation_autoIncomingSubtitle.
///
/// In en, this message translates to:
/// **'Translates Messages for notification and for chat or channel automatically.'**
String get translation_autoIncomingSubtitle;
/// No description provided for @translation_translateMessage.
///
/// In en, this message translates to:
/// **'Translate message'**
String get translation_translateMessage;
/// No description provided for @translation_targetLanguage. /// No description provided for @translation_targetLanguage.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
+10
View File
@@ -4173,6 +4173,16 @@ class AppLocalizationsBg extends AppLocalizations {
String get translation_composerSubtitle => String get translation_composerSubtitle =>
'Контролира началния статус на иконата за превод, създадена от композитора.'; 'Контролира началния статус на иконата за превод, създадена от композитора.';
@override
String get translation_autoIncomingTitle => 'Автоматичен превод на съобщения';
@override
String get translation_autoIncomingSubtitle =>
'Превежда автоматично съобщенията за известия, както и за чатове или канали.';
@override
String get translation_translateMessage => 'Преведи съобщението';
@override @override
String get translation_targetLanguage => 'Целеви език'; String get translation_targetLanguage => 'Целеви език';
+11
View File
@@ -4188,6 +4188,17 @@ class AppLocalizationsDe extends AppLocalizations {
String get translation_composerSubtitle => String get translation_composerSubtitle =>
'Steuert den Standardzustand des Icons für die Übersetzung des Komponisten.'; 'Steuert den Standardzustand des Icons für die Übersetzung des Komponisten.';
@override
String get translation_autoIncomingTitle =>
'Nachrichten automatisch übersetzen';
@override
String get translation_autoIncomingSubtitle =>
'Übersetzt Nachrichten für Benachrichtigungen sowie für Chats oder Kanäle automatisch.';
@override
String get translation_translateMessage => 'Nachricht übersetzen';
@override @override
String get translation_targetLanguage => 'Zielsprache'; String get translation_targetLanguage => 'Zielsprache';
+11
View File
@@ -4097,6 +4097,17 @@ class AppLocalizationsEn extends AppLocalizations {
String get translation_composerSubtitle => String get translation_composerSubtitle =>
'Controls the default state of the composer translation icon.'; 'Controls the default state of the composer translation icon.';
@override
String get translation_autoIncomingTitle =>
'Auto-translate incoming messages';
@override
String get translation_autoIncomingSubtitle =>
'Translates Messages for notification and for chat or channel automatically.';
@override
String get translation_translateMessage => 'Translate message';
@override @override
String get translation_targetLanguage => 'Target language'; String get translation_targetLanguage => 'Target language';
+11
View File
@@ -4175,6 +4175,17 @@ class AppLocalizationsEs extends AppLocalizations {
String get translation_composerSubtitle => String get translation_composerSubtitle =>
'Controla el estado predeterminado del icono de traducción del compositor.'; 'Controla el estado predeterminado del icono de traducción del compositor.';
@override
String get translation_autoIncomingTitle =>
'Traducir mensajes automáticamente';
@override
String get translation_autoIncomingSubtitle =>
'Traduce mensajes para notificaciones y para chats o canales automáticamente.';
@override
String get translation_translateMessage => 'Traducir mensaje';
@override @override
String get translation_targetLanguage => 'Idioma de destino'; String get translation_targetLanguage => 'Idioma de destino';
+11
View File
@@ -4205,6 +4205,17 @@ class AppLocalizationsFr extends AppLocalizations {
String get translation_composerSubtitle => String get translation_composerSubtitle =>
'Contrôle l\'état par défaut de l\'icône de traduction du composant.'; 'Contrôle l\'état par défaut de l\'icône de traduction du composant.';
@override
String get translation_autoIncomingTitle =>
'Traduire automatiquement les messages';
@override
String get translation_autoIncomingSubtitle =>
'Traduit automatiquement les messages pour les notifications et pour les discussions ou les canaux.';
@override
String get translation_translateMessage => 'Traduire le message';
@override @override
String get translation_targetLanguage => 'Langue cible'; String get translation_targetLanguage => 'Langue cible';
+10
View File
@@ -4193,6 +4193,16 @@ class AppLocalizationsHu extends AppLocalizations {
String get translation_composerSubtitle => String get translation_composerSubtitle =>
'Ellenőrzi a zeneszerző fordítási ikon alapértékét.'; 'Ellenőrzi a zeneszerző fordítási ikon alapértékét.';
@override
String get translation_autoIncomingTitle => 'Üzenetek automatikus fordítása';
@override
String get translation_autoIncomingSubtitle =>
'Automatikusan lefordítja az üzeneteket az értesítésekhez, valamint a csevegésekhez vagy csatornákhoz.';
@override
String get translation_translateMessage => 'Üzenet fordítása';
@override @override
String get translation_targetLanguage => 'Célnyelv'; String get translation_targetLanguage => 'Célnyelv';
+11
View File
@@ -4181,6 +4181,17 @@ class AppLocalizationsIt extends AppLocalizations {
String get translation_composerSubtitle => String get translation_composerSubtitle =>
'Controlla lo stato predefinito dell\'icona di traduzione del compositore.'; 'Controlla lo stato predefinito dell\'icona di traduzione del compositore.';
@override
String get translation_autoIncomingTitle =>
'Traduci automaticamente i messaggi';
@override
String get translation_autoIncomingSubtitle =>
'Traduce automaticamente i messaggi per le notifiche e per le chat o i canali.';
@override
String get translation_translateMessage => 'Traduci messaggio';
@override @override
String get translation_targetLanguage => 'Lingua di destinazione'; String get translation_targetLanguage => 'Lingua di destinazione';
+10
View File
@@ -3953,6 +3953,16 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get translation_composerSubtitle => '作曲家翻訳アイコンのデフォルト状態を制御する。'; String get translation_composerSubtitle => '作曲家翻訳アイコンのデフォルト状態を制御する。';
@override
String get translation_autoIncomingTitle => 'メッセージを自動翻訳';
@override
String get translation_autoIncomingSubtitle =>
'通知やチャット、チャンネルのメッセージを自動的に翻訳します。';
@override
String get translation_translateMessage => 'メッセージを翻訳';
@override @override
String get translation_targetLanguage => '翻訳対象言語'; String get translation_targetLanguage => '翻訳対象言語';
+10
View File
@@ -3954,6 +3954,16 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get translation_composerSubtitle => '컴포저 번역 아이콘의 기본 상태를 제어합니다.'; String get translation_composerSubtitle => '컴포저 번역 아이콘의 기본 상태를 제어합니다.';
@override
String get translation_autoIncomingTitle => '메시지 자동 번역';
@override
String get translation_autoIncomingSubtitle =>
'알림과 채팅 또는 채널의 메시지를 자동으로 번역합니다.';
@override
String get translation_translateMessage => '메시지 번역';
@override @override
String get translation_targetLanguage => '목표 언어'; String get translation_targetLanguage => '목표 언어';
+10
View File
@@ -4158,6 +4158,16 @@ class AppLocalizationsNl extends AppLocalizations {
String get translation_composerSubtitle => String get translation_composerSubtitle =>
'Stelt de standaardstatus van het pictogram voor de vertaling van de componist in.'; 'Stelt de standaardstatus van het pictogram voor de vertaling van de componist in.';
@override
String get translation_autoIncomingTitle => 'Berichten automatisch vertalen';
@override
String get translation_autoIncomingSubtitle =>
'Vertaalt berichten automatisch voor meldingen en voor chats of kanalen.';
@override
String get translation_translateMessage => 'Bericht vertalen';
@override @override
String get translation_targetLanguage => 'Doeltaal'; String get translation_targetLanguage => 'Doeltaal';
+11
View File
@@ -4196,6 +4196,17 @@ class AppLocalizationsPl extends AppLocalizations {
String get translation_composerSubtitle => String get translation_composerSubtitle =>
'Kontroluje domyślny stan ikony tłumaczenia w edytorze.'; 'Kontroluje domyślny stan ikony tłumaczenia w edytorze.';
@override
String get translation_autoIncomingTitle =>
'Automatycznie tłumacz wiadomości';
@override
String get translation_autoIncomingSubtitle =>
'Automatycznie tłumaczy wiadomości do powiadomień oraz do czatów lub kanałów.';
@override
String get translation_translateMessage => 'Przetłumacz wiadomość';
@override @override
String get translation_targetLanguage => 'Język docelowy'; String get translation_targetLanguage => 'Język docelowy';
+11
View File
@@ -4171,6 +4171,17 @@ class AppLocalizationsPt extends AppLocalizations {
String get translation_composerSubtitle => String get translation_composerSubtitle =>
'Controla o estado padrão do ícone de tradução do compositor.'; 'Controla o estado padrão do ícone de tradução do compositor.';
@override
String get translation_autoIncomingTitle =>
'Traduzir mensagens automaticamente';
@override
String get translation_autoIncomingSubtitle =>
'Traduz automaticamente mensagens para notificações e para chats ou canais.';
@override
String get translation_translateMessage => 'Traduzir mensagem';
@override @override
String get translation_targetLanguage => 'Língua-alvo'; String get translation_targetLanguage => 'Língua-alvo';
+11
View File
@@ -4189,6 +4189,17 @@ class AppLocalizationsRu extends AppLocalizations {
String get translation_composerSubtitle => String get translation_composerSubtitle =>
'Управляет исходным состоянием значка перевода, предоставляемого редактором.'; 'Управляет исходным состоянием значка перевода, предоставляемого редактором.';
@override
String get translation_autoIncomingTitle =>
'Автоматически переводить сообщения';
@override
String get translation_autoIncomingSubtitle =>
'Автоматически переводит сообщения для уведомлений, а также для чатов и каналов.';
@override
String get translation_translateMessage => 'Перевести сообщение';
@override @override
String get translation_targetLanguage => 'Целевой язык'; String get translation_targetLanguage => 'Целевой язык';
+10
View File
@@ -4153,6 +4153,16 @@ class AppLocalizationsSk extends AppLocalizations {
String get translation_composerSubtitle => String get translation_composerSubtitle =>
'Riadi výchoce stav ikony pre preklad, ktorú používa program.'; 'Riadi výchoce stav ikony pre preklad, ktorú používa program.';
@override
String get translation_autoIncomingTitle => 'Automaticky prekladať správy';
@override
String get translation_autoIncomingSubtitle =>
'Automaticky prekladá správy pre upozornenia aj pre čet alebo kanál.';
@override
String get translation_translateMessage => 'Preložiť správu';
@override @override
String get translation_targetLanguage => 'Cieľový jazyk'; String get translation_targetLanguage => 'Cieľový jazyk';
+10
View File
@@ -4151,6 +4151,16 @@ class AppLocalizationsSl extends AppLocalizations {
String get translation_composerSubtitle => String get translation_composerSubtitle =>
'Ureja privzeto stanje ikone za prevod, ki jo uporablja avtor.'; 'Ureja privzeto stanje ikone za prevod, ki jo uporablja avtor.';
@override
String get translation_autoIncomingTitle => 'Samodejno prevajaj sporočila';
@override
String get translation_autoIncomingSubtitle =>
'Samodejno prevaja sporočila za obvestila ter za klepete ali kanale.';
@override
String get translation_translateMessage => 'Prevedi sporočilo';
@override @override
String get translation_targetLanguage => 'Ciljna jezika'; String get translation_targetLanguage => 'Ciljna jezika';
+11
View File
@@ -4125,6 +4125,17 @@ class AppLocalizationsSv extends AppLocalizations {
String get translation_composerSubtitle => String get translation_composerSubtitle =>
'Styr standardtillståndet för kompositorns översättningsikon.'; 'Styr standardtillståndet för kompositorns översättningsikon.';
@override
String get translation_autoIncomingTitle =>
'Översätt meddelanden automatiskt';
@override
String get translation_autoIncomingSubtitle =>
'Översätter meddelanden automatiskt för aviseringar och för chattar eller kanaler.';
@override
String get translation_translateMessage => 'Översätt meddelande';
@override @override
String get translation_targetLanguage => 'Målmedvetet språk'; String get translation_targetLanguage => 'Målmedvetet språk';
+11
View File
@@ -4188,6 +4188,17 @@ class AppLocalizationsUk extends AppLocalizations {
String get translation_composerSubtitle => String get translation_composerSubtitle =>
'Контролює стан ікон перекладу, який використовується за замовчуванням.'; 'Контролює стан ікон перекладу, який використовується за замовчуванням.';
@override
String get translation_autoIncomingTitle =>
'Автоматично перекладати повідомлення';
@override
String get translation_autoIncomingSubtitle =>
'Автоматично перекладає повідомлення для сповіщень, а також для чатів і каналів.';
@override
String get translation_translateMessage => 'Перекласти повідомлення';
@override @override
String get translation_targetLanguage => 'Цільова мова'; String get translation_targetLanguage => 'Цільова мова';
+9
View File
@@ -3828,6 +3828,15 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get translation_composerSubtitle => '控制作曲家翻译图标的默认状态。'; String get translation_composerSubtitle => '控制作曲家翻译图标的默认状态。';
@override
String get translation_autoIncomingTitle => '自动翻译消息';
@override
String get translation_autoIncomingSubtitle => '自动为通知以及聊天或频道翻译消息。';
@override
String get translation_translateMessage => '翻译消息';
@override @override
String get translation_targetLanguage => '目标语言'; String get translation_targetLanguage => '目标语言';
+3
View File
@@ -2101,6 +2101,9 @@
"translation_composerTitle": "Vertaal voor verzending", "translation_composerTitle": "Vertaal voor verzending",
"translation_composerSubtitle": "Stelt de standaardstatus van het pictogram voor de vertaling van de componist in.", "translation_composerSubtitle": "Stelt de standaardstatus van het pictogram voor de vertaling van de componist in.",
"translation_useAppLanguage": "Gebruik de taal van de app", "translation_useAppLanguage": "Gebruik de taal van de app",
"translation_autoIncomingTitle": "Berichten automatisch vertalen",
"translation_autoIncomingSubtitle": "Vertaalt berichten automatisch voor meldingen en voor chats of kanalen.",
"translation_translateMessage": "Bericht vertalen",
"translation_targetLanguage": "Doeltaal", "translation_targetLanguage": "Doeltaal",
"translation_downloadedModelLabel": "Gedownloade model", "translation_downloadedModelLabel": "Gedownloade model",
"translation_presetModelLabel": "Voorgeprogrammeerd Hugging Face-model", "translation_presetModelLabel": "Voorgeprogrammeerd Hugging Face-model",
+3
View File
@@ -2137,6 +2137,9 @@
"translation_enableTitle": "Włącz tłumaczenie", "translation_enableTitle": "Włącz tłumaczenie",
"translation_enableSubtitle": "Tłumaczenie otrzymywanych wiadomości oraz umożliwienie tłumaczenia przed wysłaniem.", "translation_enableSubtitle": "Tłumaczenie otrzymywanych wiadomości oraz umożliwienie tłumaczenia przed wysłaniem.",
"translation_composerSubtitle": "Kontroluje domyślny stan ikony tłumaczenia w edytorze.", "translation_composerSubtitle": "Kontroluje domyślny stan ikony tłumaczenia w edytorze.",
"translation_autoIncomingTitle": "Automatycznie tłumacz wiadomości",
"translation_autoIncomingSubtitle": "Automatycznie tłumaczy wiadomości do powiadomień oraz do czatów lub kanałów.",
"translation_translateMessage": "Przetłumacz wiadomość",
"translation_targetLanguage": "Język docelowy", "translation_targetLanguage": "Język docelowy",
"translation_useAppLanguage": "Użyj języka aplikacji", "translation_useAppLanguage": "Użyj języka aplikacji",
"translation_downloadedModelLabel": "Pobudowany model", "translation_downloadedModelLabel": "Pobudowany model",
+3
View File
@@ -2100,6 +2100,9 @@
"translation_enableTitle": "Ativar a tradução", "translation_enableTitle": "Ativar a tradução",
"translation_title": "Tradução", "translation_title": "Tradução",
"translation_composerSubtitle": "Controla o estado padrão do ícone de tradução do compositor.", "translation_composerSubtitle": "Controla o estado padrão do ícone de tradução do compositor.",
"translation_autoIncomingTitle": "Traduzir mensagens automaticamente",
"translation_autoIncomingSubtitle": "Traduz automaticamente mensagens para notificações e para chats ou canais.",
"translation_translateMessage": "Traduzir mensagem",
"translation_targetLanguage": "Língua-alvo", "translation_targetLanguage": "Língua-alvo",
"translation_useAppLanguage": "Utilize o idioma da aplicação", "translation_useAppLanguage": "Utilize o idioma da aplicação",
"translation_downloadedModelLabel": "Modelo baixado", "translation_downloadedModelLabel": "Modelo baixado",
+3
View File
@@ -1268,6 +1268,9 @@
"translation_title": "Перевод", "translation_title": "Перевод",
"translation_enableTitle": "Включить перевод", "translation_enableTitle": "Включить перевод",
"translation_composerSubtitle": "Управляет исходным состоянием значка перевода, предоставляемого редактором.", "translation_composerSubtitle": "Управляет исходным состоянием значка перевода, предоставляемого редактором.",
"translation_autoIncomingTitle": "Автоматически переводить сообщения",
"translation_autoIncomingSubtitle": "Автоматически переводит сообщения для уведомлений, а также для чатов и каналов.",
"translation_translateMessage": "Перевести сообщение",
"translation_targetLanguage": "Целевой язык", "translation_targetLanguage": "Целевой язык",
"translation_useAppLanguage": "Используйте язык приложения", "translation_useAppLanguage": "Используйте язык приложения",
"translation_downloadedModelLabel": "Загруженная модель", "translation_downloadedModelLabel": "Загруженная модель",
+3
View File
@@ -2100,6 +2100,9 @@
"translation_composerTitle": "Preložte pred odeslaním", "translation_composerTitle": "Preložte pred odeslaním",
"translation_title": "Preklad", "translation_title": "Preklad",
"translation_composerSubtitle": "Riadi výchoce stav ikony pre preklad, ktorú používa program.", "translation_composerSubtitle": "Riadi výchoce stav ikony pre preklad, ktorú používa program.",
"translation_autoIncomingTitle": "Automaticky prekladať správy",
"translation_autoIncomingSubtitle": "Automaticky prekladá správy pre upozornenia aj pre čet alebo kanál.",
"translation_translateMessage": "Preložiť správu",
"translation_targetLanguage": "Cieľový jazyk", "translation_targetLanguage": "Cieľový jazyk",
"translation_useAppLanguage": "Použite jazyk aplikácie", "translation_useAppLanguage": "Použite jazyk aplikácie",
"translation_downloadedModelLabel": "Stiahnutý model", "translation_downloadedModelLabel": "Stiahnutý model",
+3
View File
@@ -2099,6 +2099,9 @@
"translation_enableSubtitle": "Prevedite vstopne sporočila in omogočite predhodno prevajanje.", "translation_enableSubtitle": "Prevedite vstopne sporočila in omogočite predhodno prevajanje.",
"translation_enableTitle": "Omogočite prevod", "translation_enableTitle": "Omogočite prevod",
"translation_composerSubtitle": "Ureja privzeto stanje ikone za prevod, ki jo uporablja avtor.", "translation_composerSubtitle": "Ureja privzeto stanje ikone za prevod, ki jo uporablja avtor.",
"translation_autoIncomingTitle": "Samodejno prevajaj sporočila",
"translation_autoIncomingSubtitle": "Samodejno prevaja sporočila za obvestila ter za klepete ali kanale.",
"translation_translateMessage": "Prevedi sporočilo",
"translation_targetLanguage": "Ciljna jezika", "translation_targetLanguage": "Ciljna jezika",
"translation_useAppLanguage": "Uporabite jezik aplikacije", "translation_useAppLanguage": "Uporabite jezik aplikacije",
"translation_downloadedModelLabel": "Naložen model", "translation_downloadedModelLabel": "Naložen model",
+3
View File
@@ -2100,6 +2100,9 @@
"translation_title": "Översättning", "translation_title": "Översättning",
"translation_composerTitle": "Översätt innan du skickar", "translation_composerTitle": "Översätt innan du skickar",
"translation_composerSubtitle": "Styr standardtillståndet för kompositorns översättningsikon.", "translation_composerSubtitle": "Styr standardtillståndet för kompositorns översättningsikon.",
"translation_autoIncomingTitle": "Översätt meddelanden automatiskt",
"translation_autoIncomingSubtitle": "Översätter meddelanden automatiskt för aviseringar och för chattar eller kanaler.",
"translation_translateMessage": "Översätt meddelande",
"translation_targetLanguage": "Målmedvetet språk", "translation_targetLanguage": "Målmedvetet språk",
"translation_useAppLanguage": "Använd appens språk", "translation_useAppLanguage": "Använd appens språk",
"translation_downloadedModelLabel": "Nedladdad modell", "translation_downloadedModelLabel": "Nedladdad modell",
+3
View File
@@ -2109,6 +2109,9 @@
"translation_enableTitle": "Увімкнути переклад", "translation_enableTitle": "Увімкнути переклад",
"translation_enableSubtitle": "Перекладати отримані повідомлення та дозволяти попередній переклад перед відправкою.", "translation_enableSubtitle": "Перекладати отримані повідомлення та дозволяти попередній переклад перед відправкою.",
"translation_composerSubtitle": "Контролює стан ікон перекладу, який використовується за замовчуванням.", "translation_composerSubtitle": "Контролює стан ікон перекладу, який використовується за замовчуванням.",
"translation_autoIncomingTitle": "Автоматично перекладати повідомлення",
"translation_autoIncomingSubtitle": "Автоматично перекладає повідомлення для сповіщень, а також для чатів і каналів.",
"translation_translateMessage": "Перекласти повідомлення",
"translation_targetLanguage": "Цільова мова", "translation_targetLanguage": "Цільова мова",
"translation_useAppLanguage": "Використовувати мову застосунку", "translation_useAppLanguage": "Використовувати мову застосунку",
"translation_downloadedModelLabel": "Завантажений шаблон", "translation_downloadedModelLabel": "Завантажений шаблон",
+3
View File
@@ -2105,6 +2105,9 @@
"translation_composerTitle": "在发送之前进行翻译", "translation_composerTitle": "在发送之前进行翻译",
"translation_enableTitle": "启用翻译功能", "translation_enableTitle": "启用翻译功能",
"translation_composerSubtitle": "控制作曲家翻译图标的默认状态。", "translation_composerSubtitle": "控制作曲家翻译图标的默认状态。",
"translation_autoIncomingTitle": "自动翻译消息",
"translation_autoIncomingSubtitle": "自动为通知以及聊天或频道翻译消息。",
"translation_translateMessage": "翻译消息",
"translation_targetLanguage": "目标语言", "translation_targetLanguage": "目标语言",
"translation_useAppLanguage": "使用应用程序语言", "translation_useAppLanguage": "使用应用程序语言",
"translation_downloadedModelLabel": "下载的模型", "translation_downloadedModelLabel": "下载的模型",
+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);
+8
View File
@@ -113,6 +113,7 @@ class AppSettings {
final int tcpServerPort; final int tcpServerPort;
final bool jumpToOldestUnread; final bool jumpToOldestUnread;
final bool translationEnabled; final bool translationEnabled;
final bool autoTranslateIncomingMessages;
final String? translationTargetLanguageCode; final String? translationTargetLanguageCode;
final bool composerTranslationEnabled; final bool composerTranslationEnabled;
final String? translationModelSourceUrl; final String? translationModelSourceUrl;
@@ -166,6 +167,7 @@ class AppSettings {
this.tcpServerPort = 0, this.tcpServerPort = 0,
this.jumpToOldestUnread = false, this.jumpToOldestUnread = false,
this.translationEnabled = false, this.translationEnabled = false,
this.autoTranslateIncomingMessages = true,
this.translationTargetLanguageCode, this.translationTargetLanguageCode,
this.composerTranslationEnabled = false, this.composerTranslationEnabled = false,
this.translationModelSourceUrl, this.translationModelSourceUrl,
@@ -226,6 +228,7 @@ class AppSettings {
'tcp_server_port': tcpServerPort, 'tcp_server_port': tcpServerPort,
'jump_to_oldest_unread': jumpToOldestUnread, 'jump_to_oldest_unread': jumpToOldestUnread,
'translation_enabled': translationEnabled, 'translation_enabled': translationEnabled,
'auto_translate_incoming_messages': autoTranslateIncomingMessages,
'translation_target_language_code': translationTargetLanguageCode, 'translation_target_language_code': translationTargetLanguageCode,
'composer_translation_enabled': composerTranslationEnabled, 'composer_translation_enabled': composerTranslationEnabled,
'translation_model_source_url': translationModelSourceUrl, 'translation_model_source_url': translationModelSourceUrl,
@@ -307,6 +310,8 @@ class AppSettings {
tcpServerPort: json['tcp_server_port'] as int? ?? 0, tcpServerPort: json['tcp_server_port'] as int? ?? 0,
jumpToOldestUnread: json['jump_to_oldest_unread'] as bool? ?? false, jumpToOldestUnread: json['jump_to_oldest_unread'] as bool? ?? false,
translationEnabled: json['translation_enabled'] as bool? ?? false, translationEnabled: json['translation_enabled'] as bool? ?? false,
autoTranslateIncomingMessages:
json['auto_translate_incoming_messages'] as bool? ?? true,
translationTargetLanguageCode: translationTargetLanguageCode:
json['translation_target_language_code'] as String?, json['translation_target_language_code'] as String?,
composerTranslationEnabled: composerTranslationEnabled:
@@ -396,6 +401,7 @@ class AppSettings {
int? tcpServerPort, int? tcpServerPort,
bool? jumpToOldestUnread, bool? jumpToOldestUnread,
bool? translationEnabled, bool? translationEnabled,
bool? autoTranslateIncomingMessages,
Object? translationTargetLanguageCode = _unset, Object? translationTargetLanguageCode = _unset,
bool? composerTranslationEnabled, bool? composerTranslationEnabled,
Object? translationModelSourceUrl = _unset, Object? translationModelSourceUrl = _unset,
@@ -453,6 +459,8 @@ class AppSettings {
tcpServerPort: tcpServerPort ?? this.tcpServerPort, tcpServerPort: tcpServerPort ?? this.tcpServerPort,
jumpToOldestUnread: jumpToOldestUnread ?? this.jumpToOldestUnread, jumpToOldestUnread: jumpToOldestUnread ?? this.jumpToOldestUnread,
translationEnabled: translationEnabled ?? this.translationEnabled, translationEnabled: translationEnabled ?? this.translationEnabled,
autoTranslateIncomingMessages:
autoTranslateIncomingMessages ?? this.autoTranslateIncomingMessages,
translationTargetLanguageCode: translationTargetLanguageCode == _unset translationTargetLanguageCode: translationTargetLanguageCode == _unset
? this.translationTargetLanguageCode ? this.translationTargetLanguageCode
: translationTargetLanguageCode as String?, : translationTargetLanguageCode as String?,
+34
View File
@@ -4,6 +4,9 @@ import 'dart:typed_data';
import 'package:crypto/crypto.dart' as crypto; import 'package:crypto/crypto.dart' as crypto;
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import 'community.dart';
enum ChannelType { public, private, hashtag, communityPublic, communityHashtag }
class Channel { class Channel {
final int index; final int index;
@@ -111,5 +114,36 @@ class Channel {
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
} }
static bool isCommunityChannel(ChannelType channelType) {
switch (channelType) {
case ChannelType.communityPublic:
case ChannelType.communityHashtag:
return true;
case ChannelType.public:
case ChannelType.private:
case ChannelType.hashtag:
return false;
}
}
static ChannelType getChannelType(
Channel channel,
CommunityPskIndex communityIndex,
) {
Community? community = communityIndex.getCommunityForChannel(channel);
if (community != null) {
if (Community.isCommunityPublicChannel(channel, community)) {
return ChannelType.communityPublic;
}
return ChannelType.communityHashtag;
}
if (channel.isPublicChannel) {
return ChannelType.public;
} else if (channel.name.startsWith('#')) {
return ChannelType.hashtag;
}
return ChannelType.private;
}
static const String publicChannelPsk = '8b3387e9c5cdea6ac9e5edbaa115cd72'; static const String publicChannelPsk = '8b3387e9c5cdea6ac9e5edbaa115cd72';
} }
+33
View File
@@ -4,6 +4,8 @@ import 'dart:typed_data';
import 'package:crypto/crypto.dart' as crypto; import 'package:crypto/crypto.dart' as crypto;
import 'channel.dart';
/// Represents a community with a shared secret for deriving channel PSKs. /// Represents a community with a shared secret for deriving channel PSKs.
/// ///
/// A Community is a namespace with a shared secret K (32 random bytes), /// A Community is a namespace with a shared secret K (32 random bytes),
@@ -162,6 +164,12 @@ class Community {
return hashtag.replaceFirst(RegExp(r'^#'), '').toLowerCase().trim(); return hashtag.replaceFirst(RegExp(r'^#'), '').toLowerCase().trim();
} }
/// Returns true if this is the community's public channel
static bool isCommunityPublicChannel(Channel channel, Community community) {
final publicPsk = community.deriveCommunityPublicPsk();
return channel.pskHex == Channel.formatPskHex(publicPsk);
}
/// Add a hashtag channel to this community's list /// Add a hashtag channel to this community's list
Community addHashtagChannel(String hashtag) { Community addHashtagChannel(String hashtag) {
final normalized = _normalizeCommunityHashtag(hashtag); final normalized = _normalizeCommunityHashtag(hashtag);
@@ -237,3 +245,28 @@ class Community {
@override @override
int get hashCode => id.hashCode; int get hashCode => id.hashCode;
} }
class CommunityPskIndex {
// Cache of PSK hex -> Community for quick lookup
final Map<String, Community> _pskToCommunity = {};
void initialize(List<Community> communities) {
_pskToCommunity.clear();
for (final community in communities) {
// Map the community public channel PSK
final publicPsk = community.deriveCommunityPublicPsk();
_pskToCommunity[Channel.formatPskHex(publicPsk)] = community;
// Map all known hashtag channel PSKs
for (final hashtag in community.hashtagChannels) {
final hashtagPsk = community.deriveCommunityHashtagPsk(hashtag);
_pskToCommunity[Channel.formatPskHex(hashtagPsk)] = community;
}
}
}
/// Returns the community this channel belongs to, or null if not a community channel
Community? getCommunityForChannel(Channel channel) {
return _pskToCommunity[channel.pskHex];
}
}
+270
View File
@@ -181,6 +181,276 @@ class RadioSettings {
txPowerDbm: 14, txPowerDbm: 14,
), ),
), ),
(
'Russia Artyom (VVO)',
RadioSettings(
frequencyMHz: 864.281,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf8,
codingRate: LoRaCodingRate.cr4_6,
txPowerDbm: 20,
),
),
(
'Russia Biysk (BSK)',
RadioSettings(
frequencyMHz: 869.000,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf8,
codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 20,
),
),
(
'Russia Chelyabinsk (CEK)',
RadioSettings(
frequencyMHz: 868.731,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf8,
codingRate: LoRaCodingRate.cr4_6,
txPowerDbm: 20,
),
),
(
'Russia Cherepovets (CEE)',
RadioSettings(
frequencyMHz: 868.570,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_8,
txPowerDbm: 20,
),
),
(
'Russia Irkutsk (IKT)',
RadioSettings(
frequencyMHz: 868.731,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_7,
txPowerDbm: 20,
),
),
(
'Russia Ivanovo (IWA)',
RadioSettings(
frequencyMHz: 868.731,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf8,
codingRate: LoRaCodingRate.cr4_8,
txPowerDbm: 20,
),
),
(
'Russia Izhevsk (IJK)',
RadioSettings(
frequencyMHz: 868.732,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf8,
codingRate: LoRaCodingRate.cr4_8,
txPowerDbm: 20,
),
),
(
'Russia Kaluga (KLF)',
RadioSettings(
frequencyMHz: 868.731,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_7,
txPowerDbm: 20,
),
),
(
'Russia Kazan (KZN)',
RadioSettings(
frequencyMHz: 868.731,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf8,
codingRate: LoRaCodingRate.cr4_6,
txPowerDbm: 20,
),
),
(
'Russia Khabarovsk (KHV)',
RadioSettings(
frequencyMHz: 864.281,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf8,
codingRate: LoRaCodingRate.cr4_6,
txPowerDbm: 20,
),
),
(
'Russia Kirov (KVX)',
RadioSettings(
frequencyMHz: 868.731,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf8,
codingRate: LoRaCodingRate.cr4_8,
txPowerDbm: 20,
),
),
(
'Russia Lipetsk (LPK)',
RadioSettings(
frequencyMHz: 868.950,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf9,
codingRate: LoRaCodingRate.cr4_7,
txPowerDbm: 20,
),
),
(
'Russia Moscow (MOW)',
RadioSettings(
frequencyMHz: 868.731,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_7,
txPowerDbm: 20,
),
),
(
'Russia Nizhny Novgorod (GOJ)',
RadioSettings(
frequencyMHz: 868.731,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf8,
codingRate: LoRaCodingRate.cr4_6,
txPowerDbm: 20,
),
),
(
'Russia Novosibirsk (OVB)',
RadioSettings(
frequencyMHz: 869.000,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf9,
codingRate: LoRaCodingRate.cr4_8,
txPowerDbm: 20,
),
),
(
'Russia Rostov-on-Don (ROV)',
RadioSettings(
frequencyMHz: 868.731,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf9,
codingRate: LoRaCodingRate.cr4_7,
txPowerDbm: 20,
),
),
(
'Russia Ryazan (RZN)',
RadioSettings(
frequencyMHz: 868.880,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf9,
codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 20,
),
),
(
'Russia Samara (KUF)',
RadioSettings(
frequencyMHz: 864.281,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf8,
codingRate: LoRaCodingRate.cr4_7,
txPowerDbm: 20,
),
),
(
'Russia Saratov (GSV)',
RadioSettings(
frequencyMHz: 864.281,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf8,
codingRate: LoRaCodingRate.cr4_7,
txPowerDbm: 20,
),
),
(
'Russia St. Petersburg (LED)',
RadioSettings(
frequencyMHz: 868.856,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_7,
txPowerDbm: 20,
),
),
(
'Russia Tambov (TBW)',
RadioSettings(
frequencyMHz: 868.950,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf10,
codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 20,
),
),
(
'Russia Tula (TYA)',
RadioSettings(
frequencyMHz: 868.731,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf8,
codingRate: LoRaCodingRate.cr4_7,
txPowerDbm: 20,
),
),
(
'Russia Tver (KLD)',
RadioSettings(
frequencyMHz: 869.169,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf8,
codingRate: LoRaCodingRate.cr4_8,
txPowerDbm: 20,
),
),
(
'Russia Ufa (UFA)',
RadioSettings(
frequencyMHz: 868.732,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf8,
codingRate: LoRaCodingRate.cr4_8,
txPowerDbm: 20,
),
),
(
'Russia Volgograd (VOG)',
RadioSettings(
frequencyMHz: 869.525,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_7,
txPowerDbm: 20,
),
),
(
'Russia Voronezh (VOZ)',
RadioSettings(
frequencyMHz: 868.731,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf8,
codingRate: LoRaCodingRate.cr4_6,
txPowerDbm: 20,
),
),
(
'Russia Yekaterinburg (SVX)',
RadioSettings(
frequencyMHz: 869.046,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_7,
txPowerDbm: 20,
),
),
( (
'Switzerland', 'Switzerland',
RadioSettings( RadioSettings(
+37 -4
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,
@@ -559,6 +561,7 @@ class AppSettingsScreen extends StatelessWidget {
TranslationService translationService, TranslationService translationService,
) { ) {
final settings = settingsService.settings; final settings = settingsService.settings;
final translationEnabled = settings.translationEnabled;
return Card( return Card(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -579,11 +582,41 @@ class AppSettingsScreen extends StatelessWidget {
), ),
const Divider(height: 1), const Divider(height: 1),
SwitchListTile( SwitchListTile(
secondary: const Icon(Icons.outgoing_mail), secondary: Icon(
title: Text(context.l10n.translation_composerTitle), Icons.auto_awesome_outlined,
subtitle: Text(context.l10n.translation_composerSubtitle), color: translationEnabled ? null : Colors.grey,
),
title: Text(
context.l10n.translation_autoIncomingTitle,
style: TextStyle(color: translationEnabled ? null : Colors.grey),
),
subtitle: Text(
context.l10n.translation_autoIncomingSubtitle,
style: TextStyle(color: translationEnabled ? null : Colors.grey),
),
value: settings.autoTranslateIncomingMessages,
onChanged: translationEnabled
? settingsService.setAutoTranslateIncomingMessages
: null,
),
const Divider(height: 1),
SwitchListTile(
secondary: Icon(
Icons.outgoing_mail,
color: translationEnabled ? null : Colors.grey,
),
title: Text(
context.l10n.translation_composerTitle,
style: TextStyle(color: translationEnabled ? null : Colors.grey),
),
subtitle: Text(
context.l10n.translation_composerSubtitle,
style: TextStyle(color: translationEnabled ? null : Colors.grey),
),
value: settings.composerTranslationEnabled, value: settings.composerTranslationEnabled,
onChanged: settingsService.setComposerTranslationEnabled, onChanged: translationEnabled
? settingsService.setComposerTranslationEnabled
: null,
), ),
const Divider(height: 1), const Divider(height: 1),
ListTile( ListTile(
+97 -4
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:math' as math; import 'dart:math' as math;
@@ -8,6 +9,8 @@ import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../models/community.dart';
import '../storage/community_store.dart';
import '../utils/platform_info.dart'; import '../utils/platform_info.dart';
import '../helpers/chat_scroll_controller.dart'; import '../helpers/chat_scroll_controller.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
@@ -33,6 +36,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';
@@ -57,8 +61,11 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final ChatScrollController _scrollController = ChatScrollController(); final ChatScrollController _scrollController = ChatScrollController();
final FocusNode _textFieldFocusNode = FocusNode(); final FocusNode _textFieldFocusNode = FocusNode();
ChannelMessage? _replyingToMessage; ChannelMessage? _replyingToMessage;
final CommunityStore _communityStore = CommunityStore();
final CommunityPskIndex _communityIndex = CommunityPskIndex();
final Map<String, GlobalKey> _messageKeys = {}; final Map<String, GlobalKey> _messageKeys = {};
bool _isLoadingOlder = false; bool _isLoadingOlder = false;
bool _communitiesLoaded = false;
MeshCoreConnector? _connector; MeshCoreConnector? _connector;
DateTime? _lastChannelSendAt; DateTime? _lastChannelSendAt;
@@ -82,6 +89,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final idx = widget.channel.index; final idx = widget.channel.index;
final unread = widget.initialUnreadCount; final unread = widget.initialUnreadCount;
final messages = connector.getChannelMessages(widget.channel); final messages = connector.getChannelMessages(widget.channel);
_loadCommunities();
ChannelMessage? anchor; ChannelMessage? anchor;
if (unread > 0) { if (unread > 0) {
anchor = _findOldestUnreadChannelAnchor(messages, unread); anchor = _findOldestUnreadChannelAnchor(messages, unread);
@@ -108,6 +116,19 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
}); });
} }
// TODO: Reload communities when returning from another screen
Future<void> _loadCommunities() async {
final connector = context.read<MeshCoreConnector>();
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
final communities = await _communityStore.loadCommunities();
if (mounted) {
setState(() {
_communityIndex.initialize(communities);
_communitiesLoaded = true;
});
}
}
ChannelMessage? _findOldestUnreadChannelAnchor( ChannelMessage? _findOldestUnreadChannelAnchor(
List<ChannelMessage> messages, List<ChannelMessage> messages,
int unreadCount, int unreadCount,
@@ -194,16 +215,63 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
); );
} }
Widget _channelIcon(Channel channel) {
// Determine icon based on channel type
final ChannelType channelType = Channel.getChannelType(
channel,
_communityIndex,
);
final bool isCommunityChannel = Channel.isCommunityChannel(channelType);
IconData icon;
switch (channelType) {
case ChannelType.communityPublic:
icon = Icons.groups;
case ChannelType.communityHashtag:
icon = Icons.tag;
case ChannelType.public:
icon = Icons.public;
case ChannelType.hashtag:
icon = Icons.tag;
case ChannelType.private:
icon = Icons.lock;
}
return Stack(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: _communitiesLoaded
? Icon(icon, size: 20)
: SizedBox.square(dimension: 20),
),
if (isCommunityChannel)
Positioned(
right: 0,
bottom: 0,
child: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: Colors.purple,
shape: BoxShape.circle,
border: Border.all(
color: Theme.of(context).cardColor,
width: 2,
),
),
child: const Icon(Icons.people, size: 8, color: Colors.white),
),
),
],
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Row( title: Row(
children: [ children: [
Icon( _channelIcon(widget.channel),
widget.channel.isPublicChannel ? Icons.public : Icons.tag,
size: 20,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Column( child: Column(
@@ -237,6 +305,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
], ],
), ),
centerTitle: false, centerTitle: false,
bottom: const SyncProgressAppBarBottom(),
actions: [ actions: [
const RadioStatsIconButton(), const RadioStatsIconButton(),
PopupMenuButton<String>( PopupMenuButton<String>(
@@ -1327,6 +1396,15 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
} }
void _showMessageActions(ChannelMessage message) { void _showMessageActions(ChannelMessage message) {
final translationService = context.read<TranslationService>();
final canTranslateMessage =
translationService.canTranslateIncoming(
text: message.text,
isCli: false,
isOutgoing: message.isOutgoing,
) &&
(message.translatedText?.trim().isEmpty ?? true);
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
builder: (sheetContext) => SafeArea( builder: (sheetContext) => SafeArea(
@@ -1368,6 +1446,21 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
_copyMessageText(message.text); _copyMessageText(message.text);
}, },
), ),
if (canTranslateMessage)
ListTile(
leading: const Icon(Icons.translate),
title: Text(context.l10n.translation_translateMessage),
onTap: () {
Navigator.pop(sheetContext);
unawaited(
context.read<MeshCoreConnector>().translateChannelMessage(
widget.channel.index,
message,
manualTranslation: true,
),
);
},
),
if (!message.isOutgoing) if (!message.isOutgoing)
ListTile( ListTile(
leading: const Icon(Icons.mark_chat_unread_outlined), leading: const Icon(Icons.mark_chat_unread_outlined),
+39 -58
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';
@@ -44,11 +45,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
with DisconnectNavigationMixin { with DisconnectNavigationMixin {
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
final CommunityStore _communityStore = CommunityStore(); final CommunityStore _communityStore = CommunityStore();
Timer? _searchDebounce; final CommunityPskIndex _communityIndex = CommunityPskIndex();
List<Community> _communities = []; List<Community> _communities = [];
Timer? _searchDebounce;
// Cache of PSK hex -> Community for quick lookup
final Map<String, Community> _pskToCommunity = {};
ChannelMessageStore get _channelMessageStore => ChannelMessageStore(); ChannelMessageStore get _channelMessageStore => ChannelMessageStore();
@@ -71,37 +70,11 @@ class _ChannelsScreenState extends State<ChannelsScreen>
if (mounted) { if (mounted) {
setState(() { setState(() {
_communities = communities; _communities = communities;
_buildPskCommunityMap(); _communityIndex.initialize(communities);
}); });
} }
} }
void _buildPskCommunityMap() {
_pskToCommunity.clear();
for (final community in _communities) {
// Map the community public channel PSK
final publicPsk = community.deriveCommunityPublicPsk();
_pskToCommunity[Channel.formatPskHex(publicPsk)] = community;
// Map all known hashtag channel PSKs
for (final hashtag in community.hashtagChannels) {
final hashtagPsk = community.deriveCommunityHashtagPsk(hashtag);
_pskToCommunity[Channel.formatPskHex(hashtagPsk)] = community;
}
}
}
/// Returns the community this channel belongs to, or null if not a community channel
Community? _getCommunityForChannel(Channel channel) {
return _pskToCommunity[channel.pskHex];
}
/// Returns true if this is the community's public channel
bool _isCommunityPublicChannel(Channel channel, Community community) {
final publicPsk = community.deriveCommunityPublicPsk();
return channel.pskHex == Channel.formatPskHex(publicPsk);
}
@override @override
void dispose() { void dispose() {
_searchDebounce?.cancel(); _searchDebounce?.cancel();
@@ -131,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) => [
@@ -180,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: [
@@ -359,6 +338,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
selectedIndex: 1, selectedIndex: 1,
onDestinationSelected: (index) => onDestinationSelected: (index) =>
_handleQuickSwitch(index, context), _handleQuickSwitch(index, context),
contactsUnreadCount: connector.getTotalContactsUnreadCount(),
channelsUnreadCount: connector.getTotalChannelsUnreadCount(),
), ),
), ),
), ),
@@ -374,37 +355,37 @@ class _ChannelsScreenState extends State<ChannelsScreen>
int? dragIndex, int? dragIndex,
}) { }) {
final unreadCount = connector.getUnreadCountForChannel(channel); final unreadCount = connector.getUnreadCountForChannel(channel);
final community = _getCommunityForChannel(channel);
final isCommunityChannel = community != null;
final isCommunityPublic =
isCommunityChannel && _isCommunityPublicChannel(channel, community);
// Determine icon and colors based on channel type // Determine icon and colors based on channel type
IconData icon; IconData icon;
Color iconColor; Color iconColor;
Color bgColor; Color bgColor;
final ChannelType channelType = Channel.getChannelType(
if (isCommunityChannel) { channel,
// Community channel styling _communityIndex,
iconColor = Colors.purple; );
bgColor = Colors.purple.withValues(alpha: 0.2); final bool isCommunityChannel = Channel.isCommunityChannel(channelType);
if (isCommunityPublic) { switch (channelType) {
case ChannelType.communityPublic:
icon = Icons.groups; icon = Icons.groups;
} else { iconColor = Colors.purple;
bgColor = Colors.purple.withValues(alpha: 0.2);
case ChannelType.communityHashtag:
icon = Icons.tag; icon = Icons.tag;
} iconColor = Colors.purple;
} else if (channel.isPublicChannel) { bgColor = Colors.purple.withValues(alpha: 0.2);
icon = Icons.public; case ChannelType.public:
iconColor = Colors.green; icon = Icons.public;
bgColor = Colors.green.withValues(alpha: 0.2); iconColor = Colors.green;
} else if (channel.name.startsWith('#')) { bgColor = Colors.green.withValues(alpha: 0.2);
icon = Icons.tag; case ChannelType.hashtag:
iconColor = Colors.blue; icon = Icons.tag;
bgColor = Colors.blue.withValues(alpha: 0.2); iconColor = Colors.blue;
} else { bgColor = Colors.blue.withValues(alpha: 0.2);
icon = Icons.lock; case ChannelType.private:
iconColor = Colors.blue; icon = Icons.lock;
bgColor = Colors.blue.withValues(alpha: 0.2); iconColor = Colors.blue;
bgColor = Colors.blue.withValues(alpha: 0.2);
} }
return Card( return Card(
+31 -7
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, _) {
@@ -485,6 +487,8 @@ class _ChatScreenState extends State<ChatScreen> {
final message = reversedMessages[messageIndex]; final message = reversedMessages[messageIndex];
String fourByteHex = ''; String fourByteHex = '';
if (contact.type == advTypeRoom) { if (contact.type == advTypeRoom) {
// Room-server messages carry the original author's 4-byte prefix
// separately from message.text; use it only for resolving the name.
contact = _resolveContactFrom4Bytes( contact = _resolveContactFrom4Bytes(
connector, connector,
message.fourByteRoomContactKey.isEmpty message.fourByteRoomContactKey.isEmpty
@@ -509,7 +513,6 @@ class _ChatScreenState extends State<ChatScreen> {
? "${contact.name} [$fourByteHex]" ? "${contact.name} [$fourByteHex]"
: contact.name, : contact.name,
sourceId: widget.contact.publicKeyHex, sourceId: widget.contact.publicKeyHex,
isRoomServer: resolvedContact.type == advTypeRoom,
textScale: textScale, textScale: textScale,
onTap: () => _openMessagePath(message, contact), onTap: () => _openMessagePath(message, contact),
onLongPress: () => _showMessageActions(message, contact), onLongPress: () => _showMessageActions(message, contact),
@@ -1587,6 +1590,15 @@ class _ChatScreenState extends State<ChatScreen> {
} }
void _showMessageActions(Message message, Contact contact) { void _showMessageActions(Message message, Contact contact) {
final translationService = context.read<TranslationService>();
final canTranslateMessage =
translationService.canTranslateIncoming(
text: message.text,
isCli: message.isCli,
isOutgoing: message.isOutgoing,
) &&
(message.translatedText?.trim().isEmpty ?? true);
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
builder: (sheetContext) => SafeArea( builder: (sheetContext) => SafeArea(
@@ -1620,6 +1632,21 @@ class _ChatScreenState extends State<ChatScreen> {
_copyMessageText(message.text); _copyMessageText(message.text);
}, },
), ),
if (canTranslateMessage)
ListTile(
leading: const Icon(Icons.translate),
title: Text(context.l10n.translation_translateMessage),
onTap: () {
Navigator.pop(sheetContext);
unawaited(
context.read<MeshCoreConnector>().translateContactMessage(
widget.contact.publicKeyHex,
message,
manualTranslation: true,
),
);
},
),
if (!message.isOutgoing) if (!message.isOutgoing)
ListTile( ListTile(
leading: const Icon(Icons.mark_chat_unread_outlined), leading: const Icon(Icons.mark_chat_unread_outlined),
@@ -1731,7 +1758,6 @@ class _ChatScreenState extends State<ChatScreen> {
class _MessageBubble extends StatelessWidget { class _MessageBubble extends StatelessWidget {
final Message message; final Message message;
final String senderName; final String senderName;
final bool isRoomServer;
final VoidCallback? onTap; final VoidCallback? onTap;
final VoidCallback? onLongPress; final VoidCallback? onLongPress;
final void Function(Message message, String emoji)? onRetryReaction; final void Function(Message message, String emoji)? onRetryReaction;
@@ -1742,7 +1768,6 @@ class _MessageBubble extends StatelessWidget {
required this.message, required this.message,
required this.senderName, required this.senderName,
required this.sourceId, required this.sourceId,
required this.isRoomServer,
required this.textScale, required this.textScale,
this.onTap, this.onTap,
this.onLongPress, this.onLongPress,
@@ -1768,10 +1793,9 @@ class _MessageBubble extends StatelessWidget {
: (isOutgoing ? colorScheme.onPrimary : colorScheme.onSurface); : (isOutgoing ? colorScheme.onPrimary : colorScheme.onSurface);
final metaColor = textColor.withValues(alpha: 0.7); final metaColor = textColor.withValues(alpha: 0.7);
const bodyFontSize = 14.0; const bodyFontSize = 14.0;
String messageText = message.text; // Do not strip room-server author bytes here: the parser stores them in
if (isRoomServer && !isOutgoing) { // fourByteRoomContactKey, so message.text is safe to render as-is.
messageText = message.text.substring(4.clamp(0, message.text.length)); final messageText = message.text;
}
final translatedDisplayText = final translatedDisplayText =
message.translatedText != null && message.translatedText != null &&
message.translatedText!.trim().isNotEmpty message.translatedText!.trim().isNotEmpty
+10 -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) => [
@@ -430,6 +432,8 @@ class _ContactsScreenState extends State<ContactsScreen>
selectedIndex: 0, selectedIndex: 0,
onDestinationSelected: (index) => onDestinationSelected: (index) =>
_handleQuickSwitch(index, context), _handleQuickSwitch(index, context),
contactsUnreadCount: connector.getTotalContactsUnreadCount(),
channelsUnreadCount: connector.getTotalChannelsUnreadCount(),
), ),
), ),
), ),
@@ -604,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());
} }
@@ -539,6 +539,12 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
child: QuickSwitchBar( child: QuickSwitchBar(
selectedIndex: 2, selectedIndex: 2,
onDestinationSelected: (index) => _handleQuickSwitch(index, context), onDestinationSelected: (index) => _handleQuickSwitch(index, context),
contactsUnreadCount: context
.watch<MeshCoreConnector>()
.getTotalContactsUnreadCount(),
channelsUnreadCount: context
.watch<MeshCoreConnector>()
.getTotalChannelsUnreadCount(),
), ),
), ),
); );
+4
View File
@@ -24,6 +24,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';
@@ -417,6 +418,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(
@@ -679,6 +681,8 @@ class _MapScreenState extends State<MapScreen> {
selectedIndex: 2, selectedIndex: 2,
onDestinationSelected: (index) => onDestinationSelected: (index) =>
_handleQuickSwitch(index, context), _handleQuickSwitch(index, context),
contactsUnreadCount: connector.getTotalContactsUnreadCount(),
channelsUnreadCount: connector.getTotalChannelsUnreadCount(),
), ),
), ),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
+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((_) {
+6
View File
@@ -235,6 +235,12 @@ class AppSettingsService extends ChangeNotifier {
await updateSettings(_settings.copyWith(translationEnabled: value)); await updateSettings(_settings.copyWith(translationEnabled: value));
} }
Future<void> setAutoTranslateIncomingMessages(bool value) async {
await updateSettings(
_settings.copyWith(autoTranslateIncomingMessages: value),
);
}
Future<void> setTranslationTargetLanguageCode(String? value) async { Future<void> setTranslationTargetLanguageCode(String? value) async {
await updateSettings( await updateSettings(
_settings.copyWith(translationTargetLanguageCode: value), _settings.copyWith(translationTargetLanguageCode: value),
+48 -71
View File
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:llamadart/llamadart.dart'; import 'package:llamadart/llamadart.dart';
import 'package:flutter_langdetect/flutter_langdetect.dart';
import '../models/app_settings.dart'; import '../models/app_settings.dart';
import '../models/translation_support.dart'; import '../models/translation_support.dart';
@@ -41,7 +42,10 @@ class TranslationService extends ChangeNotifier {
TranslationService( TranslationService(
this._appSettingsService, { this._appSettingsService, {
TranslationFileStore? fileStore, TranslationFileStore? fileStore,
}) : _fileStore = fileStore ?? TranslationFileStore(); }) : _fileStore = fileStore ?? TranslationFileStore() {
// Initialize langdetect once at service construction.
_langDetectInit = initLangDetect();
}
bool _isBusy = false; bool _isBusy = false;
bool _isDownloading = false; bool _isDownloading = false;
@@ -51,6 +55,7 @@ class TranslationService extends ChangeNotifier {
LlamaEngine? _engine; LlamaEngine? _engine;
String? _loadedModelPath; String? _loadedModelPath;
String? _failedModelPath; String? _failedModelPath;
Future<void>? _langDetectInit;
int _downloadedBytes = 0; int _downloadedBytes = 0;
int? _downloadTotalBytes; int? _downloadTotalBytes;
String? _downloadFileName; String? _downloadFileName;
@@ -84,7 +89,22 @@ class TranslationService extends ChangeNotifier {
'en'; 'en';
} }
bool shouldTranslateIncoming({ bool shouldAutoTranslateIncoming({
required String text,
required bool isCli,
required bool isOutgoing,
}) {
if (!_settings.autoTranslateIncomingMessages) {
return false;
}
return canTranslateIncoming(
text: text,
isCli: isCli,
isOutgoing: isOutgoing,
);
}
bool canTranslateIncoming({
required String text, required String text,
required bool isCli, required bool isCli,
required bool isOutgoing, required bool isOutgoing,
@@ -368,7 +388,9 @@ class TranslationService extends ChangeNotifier {
if (targetLanguageCode == null || !_isPlainTextEligible(text)) { if (targetLanguageCode == null || !_isPlainTextEligible(text)) {
return null; return null;
} }
final detectedLanguageCode = await detectLanguage(text); final detectedLanguageCode = await detectLanguage(
_stripReplyInfoForDetection(text),
);
if (detectedLanguageCode != null && if (detectedLanguageCode != null &&
detectedLanguageCode == targetLanguageCode) { detectedLanguageCode == targetLanguageCode) {
return const TranslationResult( return const TranslationResult(
@@ -409,7 +431,9 @@ class TranslationService extends ChangeNotifier {
if (targetLanguageCode == null || !_isPlainTextEligible(text)) { if (targetLanguageCode == null || !_isPlainTextEligible(text)) {
return null; return null;
} }
final detectedLanguageCode = await detectLanguage(text); final detectedLanguageCode = await detectLanguage(
_stripReplyInfoForDetection(text),
);
if (detectedLanguageCode != null && if (detectedLanguageCode != null &&
detectedLanguageCode == targetLanguageCode) { detectedLanguageCode == targetLanguageCode) {
return const TranslationResult( return const TranslationResult(
@@ -436,7 +460,26 @@ class TranslationService extends ChangeNotifier {
} }
Future<String?> detectLanguage(String text) async { Future<String?> detectLanguage(String text) async {
return _heuristicLanguageCode(text); try {
// Ensure the detector is initialized (constructor starts init).
await (_langDetectInit ??= initLangDetect());
final code = detect(text);
if (code.isEmpty) return null;
return code;
} catch (error) {
_lastError = error.toString();
appLogger.warn('Language detection failed: $error');
notifyListeners();
return null;
}
}
String _stripReplyInfoForDetection(String text) {
final match = RegExp(
r'@\[([^\]]+)\]\s+(.+)$',
dotAll: true,
).firstMatch(text);
return match?.group(2) ?? text;
} }
Future<String?> _translateText({ Future<String?> _translateText({
@@ -518,72 +561,6 @@ class TranslationService extends ChangeNotifier {
trimmed.startsWith('r:')); trimmed.startsWith('r:'));
} }
String? _heuristicLanguageCode(String text) {
final trimmed = text.trim();
if (trimmed.isEmpty) {
return null;
}
if (RegExp(r'[ぁ-んァ-ン]').hasMatch(text)) {
return 'ja';
}
if (RegExp(r'[가-힣]').hasMatch(text)) {
return 'ko';
}
if (RegExp(r'[\u4e00-\u9fff]').hasMatch(text)) {
return 'zh';
}
final lower = trimmed.toLowerCase();
final patterns = <String, String>{
'uk': r'\b(привіт|дякую|будь|ласка|як|де|не|так|це|є|най|ще|може|для)\b',
'ru':
r'\b(что|это|как|не|да|нет|он|она|они|быть|есть|для|сегодня|если|уже|может)\b',
'bg': r'\b(ще|няма|благодаря|моля|това|какво|тук|ние|вие|не|със|за)\b',
'de':
r'\b(der|die|das|und|ist|nicht|ein|eine|ich|für|mit|auf|zu|auch|als|an|im|am|es|dem|den|sich|von)\b',
'en':
r'\b(the|and|is|you|for|with|from|not|that|this|have|be|are|was|were|but|can|will|your|what|when|how|they)\b',
'es':
r'\b(el|la|los|las|es|que|de|en|con|por|para|no|un|una|se|como|su|al|del|está)\b',
'fr':
r'\b(le|la|les|un|une|et|est|que|qui|pour|dans|pas|avec|sur|ne|vous|il|elle|des|ce|cette|je|tu|nous|vous)\b',
'it':
r'\b(il|la|lo|un|una|che|di|da|in|per|con|non|si|mi|ti|noi|voi|lui|lei)\b',
'pt':
r'\b(os|as|que|de|do|da|em|para|com|por|não|uma|um|se|você|também)\b',
'nl':
r'\b(de|het|een|en|is|niet|dat|wat|je|ik|op|aan|voor|met|als|nog|zijn)\b',
'sv':
r'\b(och|är|det|att|som|en|på|inte|har|var|men|du|jag|vi|ni|den|detta)\b',
'pl':
r'\b(na|się|nie|jest|to|że|do|od|dla|czy|tak|ale|ma|jak|on|ona|my)\b',
'sk': r'\b(je|na|so|že|do|od|za|si|to|ten|tá|tí|ako|má|nie|som|sa)\b',
'sl': r'\b(in|je|na|se|da|za|od|ne|to|ta|so|kako|bo|sem|si)\b',
'hu':
r'\b(az|és|nem|van|volt|hogy|mit|mire|ki|mi|ez|azért|is|de|ha|te|ő|mi|itt)\b',
};
final scores = <String, int>{};
for (final entry in patterns.entries) {
scores[entry.key] = RegExp(
entry.value,
caseSensitive: false,
).allMatches(lower).length;
}
final sorted = scores.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
if (sorted.isEmpty || sorted.first.value == 0) {
return null;
}
if (sorted.length > 1 && sorted.first.value == sorted[1].value) {
return null;
}
return sorted.first.key;
}
String _languageLabel(String code) { String _languageLabel(String code) {
for (final option in supportedTranslationLanguages) { for (final option in supportedTranslationLanguages) {
if (option.code == code) { if (option.code == code) {
+44 -2
View File
@@ -6,11 +6,15 @@ import '../l10n/l10n.dart';
class QuickSwitchBar extends StatelessWidget { class QuickSwitchBar extends StatelessWidget {
final int selectedIndex; final int selectedIndex;
final ValueChanged<int> onDestinationSelected; final ValueChanged<int> onDestinationSelected;
final int contactsUnreadCount;
final int channelsUnreadCount;
const QuickSwitchBar({ const QuickSwitchBar({
super.key, super.key,
required this.selectedIndex, required this.selectedIndex,
required this.onDestinationSelected, required this.onDestinationSelected,
this.contactsUnreadCount = 0,
this.channelsUnreadCount = 0,
}); });
@override @override
@@ -62,15 +66,30 @@ class QuickSwitchBar extends StatelessWidget {
onDestinationSelected: onDestinationSelected, onDestinationSelected: onDestinationSelected,
destinations: [ destinations: [
NavigationDestination( NavigationDestination(
icon: const Icon(Icons.people_outline), icon: _buildIconWithBadge(
const Icon(Icons.people_outline),
contactsUnreadCount,
),
selectedIcon: _buildIconWithBadge(
const Icon(Icons.people),
contactsUnreadCount,
),
label: context.l10n.nav_contacts, label: context.l10n.nav_contacts,
), ),
NavigationDestination( NavigationDestination(
icon: const Icon(Icons.tag), icon: _buildIconWithBadge(
const Icon(Icons.tag),
channelsUnreadCount,
),
selectedIcon: _buildIconWithBadge(
const Icon(Icons.tag),
channelsUnreadCount,
),
label: context.l10n.nav_channels, label: context.l10n.nav_channels,
), ),
NavigationDestination( NavigationDestination(
icon: const Icon(Icons.map_outlined), icon: const Icon(Icons.map_outlined),
selectedIcon: const Icon(Icons.map),
label: context.l10n.nav_map, label: context.l10n.nav_map,
), ),
], ],
@@ -81,4 +100,27 @@ class QuickSwitchBar extends StatelessWidget {
), ),
); );
} }
Widget _buildIconWithBadge(Icon icon, int count) {
if (count <= 0) return icon;
return Stack(
clipBehavior: Clip.none,
children: [
icon,
Positioned(
right: -2,
top: -2,
child: Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: Colors.redAccent,
shape: BoxShape.circle,
),
),
),
],
);
}
} }
+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;
}
}
+1
View File
@@ -72,6 +72,7 @@ dependencies:
ml_algo: ^16.0.0 ml_algo: ^16.0.0
ml_dataframe: ^1.0.0 ml_dataframe: ^1.0.0
llamadart: '>=0.6.8 <0.7.0' llamadart: '>=0.6.8 <0.7.0'
flutter_langdetect: ^0.0.1
hooks: hooks:
user_defines: user_defines: