diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 51fe11bc..a25d0273 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -234,6 +234,7 @@ class MeshCoreConnector extends ChangeNotifier { double? _selfLongitude; final List _directRepeaters = List.empty(growable: true); bool _isLoadingContacts = false; + bool _hasLoadedContacts = false; bool _isLoadingChannels = false; bool _hasLoadedChannels = false; TimeoutPredictionService? _timeoutPredictionService; @@ -255,10 +256,13 @@ class MeshCoreConnector extends ChangeNotifier { bool _batteryRequested = false; bool _awaitingSelfInfo = false; bool _hasReceivedDeviceInfo = false; + // Initial sync is serialized for predictable progress. Firmware exposes one + // FIFO queued-message stream, so direct/room frames are buffered until after + // contacts are known. bool _pendingInitialChannelSync = false; bool _pendingInitialContactsSync = false; + bool _pendingInitialQueuedMessageSync = false; bool _bleInitialSyncStarted = false; - bool _pendingDeferredChannelSyncAfterContacts = false; bool _webInitialHandshakeRequestSent = false; bool _preserveContactsOnRefresh = false; bool _autoAddUsers = false; @@ -273,13 +277,18 @@ class MeshCoreConnector extends ChangeNotifier { int _advertLocPolicy = 0; int _multiAcks = 0; - static const int _defaultMaxContacts = 32; - static const int _defaultMaxChannels = 8; + static const int _defaultMaxContacts = 350; + static const int _defaultMaxChannels = 40; int _maxContacts = _defaultMaxContacts; int _maxChannels = _defaultMaxChannels; + int? _contactSyncTotal; + int _contactSyncReceived = 0; + bool _contactSyncUsesSinceFilter = false; bool _isSyncingQueuedMessages = false; + bool _deferQueuedContactMessagesUntilContacts = false; + bool _isProcessingDeferredQueuedContactMessages = false; bool _queuedMessageSyncInFlight = false; - bool _didInitialQueueSync = false; + final List _deferredQueuedContactMessageFrames = []; bool _pendingQueueSync = false; Timer? _queueSyncTimeout; int _queueSyncRetries = 0; @@ -337,6 +346,8 @@ class MeshCoreConnector extends ChangeNotifier { final Map _contactUnreadCount = {}; final Map _repeaterBatterySnapshots = {}; bool _unreadStateLoaded = false; + int _cachedContactsUnreadTotal = 0; + int _cachedChannelsUnreadTotal = 0; final Map _pendingRepeaterAcks = {}; String? _activeContactKey; int? _activeChannelIndex; @@ -406,7 +417,9 @@ class MeshCoreConnector extends ChangeNotifier { List get channels => List.unmodifiable(_channels); bool get isConnected => _state == MeshCoreConnectionState.connected; bool get isLoadingContacts => _isLoadingContacts; + bool get hasLoadedContacts => _hasLoadedContacts; bool get isLoadingChannels => _isLoadingChannels; + bool get hasLoadedChannels => _hasLoadedChannels; Stream get receivedFrames => _receivedFramesController.stream; Uint8List? get selfPublicKey => _selfPublicKey; String get selfPublicKeyHex => pubKeyToHex(_selfPublicKey ?? Uint8List(0)); @@ -469,7 +482,16 @@ class MeshCoreConnector extends ChangeNotifier { int get maxContacts => _maxContacts; int get maxChannels => _maxChannels; Set get knownContactKeys => Set.unmodifiable(_knownContactKeys); - bool get isSyncingQueuedMessages => _isSyncingQueuedMessages; + double? get contactSyncProgress { + final total = _contactSyncTotal; + if (!_isLoadingContacts || total == null || total <= 0) return null; + return (_contactSyncReceived / total).clamp(0.0, 1.0).toDouble(); + } + + bool get isSyncingQueuedMessages => + _isSyncingQueuedMessages || _isProcessingDeferredQueuedContactMessages; + bool get isShowingQueuedMessageSyncProgress => + _deferQueuedContactMessagesUntilContacts && isSyncingQueuedMessages; bool get isSyncingChannels => _isSyncingChannels; int get channelSyncProgress => _isSyncingChannels && _totalChannelsToRequest > 0 @@ -641,16 +663,42 @@ class MeshCoreConnector extends ChangeNotifier { int getTotalUnreadCount() { if (!_unreadStateLoaded) return 0; - var total = 0; - // Count unread contact messages - for (final contact in _contacts) { - total += getUnreadCountForContact(contact); - } - // Count unread channel messages - for (final channelIndex in _channelMessages.keys) { - total += getUnreadCountForChannelIndex(channelIndex); - } - return total; + return getTotalContactsUnreadCount() + getTotalChannelsUnreadCount(); + } + + int getTotalContactsUnreadCount() { + if (!_unreadStateLoaded) return 0; + return _cachedContactsUnreadTotal; + } + + int getTotalChannelsUnreadCount() { + 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) { @@ -684,11 +732,13 @@ class MeshCoreConnector extends ChangeNotifier { ..clear() ..addAll(await _unreadStore.loadContactUnreadCount()); _unreadStateLoaded = true; + _recalculateCachedUnreadTotals(); notifyListeners(); } Future loadCachedChannels() async { _cachedChannels = await _channelStore.loadChannels(); + _recalculateCachedChannelsUnreadTotal(); } void setActiveContact(String? contactKeyHex) { @@ -715,6 +765,8 @@ class MeshCoreConnector extends ChangeNotifier { final previousCount = _contactUnreadCount[contactKeyHex] ?? 0; if (previousCount > 0) { _contactUnreadCount[contactKeyHex] = 0; + _cachedContactsUnreadTotal = (_cachedContactsUnreadTotal - previousCount) + .clamp(0, _cachedContactsUnreadTotal); _appDebugLogService?.info( 'Contact $contactKeyHex marked as read (was $previousCount unread)', tag: 'Unread', @@ -756,6 +808,8 @@ class MeshCoreConnector extends ChangeNotifier { if (channel != null && channel.unreadCount > 0) { final previousCount = channel.unreadCount; channel.unreadCount = 0; + _cachedChannelsUnreadTotal = (_cachedChannelsUnreadTotal - previousCount) + .clamp(0, _cachedChannelsUnreadTotal); _appDebugLogService?.info( 'Channel ${channel.name.isNotEmpty ? channel.name : channelIndex} marked as read (was $previousCount unread)', tag: 'Unread', @@ -1107,19 +1161,31 @@ class MeshCoreConnector extends ChangeNotifier { } } - Future _translateIncomingContactMessage( + Future translateContactMessage( String contactKeyHex, - Message message, - ) async { + Message message, { + bool manualTranslation = false, + }) async { try { + if (message.translatedText?.trim().isNotEmpty == true || + (!manualTranslation && + message.translationStatus != MessageTranslationStatus.none)) { + return null; + } final service = _translationService; if (service == null || - !service.shouldTranslateIncoming( - text: message.text, - isCli: message.isCli, - isOutgoing: message.isOutgoing, - )) { - return; + !(manualTranslation + ? service.canTranslateIncoming( + text: message.text, + isCli: message.isCli, + isOutgoing: message.isOutgoing, + ) + : service.shouldAutoTranslateIncoming( + text: message.text, + isCli: message.isCli, + isOutgoing: message.isOutgoing, + ))) { + return null; } final targetLanguageCode = service.resolvedIncomingLanguageCode( _appSettingsService?.settings.languageOverride, @@ -1129,7 +1195,7 @@ class MeshCoreConnector extends ChangeNotifier { targetLanguageCode: targetLanguageCode, ); if (result == null) { - return; + return null; } final translated = result.status == MessageTranslationStatus.completed ? result.translatedText @@ -1144,24 +1210,38 @@ class MeshCoreConnector extends ChangeNotifier { translationModelId: result.modelId, ), ); + return result; } catch (error) { appLogger.warn('Translation failed for contact message: $error'); + return null; } } - Future _translateIncomingChannelMessage( + Future translateChannelMessage( int channelIndex, - ChannelMessage message, - ) async { + ChannelMessage message, { + bool manualTranslation = false, + }) async { try { + if (message.translatedText?.trim().isNotEmpty == true || + (!manualTranslation && + message.translationStatus != MessageTranslationStatus.none)) { + return null; + } final service = _translationService; if (service == null || - !service.shouldTranslateIncoming( - text: message.text, - isCli: false, - isOutgoing: message.isOutgoing, - )) { - return; + !(manualTranslation + ? service.canTranslateIncoming( + text: message.text, + isCli: false, + isOutgoing: message.isOutgoing, + ) + : service.shouldAutoTranslateIncoming( + text: message.text, + isCli: false, + isOutgoing: message.isOutgoing, + ))) { + return null; } final targetLanguageCode = service.resolvedIncomingLanguageCode( _appSettingsService?.settings.languageOverride, @@ -1171,11 +1251,16 @@ class MeshCoreConnector extends ChangeNotifier { targetLanguageCode: targetLanguageCode, ); if (result == null) { - return; + return null; } - final translated = result.status == MessageTranslationStatus.completed + var translated = result.status == MessageTranslationStatus.completed ? result.translatedText : 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( channelIndex, message.messageId, @@ -1186,8 +1271,10 @@ class MeshCoreConnector extends ChangeNotifier { translationModelId: result.modelId, ), ); + return result; } catch (error) { appLogger.warn('Translation failed for channel message: $error'); + return null; } } @@ -1516,6 +1603,8 @@ class MeshCoreConnector extends ChangeNotifier { _setState(MeshCoreConnectionState.connected); _pendingInitialChannelSync = true; + _pendingInitialQueuedMessageSync = true; + _pendingInitialContactsSync = true; _appDebugLogService?.info( 'connectUsb: requesting device info…', tag: 'USB', @@ -1626,6 +1715,8 @@ class MeshCoreConnector extends ChangeNotifier { _setState(MeshCoreConnectionState.connected); _pendingInitialChannelSync = true; + _pendingInitialQueuedMessageSync = true; + _pendingInitialContactsSync = true; await _requestDeviceInfo(); _startBatteryPolling(); if (_radioStatsPollRefCount > 0) _startRadioStatsPolling(); @@ -2260,7 +2351,9 @@ class MeshCoreConnector extends ChangeNotifier { return; } _bleInitialSyncStarted = true; + _pendingInitialChannelSync = true; _pendingInitialContactsSync = true; + _pendingInitialQueuedMessageSync = true; await _requestDeviceInfo(); _startBatteryPolling(); @@ -2275,7 +2368,7 @@ class MeshCoreConnector extends ChangeNotifier { } await syncTime(); - unawaited(getChannels()); + _maybeStartInitialChannelSync(); } void _resetConnectionHandshakeState() { @@ -2288,11 +2381,39 @@ class MeshCoreConnector extends ChangeNotifier { _selfInfoRetryTimer?.cancel(); _selfInfoRetryTimer = null; _hasReceivedDeviceInfo = false; + _resetSyncProgressState(); + _bleInitialSyncStarted = false; + _pathHashByteWidth = 1; + } + + void _resetSyncProgressState() { _pendingInitialChannelSync = false; _pendingInitialContactsSync = false; - _bleInitialSyncStarted = false; - _pendingDeferredChannelSyncAfterContacts = false; - _pathHashByteWidth = 1; + _pendingInitialQueuedMessageSync = false; + _contactSyncTotal = null; + _contactSyncReceived = 0; + _contactSyncUsesSinceFilter = false; + _isLoadingContacts = false; + _hasLoadedContacts = false; + _isLoadingChannels = false; + _hasLoadedChannels = false; + _isSyncingQueuedMessages = false; + _deferQueuedContactMessagesUntilContacts = false; + _isProcessingDeferredQueuedContactMessages = false; + _queuedMessageSyncInFlight = false; + _deferredQueuedContactMessageFrames.clear(); + _pendingQueueSync = false; + _queueSyncTimeout?.cancel(); + _queueSyncTimeout = null; + _queueSyncRetries = 0; + _isSyncingChannels = false; + _channelSyncInFlight = false; + _channelSyncTimeout?.cancel(); + _channelSyncTimeout = null; + _channelSyncRetries = 0; + _nextChannelIndexToRequest = 0; + _totalChannelsToRequest = 0; + _previousChannelsCache.clear(); } bool get _shouldAutoReconnect => @@ -2429,17 +2550,9 @@ class MeshCoreConnector extends ChangeNotifier { _batteryRequested = false; _awaitingSelfInfo = false; _hasReceivedDeviceInfo = false; - _pendingInitialChannelSync = false; - _pendingInitialContactsSync = false; _maxContacts = _defaultMaxContacts; _maxChannels = _defaultMaxChannels; - _isSyncingQueuedMessages = false; - _queuedMessageSyncInFlight = false; - _didInitialQueueSync = false; - _pendingQueueSync = false; - _isSyncingChannels = false; - _channelSyncInFlight = false; - _hasLoadedChannels = false; + _resetSyncProgressState(); _pendingChannelSentQueue.clear(); _pendingGenericAckQueue.clear(); _reactionSendQueueSequence = 0; @@ -2717,10 +2830,14 @@ class MeshCoreConnector extends ChangeNotifier { _isLoadingContacts = true; _preserveContactsOnRefresh = preserveExisting; + _contactSyncTotal = null; + _contactSyncReceived = 0; + _contactSyncUsesSinceFilter = since != null; if (!preserveExisting) { + _hasLoadedContacts = false; _contacts.clear(); - notifyListeners(); } + notifyListeners(); await sendFrame(buildGetContactsFrame(since: since)); } @@ -3205,6 +3322,9 @@ class MeshCoreConnector extends ChangeNotifier { unawaited(_persistContacts()); _conversations.remove(contact.publicKeyHex); _loadedConversationKeys.remove(contact.publicKeyHex); + final removedCount = _contactUnreadCount[contact.publicKeyHex] ?? 0; + _cachedContactsUnreadTotal = (_cachedContactsUnreadTotal - removedCount) + .clamp(0, _cachedContactsUnreadTotal); _contactUnreadCount.remove(contact.publicKeyHex); _unreadStore.saveContactUnreadCount( Map.from(_contactUnreadCount), @@ -3342,11 +3462,20 @@ class MeshCoreConnector extends ChangeNotifier { Future syncQueuedMessages({bool force = false}) async { if (!isConnected) return; if (!force && _isSyncingQueuedMessages) return; + if (_isProcessingDeferredQueuedContactMessages) { + _pendingQueueSync = true; + return; + } if (_awaitingSelfInfo || _isLoadingContacts) { _pendingQueueSync = true; return; } + if (_isSyncingChannels || _channelSyncInFlight) { + _pendingQueueSync = true; + return; + } _isSyncingQueuedMessages = true; + notifyListeners(); await _requestNextQueuedMessage(); } @@ -3380,6 +3509,8 @@ class MeshCoreConnector extends ChangeNotifier { _isSyncingQueuedMessages = false; _queueSyncTimeout?.cancel(); _queueSyncRetries = 0; + notifyListeners(); + _continueAfterQueuedMessageSync(); } } @@ -3399,6 +3530,8 @@ class MeshCoreConnector extends ChangeNotifier { _queuedMessageSyncInFlight = false; _isSyncingQueuedMessages = false; _queueSyncRetries = 0; + notifyListeners(); + _continueAfterQueuedMessageSync(); } } @@ -3498,6 +3631,7 @@ class MeshCoreConnector extends ChangeNotifier { _isLoadingChannels = true; _isSyncingChannels = true; + _hasLoadedChannels = false; _previousChannelsCache = List.from(_channels); _channels.clear(); _nextChannelIndexToRequest = 0; @@ -3587,6 +3721,7 @@ class MeshCoreConnector extends ChangeNotifier { _nextChannelIndexToRequest++; _channelSyncRetries = 0; _channelSyncInFlight = false; + notifyListeners(); unawaited(_requestNextChannel()); } } @@ -3603,6 +3738,7 @@ class MeshCoreConnector extends ChangeNotifier { // Cache channels for offline use _cachedChannels = List.from(_channels); unawaited(_channelStore.saveChannels(_channels)); + _recalculateCachedChannelsUnreadTotal(); // Apply ordering and notify UI _applyChannelOrder(); @@ -3621,16 +3757,31 @@ class MeshCoreConnector extends ChangeNotifier { if (completed) { _hasLoadedChannels = true; _previousChannelsCache.clear(); + } else if (_channels.isEmpty && _previousChannelsCache.isNotEmpty) { + // A failed initial sync should not leave the UI empty/spinning forever. + // Restore the pre-sync list so cached channels remain usable. + _channels.addAll(_previousChannelsCache); + _applyChannelOrder(); + _recalculateCachedChannelsUnreadTotal(); } - // Fallback: if contact sync was deferred waiting for channel 0 but - // channel sync finished without triggering it, start contacts now. - if (_pendingInitialContactsSync && isConnected) { - _pendingInitialContactsSync = false; - unawaited(getContacts()); + if (isConnected) { + _startPostChannelInitialQueuedMessageSync(); } // Keep cache on failure/disconnection for future attempts + if (!completed) { + notifyListeners(); + } + } + + void _startPostChannelInitialQueuedMessageSync() { + if (_pendingInitialQueuedMessageSync || _pendingQueueSync) { + _deferQueuedContactMessagesUntilContacts = _pendingInitialContactsSync; + _pendingInitialQueuedMessageSync = false; + _pendingQueueSync = false; + unawaited(syncQueuedMessages(force: true)); + } } Future setChannel(int index, String name, Uint8List psk) async { @@ -3682,6 +3833,20 @@ class MeshCoreConnector extends ChangeNotifier { _contacts.clear(); } _isLoadingContacts = true; + _contactSyncReceived = 0; + // Firmware v3+ includes total contacts after CONTACTS_START. + // Incremental sync reports total contacts, not filtered result count. + if (frame.length >= 5 && !_contactSyncUsesSinceFilter) { + final reader = BufferReader(frame); + reader.skipBytes(1); + _contactSyncTotal = reader.readUInt32LE(); + } else if (!_contactSyncUsesSinceFilter) { + // Older firmwares may omit the count; use the nRF node capacity as + // a conservative progress fallback instead of hiding the progress. + _contactSyncTotal = _defaultMaxContacts; + } else { + _contactSyncTotal = null; + } notifyListeners(); break; case pushCodeAdvert: @@ -3699,7 +3864,9 @@ class MeshCoreConnector extends ChangeNotifier { case respCodeEndOfContacts: debugPrint('Got END_OF_CONTACTS'); _isLoadingContacts = false; + _hasLoadedContacts = true; _preserveContactsOnRefresh = false; + _contactSyncUsesSinceFilter = false; unawaited(updateKnownDiscovered()); notifyListeners(); unawaited(_persistContacts()); @@ -3709,23 +3876,21 @@ class MeshCoreConnector extends ChangeNotifier { !_channelSyncInFlight) { unawaited(_requestNextChannel()); } - if (!_didInitialQueueSync || _pendingQueueSync) { - _didInitialQueueSync = true; + if (_deferQueuedContactMessagesUntilContacts) { + unawaited(_processDeferredQueuedContactMessages()); + } else if (_pendingQueueSync) { _pendingQueueSync = false; unawaited(syncQueuedMessages(force: true)); } - if (_pendingDeferredChannelSyncAfterContacts && - (_activeTransport == MeshCoreTransportType.bluetooth || - _activeTransport == MeshCoreTransportType.usb || - _activeTransport == MeshCoreTransportType.tcp)) { - _pendingDeferredChannelSyncAfterContacts = false; - _pendingInitialChannelSync = false; - unawaited(getChannels()); - } break; case respCodeContactMsgRecv: case respCodeContactMsgRecvV3: - _handleIncomingMessage(frame); + if (_shouldDeferQueuedContactMessage(frame)) { + _deferredQueuedContactMessageFrames.add(Uint8List.fromList(frame)); + _handleQueuedMessageReceived(); + } else { + unawaited(_handleIncomingMessage(frame)); + } break; case respCodeChannelMsgRecv: case respCodeChannelMsgRecvV3: @@ -3909,23 +4074,8 @@ class MeshCoreConnector extends ChangeNotifier { _selfInfoRetryTimer = null; notifyListeners(); - // Auto-fetch contacts after getting self info. On web BLE, defer this - // until after channel 0 so startup writes stay serialized. - if (PlatformInfo.isWeb && - _activeTransport == MeshCoreTransportType.bluetooth) { - _pendingInitialContactsSync = true; - } else if (_activeTransport == MeshCoreTransportType.usb || - _activeTransport == MeshCoreTransportType.tcp) { - _pendingDeferredChannelSyncAfterContacts = true; - getContacts(); - } else { - getContacts(); - } - if (_shouldGateInitialChannelSync && - _activeTransport != MeshCoreTransportType.usb && - _activeTransport != MeshCoreTransportType.tcp) { - _maybeStartInitialChannelSync(); - } + // Start the serialized initial sync pipeline after SELF_INFO. + _maybeStartInitialChannelSync(); } void _handleDeviceInfo(Uint8List frame) { @@ -3969,7 +4119,7 @@ class MeshCoreConnector extends ChangeNotifier { unawaited(loadAllChannelMessages(maxChannels: nextMaxChannels)); if (isConnected && _selfPublicKey != null && - (!_shouldGateInitialChannelSync || !_pendingInitialChannelSync)) { + !_pendingInitialChannelSync) { unawaited(getChannels(maxChannels: nextMaxChannels)); } } @@ -3984,12 +4134,13 @@ class MeshCoreConnector extends ChangeNotifier { if (!_pendingInitialChannelSync || !isConnected) { return; } - if (_selfPublicKey == null || !_hasReceivedDeviceInfo) { + if (_selfPublicKey == null || + (_shouldGateInitialChannelSync && !_hasReceivedDeviceInfo)) { return; } _pendingInitialChannelSync = false; - unawaited(getChannels(maxChannels: _maxChannels)); + unawaited(getChannels(maxChannels: _maxChannels, force: true)); } void _handleNoMoreMessages() { @@ -3998,6 +4149,64 @@ class MeshCoreConnector extends ChangeNotifier { _isSyncingQueuedMessages = false; _queuedMessageSyncInFlight = false; _queueSyncRetries = 0; // Reset retry counter on successful completion + notifyListeners(); + _continueAfterQueuedMessageSync(); + } + + bool _shouldDeferQueuedContactMessage(Uint8List frame) { + if (!_deferQueuedContactMessagesUntilContacts || + !_isSyncingQueuedMessages) { + return false; + } + if (frame.isEmpty) return false; + return frame[0] == respCodeContactMsgRecv || + frame[0] == respCodeContactMsgRecvV3; + } + + void _continueAfterQueuedMessageSync() { + if (!_deferQueuedContactMessagesUntilContacts) return; + if (_pendingInitialContactsSync && isConnected) { + _pendingInitialContactsSync = false; + unawaited(getContacts()); + return; + } + unawaited(_processDeferredQueuedContactMessages()); + } + + Future _processDeferredQueuedContactMessages() async { + if (!_deferQueuedContactMessagesUntilContacts || + _isProcessingDeferredQueuedContactMessages) { + return; + } + if (_deferredQueuedContactMessageFrames.isEmpty) { + _deferQueuedContactMessagesUntilContacts = false; + notifyListeners(); + if (_pendingQueueSync && isConnected) { + _pendingQueueSync = false; + unawaited(syncQueuedMessages(force: true)); + } + return; + } + + _isProcessingDeferredQueuedContactMessages = true; + notifyListeners(); + try { + // Replay direct/room queued messages only after contacts are loaded, so + // sender prefixes can be resolved against the current contact list. + while (_deferredQueuedContactMessageFrames.isNotEmpty) { + final frame = _deferredQueuedContactMessageFrames.removeAt(0); + await _handleIncomingMessage(frame); + } + } finally { + _deferQueuedContactMessagesUntilContacts = false; + _isProcessingDeferredQueuedContactMessages = false; + notifyListeners(); + } + + if (_pendingQueueSync && isConnected) { + _pendingQueueSync = false; + unawaited(syncQueuedMessages(force: true)); + } } void _handleQueuedMessageReceived() { @@ -4006,6 +4215,7 @@ class MeshCoreConnector extends ChangeNotifier { _queueSyncTimeout?.cancel(); // Cancel timeout - message arrived _queuedMessageSyncInFlight = false; _queueSyncRetries = 0; // Reset retry counter on successful message + notifyListeners(); unawaited(_requestNextQueuedMessage()); } @@ -4147,11 +4357,15 @@ class MeshCoreConnector extends ChangeNotifier { void _handleContact(Uint8List frame, {bool isContact = true}) { final contactTmp = Contact.fromFrame(frame); if (contactTmp != null) { + if (isContact && _isLoadingContacts) { + _contactSyncReceived++; + } if (listEquals(contactTmp.publicKey, _selfPublicKey)) { appLogger.info( 'Ignoring contact with self public key: ${contactTmp.name}', tag: 'Connector', ); + notifyListeners(); removeContact(contactTmp); return; } @@ -4159,6 +4373,9 @@ class MeshCoreConnector extends ChangeNotifier { _handleDiscovery(contact, frame, noNotify: true, addActive: true); if (contact.type == advTypeRepeater) { + final removedCount = _contactUnreadCount[contact.publicKeyHex] ?? 0; + _cachedContactsUnreadTotal = (_cachedContactsUnreadTotal - removedCount) + .clamp(0, _cachedContactsUnreadTotal); _contactUnreadCount.remove(contact.publicKeyHex); _unreadStore.saveContactUnreadCount( Map.from(_contactUnreadCount), @@ -4212,6 +4429,7 @@ class MeshCoreConnector extends ChangeNotifier { "Discovered contact ${contact.name} (type ${contact.typeLabelRaw}) not added due to auto-add settings", tag: 'Connector', ); + notifyListeners(); return; } } @@ -4249,6 +4467,9 @@ class MeshCoreConnector extends ChangeNotifier { } if (contact.type == advTypeRepeater) { + final removedCount = _contactUnreadCount[contact.publicKeyHex] ?? 0; + _cachedContactsUnreadTotal = (_cachedContactsUnreadTotal - removedCount) + .clamp(0, _cachedContactsUnreadTotal); _contactUnreadCount.remove(contact.publicKeyHex); _unreadStore.saveContactUnreadCount( Map.from(_contactUnreadCount), @@ -4426,9 +4647,15 @@ class MeshCoreConnector extends ChangeNotifier { return false; } - void _handleIncomingMessage(Uint8List frame) async { + Future _handleIncomingMessage(Uint8List frame) async { 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); // If message parsing failed due to unknown contact, refresh contacts and retry @@ -4494,37 +4721,52 @@ class MeshCoreConnector extends ChangeNotifier { } } _addMessage(message.senderKeyHex, message); - if (!message.isOutgoing) { - unawaited( - _translateIncomingContactMessage(message.senderKeyHex, message), - ); - } _maybeIncrementContactUnread(message); notifyListeners(); - // Show notification for new incoming message + // Show notification for new incoming message (run async with translation) if (!message.isOutgoing && !message.isCli && _appSettingsService != null) { final settings = _appSettingsService!.settings; if (settings.notificationsEnabled && settings.notifyOnNewMessage) { - if (contact?.type == advTypeChat) { - _notificationService.showMessageNotification( - contactName: contact?.name ?? 'Unknown', - message: message.text, - contactId: message.senderKeyHex, - badgeCount: getTotalUnreadCount(), + final msg = message; // capture for closure + final c = contact; // capture contact reference + unawaited(() async { + final translationResult = await translateContactMessage( + msg.senderKeyHex, + msg, ); - } else if (contact?.type == advTypeRoom) { - _notificationService.showMessageNotification( - contactName: contact?.name ?? 'Unknown Room', - message: message.text.length > 4 - ? message.text.substring(4) - : message.text, - contactId: message.senderKeyHex, - badgeCount: getTotalUnreadCount(), - ); - } + if (c?.type == advTypeChat) { + 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', + 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(); @@ -4567,16 +4809,24 @@ class MeshCoreConnector extends ChangeNotifier { timestampRaw * 1000, ); - if (txtType == 2) { - reader.skipBytes(4); // Skip extra 4 bytes for signed/plain variants + final flags = txtType; + 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 flags = txtType; - final shiftedType = flags >> 2; - final rawType = flags; - final isPlain = shiftedType == txtTypePlain || rawType == txtTypePlain; + final isPlain = + shiftedType == txtTypePlain || rawType == txtTypePlain || isSigned; final isCli = shiftedType == txtTypeCliData || rawType == txtTypeCliData; if (!isPlain && !isCli) { appLogger.warn( @@ -4613,9 +4863,7 @@ class MeshCoreConnector extends ChangeNotifier { status: MessageStatus.delivered, pathLength: pathLength == 0xFF ? -1 : (pathLength & 0x3F), pathBytes: Uint8List(0), - fourByteRoomContactKey: msgText.length >= 4 - ? Uint8List.fromList(msgText.substring(0, 4).codeUnits) - : null, + fourByteRoomContactKey: roomAuthorPrefix, ); } catch (e) { appLogger.warn('Error parsing contact direct message: $e'); @@ -4792,6 +5040,7 @@ class MeshCoreConnector extends ChangeNotifier { void _maybeNotifyChannelMessage( ChannelMessage message, { String? channelName, + TranslationResult? translationResult, }) { if (message.isOutgoing || _appSettingsService == null) return; final channelIndex = message.channelIndex; @@ -4805,16 +5054,30 @@ class MeshCoreConnector extends ChangeNotifier { final label = channelName ?? _channelDisplayName(channelIndex); if (_appSettingsService!.isChannelMuted(label)) return; - _notificationService.showChannelMessageNotification( - channelName: label, - senderName: message.senderName, - message: message.text, - channelIndex: channelIndex, - badgeCount: getTotalUnreadCount(), - ); + // Reuse translation result only if completed and non-empty; else use original text + final resolvedText = + (translationResult != null && + translationResult.status == MessageTranslationStatus.completed && + translationResult.translatedText.trim().isNotEmpty) + ? 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); if (parsed != null && parsed.channelIndex != null) { if (_shouldDropSelfChannelMessage(parsed.senderName, parsed.pathBytes)) { @@ -4834,15 +5097,17 @@ class MeshCoreConnector extends ChangeNotifier { pathHashWidth: message.pathHashWidth, ); final isNew = _addChannelMessage(message.channelIndex!, message); - if (isNew && !message.isOutgoing) { - unawaited( - _translateIncomingChannelMessage(message.channelIndex!, message), - ); - } _maybeIncrementChannelUnread(message, isNew: isNew); notifyListeners(); - if (isNew) { - _maybeNotifyChannelMessage(message); + if (isNew && !message.isOutgoing) { + final msg = message; // capture for closure + unawaited(() async { + final translationResult = await translateChannelMessage( + msg.channelIndex!, + msg, + ); + _maybeNotifyChannelMessage(msg, translationResult: translationResult); + }()); } _handleQueuedMessageReceived(); } 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; try { final reader = BufferReader(frame); @@ -4920,16 +5185,24 @@ class MeshCoreConnector extends ChangeNotifier { pathHashWidth: message.pathHashWidth, ); final isNew = _addChannelMessage(channel.index, message); - if (isNew && !message.isOutgoing) { - unawaited(_translateIncomingChannelMessage(channel.index, message)); - } _maybeIncrementChannelUnread(message, isNew: isNew); notifyListeners(); if (isNew) { - final label = channel.name.isEmpty - ? 'Channel ${channel.index}' - : channel.name; - _maybeNotifyChannelMessage(message, channelName: label); + // Run translation + notification asynchronously to avoid blocking + unawaited(() async { + final translationResult = await translateChannelMessage( + channel.index, + message, + ); + final label = channel.name.isEmpty + ? 'Channel ${channel.index}' + : channel.name; + _maybeNotifyChannelMessage( + message, + channelName: label, + translationResult: translationResult, + ); + }()); } return; } catch (e) { @@ -5147,14 +5420,7 @@ class MeshCoreConnector extends ChangeNotifier { // Move to next channel _nextChannelIndexToRequest++; - if (PlatformInfo.isWeb && - _activeTransport == MeshCoreTransportType.bluetooth && - channel.index == 0 && - _pendingInitialContactsSync) { - _pendingInitialContactsSync = false; - unawaited(getContacts()); - return; - } + notifyListeners(); unawaited(_requestNextChannel()); return; } else { @@ -5273,6 +5539,7 @@ class MeshCoreConnector extends ChangeNotifier { final channel = _findChannelByIndex(channelIndex); if (channel != null) { channel.unreadCount++; + _cachedChannelsUnreadTotal++; _appDebugLogService?.info( 'Channel ${channel.name.isNotEmpty ? channel.name : channelIndex} unread count incremented to ${channel.unreadCount}', tag: 'Unread', @@ -5317,6 +5584,7 @@ class MeshCoreConnector extends ChangeNotifier { final currentCount = _contactUnreadCount[contactKey] ?? 0; _contactUnreadCount[contactKey] = currentCount + 1; + _cachedContactsUnreadTotal++; _appDebugLogService?.info( 'Contact $contactKey unread count incremented to ${currentCount + 1}', tag: 'Unread', @@ -5854,14 +6122,9 @@ class MeshCoreConnector extends ChangeNotifier { // Preserve deviceId and displayName for UI display during reconnection // They're only cleared on manual disconnect via disconnect() method _hasReceivedDeviceInfo = false; - _pendingInitialChannelSync = false; - _pendingInitialContactsSync = false; _maxContacts = _defaultMaxContacts; _maxChannels = _defaultMaxChannels; - _isSyncingQueuedMessages = false; - _queuedMessageSyncInFlight = false; - _isSyncingChannels = false; - _channelSyncInFlight = false; + _resetSyncProgressState(); _pendingChannelSentQueue.clear(); _pendingGenericAckQueue.clear(); _reactionSendQueueSequence = 0; diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb index 7ea19982..baf0bc3f 100644 --- a/lib/l10n/app_bg.arb +++ b/lib/l10n/app_bg.arb @@ -2099,6 +2099,9 @@ "translation_composerTitle": "Преведете преди да изпратите", "translation_enableSubtitle": "Превеждайте входящите съобщения и позволявайте предварително превеждане преди изпращане.", "translation_composerSubtitle": "Контролира началния статус на иконата за превод, създадена от композитора.", + "translation_autoIncomingTitle": "Автоматичен превод на съобщения", + "translation_autoIncomingSubtitle": "Превежда автоматично съобщенията за известия, както и за чатове или канали.", + "translation_translateMessage": "Преведи съобщението", "translation_targetLanguage": "Целеви език", "translation_useAppLanguage": "Използвайте езика на приложението", "translation_downloadedModelLabel": "Изтегнат модел", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index fd9ab886..5b717e02 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -2127,6 +2127,9 @@ "translation_enableSubtitle": "Nachrichten empfangen und übersetzen sowie die Möglichkeit bieten, Nachrichten vor dem Versenden zu übersetzen.", "translation_enableTitle": "Aktivieren Sie die Übersetzung", "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_useAppLanguage": "Verwenden Sie die App-Sprache", "translation_downloadedModelLabel": "Heruntergeladenes Modell", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e9a56cfa..af1bdb24 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2292,6 +2292,9 @@ "translation_enableSubtitle": "Translate incoming messages and allow pre-send translation.", "translation_composerTitle": "Translate before sending", "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_useAppLanguage": "Use app language", "translation_downloadedModelLabel": "Downloaded model", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 697cefa7..8d76f576 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2128,6 +2128,9 @@ "translation_enableTitle": "Habilitar la traducción", "translation_composerTitle": "Traducir antes de enviar", "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_useAppLanguage": "Utilizar el idioma de la aplicación", "translation_downloadedModelLabel": "Modelo descargado", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 5bc12755..b46d0b57 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2099,6 +2099,9 @@ "translation_title": "Traduction", "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_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_useAppLanguage": "Utiliser la langue de l'application", "translation_downloadedModelLabel": "Modèle téléchargé", diff --git a/lib/l10n/app_hu.arb b/lib/l10n/app_hu.arb index e3081f87..c4576038 100644 --- a/lib/l10n/app_hu.arb +++ b/lib/l10n/app_hu.arb @@ -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_composerTitle": "Fordítsa el, mielőtt elküldi", "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_useAppLanguage": "Használja az alkalmazás nyelvének beállítását.", "translation_downloadedModelLabel": "Letöltött modell", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index dac5f78c..a8e1d4ee 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -2100,6 +2100,9 @@ "translation_enableTitle": "Abilitare la traduzione", "translation_title": "Traduzione", "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_useAppLanguage": "Utilizza la lingua dell'app", "translation_downloadedModelLabel": "Modello scaricato", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index f2037360..def330d2 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -2137,6 +2137,9 @@ "translation_composerTitle": "送信する前に翻訳する", "translation_enableTitle": "翻訳機能を有効にする", "translation_composerSubtitle": "作曲家翻訳アイコンのデフォルト状態を制御する。", + "translation_autoIncomingTitle": "メッセージを自動翻訳", + "translation_autoIncomingSubtitle": "通知やチャット、チャンネルのメッセージを自動的に翻訳します。", + "translation_translateMessage": "メッセージを翻訳", "translation_targetLanguage": "翻訳対象言語", "translation_useAppLanguage": "アプリの言語設定", "translation_downloadedModelLabel": "ダウンロードしたモデル", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 27e7c1ee..a9f4d494 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -2137,6 +2137,9 @@ "translation_enableTitle": "번역 기능 활성화", "translation_composerTitle": "보내기 전에 번역", "translation_composerSubtitle": "컴포저 번역 아이콘의 기본 상태를 제어합니다.", + "translation_autoIncomingTitle": "메시지 자동 번역", + "translation_autoIncomingSubtitle": "알림과 채팅 또는 채널의 메시지를 자동으로 번역합니다.", + "translation_translateMessage": "메시지 번역", "translation_targetLanguage": "목표 언어", "translation_useAppLanguage": "앱 언어 사용", "translation_downloadedModelLabel": "다운로드한 모델", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index c4c69237..e25ede88 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -7138,6 +7138,24 @@ abstract class AppLocalizations { /// **'Controls the default state of the composer translation icon.'** 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. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index aeb059f3..4e564088 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -4173,6 +4173,16 @@ class AppLocalizationsBg extends AppLocalizations { String get translation_composerSubtitle => 'Контролира началния статус на иконата за превод, създадена от композитора.'; + @override + String get translation_autoIncomingTitle => 'Автоматичен превод на съобщения'; + + @override + String get translation_autoIncomingSubtitle => + 'Превежда автоматично съобщенията за известия, както и за чатове или канали.'; + + @override + String get translation_translateMessage => 'Преведи съобщението'; + @override String get translation_targetLanguage => 'Целеви език'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 88ad3dde..1bea3ca0 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -4188,6 +4188,17 @@ class AppLocalizationsDe extends AppLocalizations { String get translation_composerSubtitle => '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 String get translation_targetLanguage => 'Zielsprache'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 4338f35a..a2ecac1a 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -4097,6 +4097,17 @@ class AppLocalizationsEn extends AppLocalizations { String get translation_composerSubtitle => '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 String get translation_targetLanguage => 'Target language'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 6fd8f0f5..356d9e49 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -4175,6 +4175,17 @@ class AppLocalizationsEs extends AppLocalizations { String get translation_composerSubtitle => '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 String get translation_targetLanguage => 'Idioma de destino'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 54c80d7e..158ebac7 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -4205,6 +4205,17 @@ class AppLocalizationsFr extends AppLocalizations { String get translation_composerSubtitle => '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 String get translation_targetLanguage => 'Langue cible'; diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index 5709f65e..802da6c9 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -4193,6 +4193,16 @@ class AppLocalizationsHu extends AppLocalizations { String get translation_composerSubtitle => '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 String get translation_targetLanguage => 'Célnyelv'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 766a5a9c..078fc96d 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -4181,6 +4181,17 @@ class AppLocalizationsIt extends AppLocalizations { String get translation_composerSubtitle => '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 String get translation_targetLanguage => 'Lingua di destinazione'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index ef66185f..9ebae739 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -3953,6 +3953,16 @@ class AppLocalizationsJa extends AppLocalizations { @override String get translation_composerSubtitle => '作曲家翻訳アイコンのデフォルト状態を制御する。'; + @override + String get translation_autoIncomingTitle => 'メッセージを自動翻訳'; + + @override + String get translation_autoIncomingSubtitle => + '通知やチャット、チャンネルのメッセージを自動的に翻訳します。'; + + @override + String get translation_translateMessage => 'メッセージを翻訳'; + @override String get translation_targetLanguage => '翻訳対象言語'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index e8ffd898..2a99bbf1 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -3954,6 +3954,16 @@ class AppLocalizationsKo extends AppLocalizations { @override String get translation_composerSubtitle => '컴포저 번역 아이콘의 기본 상태를 제어합니다.'; + @override + String get translation_autoIncomingTitle => '메시지 자동 번역'; + + @override + String get translation_autoIncomingSubtitle => + '알림과 채팅 또는 채널의 메시지를 자동으로 번역합니다.'; + + @override + String get translation_translateMessage => '메시지 번역'; + @override String get translation_targetLanguage => '목표 언어'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 0996cff3..2995afde 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -4158,6 +4158,16 @@ class AppLocalizationsNl extends AppLocalizations { String get translation_composerSubtitle => '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 String get translation_targetLanguage => 'Doeltaal'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index d0af0520..b85fd254 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -4196,6 +4196,17 @@ class AppLocalizationsPl extends AppLocalizations { String get translation_composerSubtitle => '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 String get translation_targetLanguage => 'Język docelowy'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 14287616..21f03652 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -4171,6 +4171,17 @@ class AppLocalizationsPt extends AppLocalizations { String get translation_composerSubtitle => '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 String get translation_targetLanguage => 'Língua-alvo'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 3643e25e..19937d4e 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -4189,6 +4189,17 @@ class AppLocalizationsRu extends AppLocalizations { String get translation_composerSubtitle => 'Управляет исходным состоянием значка перевода, предоставляемого редактором.'; + @override + String get translation_autoIncomingTitle => + 'Автоматически переводить сообщения'; + + @override + String get translation_autoIncomingSubtitle => + 'Автоматически переводит сообщения для уведомлений, а также для чатов и каналов.'; + + @override + String get translation_translateMessage => 'Перевести сообщение'; + @override String get translation_targetLanguage => 'Целевой язык'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index 34f31aff..b49b3904 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -4153,6 +4153,16 @@ class AppLocalizationsSk extends AppLocalizations { String get translation_composerSubtitle => '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 String get translation_targetLanguage => 'Cieľový jazyk'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index c5668980..6ca860fa 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -4151,6 +4151,16 @@ class AppLocalizationsSl extends AppLocalizations { String get translation_composerSubtitle => '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 String get translation_targetLanguage => 'Ciljna jezika'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index 071ebb8c..533d42c1 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -4125,6 +4125,17 @@ class AppLocalizationsSv extends AppLocalizations { String get translation_composerSubtitle => '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 String get translation_targetLanguage => 'Målmedvetet språk'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 469dc67d..7f4f90bb 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -4188,6 +4188,17 @@ class AppLocalizationsUk extends AppLocalizations { String get translation_composerSubtitle => 'Контролює стан ікон перекладу, який використовується за замовчуванням.'; + @override + String get translation_autoIncomingTitle => + 'Автоматично перекладати повідомлення'; + + @override + String get translation_autoIncomingSubtitle => + 'Автоматично перекладає повідомлення для сповіщень, а також для чатів і каналів.'; + + @override + String get translation_translateMessage => 'Перекласти повідомлення'; + @override String get translation_targetLanguage => 'Цільова мова'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index c399bb6c..88b252e5 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -3828,6 +3828,15 @@ class AppLocalizationsZh extends AppLocalizations { @override String get translation_composerSubtitle => '控制作曲家翻译图标的默认状态。'; + @override + String get translation_autoIncomingTitle => '自动翻译消息'; + + @override + String get translation_autoIncomingSubtitle => '自动为通知以及聊天或频道翻译消息。'; + + @override + String get translation_translateMessage => '翻译消息'; + @override String get translation_targetLanguage => '目标语言'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 072b2d51..3a4a5acd 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -2101,6 +2101,9 @@ "translation_composerTitle": "Vertaal voor verzending", "translation_composerSubtitle": "Stelt de standaardstatus van het pictogram voor de vertaling van de componist in.", "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_downloadedModelLabel": "Gedownloade model", "translation_presetModelLabel": "Voorgeprogrammeerd Hugging Face-model", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index afb88e57..e3b9704e 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -2137,6 +2137,9 @@ "translation_enableTitle": "Włącz tłumaczenie", "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_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_useAppLanguage": "Użyj języka aplikacji", "translation_downloadedModelLabel": "Pobudowany model", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 01243468..2e8ef6cf 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -2100,6 +2100,9 @@ "translation_enableTitle": "Ativar a tradução", "translation_title": "Tradução", "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_useAppLanguage": "Utilize o idioma da aplicação", "translation_downloadedModelLabel": "Modelo baixado", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 0dd6d497..2cd790d7 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1268,6 +1268,9 @@ "translation_title": "Перевод", "translation_enableTitle": "Включить перевод", "translation_composerSubtitle": "Управляет исходным состоянием значка перевода, предоставляемого редактором.", + "translation_autoIncomingTitle": "Автоматически переводить сообщения", + "translation_autoIncomingSubtitle": "Автоматически переводит сообщения для уведомлений, а также для чатов и каналов.", + "translation_translateMessage": "Перевести сообщение", "translation_targetLanguage": "Целевой язык", "translation_useAppLanguage": "Используйте язык приложения", "translation_downloadedModelLabel": "Загруженная модель", diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb index 1393d22b..13ac1ce1 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -2100,6 +2100,9 @@ "translation_composerTitle": "Preložte pred odeslaním", "translation_title": "Preklad", "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_useAppLanguage": "Použite jazyk aplikácie", "translation_downloadedModelLabel": "Stiahnutý model", diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index e64fee4c..1ee5a6eb 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -2099,6 +2099,9 @@ "translation_enableSubtitle": "Prevedite vstopne sporočila in omogočite predhodno prevajanje.", "translation_enableTitle": "Omogočite prevod", "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_useAppLanguage": "Uporabite jezik aplikacije", "translation_downloadedModelLabel": "Naložen model", diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index e8f1b230..c2797395 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -2100,6 +2100,9 @@ "translation_title": "Översättning", "translation_composerTitle": "Översätt innan du skickar", "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_useAppLanguage": "Använd appens språk", "translation_downloadedModelLabel": "Nedladdad modell", diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 10025137..5f4da699 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -2109,6 +2109,9 @@ "translation_enableTitle": "Увімкнути переклад", "translation_enableSubtitle": "Перекладати отримані повідомлення та дозволяти попередній переклад перед відправкою.", "translation_composerSubtitle": "Контролює стан ікон перекладу, який використовується за замовчуванням.", + "translation_autoIncomingTitle": "Автоматично перекладати повідомлення", + "translation_autoIncomingSubtitle": "Автоматично перекладає повідомлення для сповіщень, а також для чатів і каналів.", + "translation_translateMessage": "Перекласти повідомлення", "translation_targetLanguage": "Цільова мова", "translation_useAppLanguage": "Використовувати мову застосунку", "translation_downloadedModelLabel": "Завантажений шаблон", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index d4743425..26f12c1d 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -2105,6 +2105,9 @@ "translation_composerTitle": "在发送之前进行翻译", "translation_enableTitle": "启用翻译功能", "translation_composerSubtitle": "控制作曲家翻译图标的默认状态。", + "translation_autoIncomingTitle": "自动翻译消息", + "translation_autoIncomingSubtitle": "自动为通知以及聊天或频道翻译消息。", + "translation_translateMessage": "翻译消息", "translation_targetLanguage": "目标语言", "translation_useAppLanguage": "使用应用程序语言", "translation_downloadedModelLabel": "下载的模型", diff --git a/lib/main.dart b/lib/main.dart index cd622811..37aa29ff 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'l10n/app_localizations.dart'; import 'package:provider/provider.dart'; @@ -214,7 +215,10 @@ class MeshCoreApp extends StatelessWidget { // Update notification service with resolved locale final locale = Localizations.localeOf(context); NotificationService().setLocale(locale); - return child ?? const SizedBox.shrink(); + return AnnotatedRegion( + value: _systemUiOverlayStyle(context), + child: child ?? const SizedBox.shrink(), + ); }, home: (PlatformInfo.isWeb && !PlatformInfo.isChrome) ? const ChromeRequiredScreen() @@ -236,6 +240,24 @@ class MeshCoreApp extends StatelessWidget { } } + SystemUiOverlayStyle _systemUiOverlayStyle(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final isDark = theme.brightness == Brightness.dark; + final iconBrightness = isDark ? Brightness.light : Brightness.dark; + + // Keep Android system bars aligned with the resolved Flutter theme. + return SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: iconBrightness, + statusBarBrightness: isDark ? Brightness.dark : Brightness.light, + systemNavigationBarColor: colorScheme.surface, + systemNavigationBarIconBrightness: iconBrightness, + systemNavigationBarDividerColor: colorScheme.surface, + systemNavigationBarContrastEnforced: false, + ); + } + Locale? _localeFromSetting(String? languageCode) { if (languageCode == null) return null; return Locale(languageCode); diff --git a/lib/models/app_settings.dart b/lib/models/app_settings.dart index 4e95311f..6995b6ed 100644 --- a/lib/models/app_settings.dart +++ b/lib/models/app_settings.dart @@ -113,6 +113,7 @@ class AppSettings { final int tcpServerPort; final bool jumpToOldestUnread; final bool translationEnabled; + final bool autoTranslateIncomingMessages; final String? translationTargetLanguageCode; final bool composerTranslationEnabled; final String? translationModelSourceUrl; @@ -166,6 +167,7 @@ class AppSettings { this.tcpServerPort = 0, this.jumpToOldestUnread = false, this.translationEnabled = false, + this.autoTranslateIncomingMessages = true, this.translationTargetLanguageCode, this.composerTranslationEnabled = false, this.translationModelSourceUrl, @@ -226,6 +228,7 @@ class AppSettings { 'tcp_server_port': tcpServerPort, 'jump_to_oldest_unread': jumpToOldestUnread, 'translation_enabled': translationEnabled, + 'auto_translate_incoming_messages': autoTranslateIncomingMessages, 'translation_target_language_code': translationTargetLanguageCode, 'composer_translation_enabled': composerTranslationEnabled, 'translation_model_source_url': translationModelSourceUrl, @@ -307,6 +310,8 @@ class AppSettings { tcpServerPort: json['tcp_server_port'] as int? ?? 0, jumpToOldestUnread: json['jump_to_oldest_unread'] as bool? ?? false, translationEnabled: json['translation_enabled'] as bool? ?? false, + autoTranslateIncomingMessages: + json['auto_translate_incoming_messages'] as bool? ?? true, translationTargetLanguageCode: json['translation_target_language_code'] as String?, composerTranslationEnabled: @@ -396,6 +401,7 @@ class AppSettings { int? tcpServerPort, bool? jumpToOldestUnread, bool? translationEnabled, + bool? autoTranslateIncomingMessages, Object? translationTargetLanguageCode = _unset, bool? composerTranslationEnabled, Object? translationModelSourceUrl = _unset, @@ -453,6 +459,8 @@ class AppSettings { tcpServerPort: tcpServerPort ?? this.tcpServerPort, jumpToOldestUnread: jumpToOldestUnread ?? this.jumpToOldestUnread, translationEnabled: translationEnabled ?? this.translationEnabled, + autoTranslateIncomingMessages: + autoTranslateIncomingMessages ?? this.autoTranslateIncomingMessages, translationTargetLanguageCode: translationTargetLanguageCode == _unset ? this.translationTargetLanguageCode : translationTargetLanguageCode as String?, diff --git a/lib/models/channel.dart b/lib/models/channel.dart index 4fdd6270..9baf6302 100644 --- a/lib/models/channel.dart +++ b/lib/models/channel.dart @@ -4,6 +4,9 @@ import 'dart:typed_data'; import 'package:crypto/crypto.dart' as crypto; import '../connector/meshcore_protocol.dart'; +import 'community.dart'; + +enum ChannelType { public, private, hashtag, communityPublic, communityHashtag } class Channel { final int index; @@ -111,5 +114,36 @@ class Channel { 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'; } diff --git a/lib/models/community.dart b/lib/models/community.dart index c829f3d1..7261ddf9 100644 --- a/lib/models/community.dart +++ b/lib/models/community.dart @@ -4,6 +4,8 @@ import 'dart:typed_data'; import 'package:crypto/crypto.dart' as crypto; +import 'channel.dart'; + /// Represents a community with a shared secret for deriving channel PSKs. /// /// 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(); } + /// 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 Community addHashtagChannel(String hashtag) { final normalized = _normalizeCommunityHashtag(hashtag); @@ -237,3 +245,28 @@ class Community { @override int get hashCode => id.hashCode; } + +class CommunityPskIndex { + // Cache of PSK hex -> Community for quick lookup + final Map _pskToCommunity = {}; + + void initialize(List 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]; + } +} diff --git a/lib/models/radio_settings.dart b/lib/models/radio_settings.dart index 099d9201..e74cf209 100644 --- a/lib/models/radio_settings.dart +++ b/lib/models/radio_settings.dart @@ -181,6 +181,276 @@ class RadioSettings { 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', RadioSettings( diff --git a/lib/screens/app_settings_screen.dart b/lib/screens/app_settings_screen.dart index 94b3efe8..9b0fb378 100644 --- a/lib/screens/app_settings_screen.dart +++ b/lib/screens/app_settings_screen.dart @@ -11,6 +11,7 @@ import '../services/app_settings_service.dart'; import '../services/notification_service.dart'; import '../services/translation_service.dart'; import '../widgets/adaptive_app_bar_title.dart'; +import '../widgets/sync_progress_overlay.dart'; import '../helpers/snack_bar_builder.dart'; import 'map_cache_screen.dart'; @@ -23,6 +24,7 @@ class AppSettingsScreen extends StatelessWidget { appBar: AppBar( title: AdaptiveAppBarTitle(context.l10n.appSettings_title), centerTitle: true, + bottom: const SyncProgressAppBarBottom(), ), body: SafeArea( top: false, @@ -559,6 +561,7 @@ class AppSettingsScreen extends StatelessWidget { TranslationService translationService, ) { final settings = settingsService.settings; + final translationEnabled = settings.translationEnabled; return Card( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -579,11 +582,41 @@ class AppSettingsScreen extends StatelessWidget { ), const Divider(height: 1), SwitchListTile( - secondary: const Icon(Icons.outgoing_mail), - title: Text(context.l10n.translation_composerTitle), - subtitle: Text(context.l10n.translation_composerSubtitle), + secondary: Icon( + Icons.auto_awesome_outlined, + 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, - onChanged: settingsService.setComposerTranslationEnabled, + onChanged: translationEnabled + ? settingsService.setComposerTranslationEnabled + : null, ), const Divider(height: 1), ListTile( diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index 92903521..0f00769d 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:math' as math; @@ -8,6 +9,8 @@ import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; +import '../models/community.dart'; +import '../storage/community_store.dart'; import '../utils/platform_info.dart'; import '../helpers/chat_scroll_controller.dart'; import '../connector/meshcore_protocol.dart'; @@ -33,6 +36,7 @@ import '../widgets/gif_picker.dart'; import '../widgets/message_translation_button.dart'; import '../widgets/message_status_icon.dart'; import '../widgets/radio_stats_entry.dart'; +import '../widgets/sync_progress_overlay.dart'; import '../widgets/translated_message_content.dart'; import '../widgets/unread_divider.dart'; import 'channel_message_path_screen.dart'; @@ -57,8 +61,11 @@ class _ChannelChatScreenState extends State { final ChatScrollController _scrollController = ChatScrollController(); final FocusNode _textFieldFocusNode = FocusNode(); ChannelMessage? _replyingToMessage; + final CommunityStore _communityStore = CommunityStore(); + final CommunityPskIndex _communityIndex = CommunityPskIndex(); final Map _messageKeys = {}; bool _isLoadingOlder = false; + bool _communitiesLoaded = false; MeshCoreConnector? _connector; DateTime? _lastChannelSendAt; @@ -82,6 +89,7 @@ class _ChannelChatScreenState extends State { final idx = widget.channel.index; final unread = widget.initialUnreadCount; final messages = connector.getChannelMessages(widget.channel); + _loadCommunities(); ChannelMessage? anchor; if (unread > 0) { anchor = _findOldestUnreadChannelAnchor(messages, unread); @@ -108,6 +116,19 @@ class _ChannelChatScreenState extends State { }); } + // TODO: Reload communities when returning from another screen + Future _loadCommunities() async { + final connector = context.read(); + _communityStore.setPublicKeyHex = connector.selfPublicKeyHex; + final communities = await _communityStore.loadCommunities(); + if (mounted) { + setState(() { + _communityIndex.initialize(communities); + _communitiesLoaded = true; + }); + } + } + ChannelMessage? _findOldestUnreadChannelAnchor( List messages, int unreadCount, @@ -194,16 +215,63 @@ class _ChannelChatScreenState extends State { ); } + 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 Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Row( children: [ - Icon( - widget.channel.isPublicChannel ? Icons.public : Icons.tag, - size: 20, - ), + _channelIcon(widget.channel), const SizedBox(width: 8), Expanded( child: Column( @@ -237,6 +305,7 @@ class _ChannelChatScreenState extends State { ], ), centerTitle: false, + bottom: const SyncProgressAppBarBottom(), actions: [ const RadioStatsIconButton(), PopupMenuButton( @@ -1327,6 +1396,15 @@ class _ChannelChatScreenState extends State { } void _showMessageActions(ChannelMessage message) { + final translationService = context.read(); + final canTranslateMessage = + translationService.canTranslateIncoming( + text: message.text, + isCli: false, + isOutgoing: message.isOutgoing, + ) && + (message.translatedText?.trim().isEmpty ?? true); + showModalBottomSheet( context: context, builder: (sheetContext) => SafeArea( @@ -1368,6 +1446,21 @@ class _ChannelChatScreenState extends State { _copyMessageText(message.text); }, ), + if (canTranslateMessage) + ListTile( + leading: const Icon(Icons.translate), + title: Text(context.l10n.translation_translateMessage), + onTap: () { + Navigator.pop(sheetContext); + unawaited( + context.read().translateChannelMessage( + widget.channel.index, + message, + manualTranslation: true, + ), + ); + }, + ), if (!message.isOutgoing) ListTile( leading: const Icon(Icons.mark_chat_unread_outlined), diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 6dbe1aab..d871892c 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -23,6 +23,7 @@ import '../widgets/list_filter_widget.dart'; import '../widgets/empty_state.dart'; import '../widgets/qr_code_display.dart'; import '../widgets/quick_switch_bar.dart'; +import '../widgets/sync_progress_overlay.dart'; import '../widgets/unread_badge.dart'; import '../helpers/snack_bar_builder.dart'; import 'channel_chat_screen.dart'; @@ -44,11 +45,9 @@ class _ChannelsScreenState extends State with DisconnectNavigationMixin { final TextEditingController _searchController = TextEditingController(); final CommunityStore _communityStore = CommunityStore(); - Timer? _searchDebounce; + final CommunityPskIndex _communityIndex = CommunityPskIndex(); List _communities = []; - - // Cache of PSK hex -> Community for quick lookup - final Map _pskToCommunity = {}; + Timer? _searchDebounce; ChannelMessageStore get _channelMessageStore => ChannelMessageStore(); @@ -71,37 +70,11 @@ class _ChannelsScreenState extends State if (mounted) { setState(() { _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 void dispose() { _searchDebounce?.cancel(); @@ -131,6 +104,7 @@ class _ChannelsScreenState extends State title: AppBarTitle(context.l10n.channels_title), centerTitle: true, automaticallyImplyLeading: false, + bottom: const SyncProgressAppBarBottom(), actions: [ PopupMenuButton( itemBuilder: (context) => [ @@ -180,12 +154,17 @@ class _ChannelsScreenState extends State await context.read().getChannels(force: true); }, 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()); } - final channels = connector.channels; - if (channels.isEmpty) { return ListView( children: [ @@ -359,6 +338,8 @@ class _ChannelsScreenState extends State selectedIndex: 1, onDestinationSelected: (index) => _handleQuickSwitch(index, context), + contactsUnreadCount: connector.getTotalContactsUnreadCount(), + channelsUnreadCount: connector.getTotalChannelsUnreadCount(), ), ), ), @@ -374,37 +355,37 @@ class _ChannelsScreenState extends State int? dragIndex, }) { 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 IconData icon; Color iconColor; Color bgColor; - - if (isCommunityChannel) { - // Community channel styling - iconColor = Colors.purple; - bgColor = Colors.purple.withValues(alpha: 0.2); - if (isCommunityPublic) { + final ChannelType channelType = Channel.getChannelType( + channel, + _communityIndex, + ); + final bool isCommunityChannel = Channel.isCommunityChannel(channelType); + switch (channelType) { + case ChannelType.communityPublic: icon = Icons.groups; - } else { + iconColor = Colors.purple; + bgColor = Colors.purple.withValues(alpha: 0.2); + case ChannelType.communityHashtag: icon = Icons.tag; - } - } else if (channel.isPublicChannel) { - icon = Icons.public; - iconColor = Colors.green; - bgColor = Colors.green.withValues(alpha: 0.2); - } else if (channel.name.startsWith('#')) { - icon = Icons.tag; - iconColor = Colors.blue; - bgColor = Colors.blue.withValues(alpha: 0.2); - } else { - icon = Icons.lock; - iconColor = Colors.blue; - bgColor = Colors.blue.withValues(alpha: 0.2); + iconColor = Colors.purple; + bgColor = Colors.purple.withValues(alpha: 0.2); + case ChannelType.public: + icon = Icons.public; + iconColor = Colors.green; + bgColor = Colors.green.withValues(alpha: 0.2); + case ChannelType.hashtag: + icon = Icons.tag; + iconColor = Colors.blue; + bgColor = Colors.blue.withValues(alpha: 0.2); + case ChannelType.private: + icon = Icons.lock; + iconColor = Colors.blue; + bgColor = Colors.blue.withValues(alpha: 0.2); } return Card( diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index faf2ef4e..8207e071 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -41,6 +41,7 @@ import '../widgets/gif_picker.dart'; import '../widgets/message_translation_button.dart'; import '../widgets/path_selection_dialog.dart'; import '../widgets/radio_stats_entry.dart'; +import '../widgets/sync_progress_overlay.dart'; import '../widgets/translated_message_content.dart'; import '../utils/app_logger.dart'; import '../l10n/l10n.dart'; @@ -216,6 +217,7 @@ class _ChatScreenState extends State { }, ), centerTitle: false, + bottom: const SyncProgressAppBarBottom(), actions: [ Consumer( builder: (context, connector, _) { @@ -485,6 +487,8 @@ class _ChatScreenState extends State { final message = reversedMessages[messageIndex]; String fourByteHex = ''; 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( connector, message.fourByteRoomContactKey.isEmpty @@ -509,7 +513,6 @@ class _ChatScreenState extends State { ? "${contact.name} [$fourByteHex]" : contact.name, sourceId: widget.contact.publicKeyHex, - isRoomServer: resolvedContact.type == advTypeRoom, textScale: textScale, onTap: () => _openMessagePath(message, contact), onLongPress: () => _showMessageActions(message, contact), @@ -1587,6 +1590,15 @@ class _ChatScreenState extends State { } void _showMessageActions(Message message, Contact contact) { + final translationService = context.read(); + final canTranslateMessage = + translationService.canTranslateIncoming( + text: message.text, + isCli: message.isCli, + isOutgoing: message.isOutgoing, + ) && + (message.translatedText?.trim().isEmpty ?? true); + showModalBottomSheet( context: context, builder: (sheetContext) => SafeArea( @@ -1620,6 +1632,21 @@ class _ChatScreenState extends State { _copyMessageText(message.text); }, ), + if (canTranslateMessage) + ListTile( + leading: const Icon(Icons.translate), + title: Text(context.l10n.translation_translateMessage), + onTap: () { + Navigator.pop(sheetContext); + unawaited( + context.read().translateContactMessage( + widget.contact.publicKeyHex, + message, + manualTranslation: true, + ), + ); + }, + ), if (!message.isOutgoing) ListTile( leading: const Icon(Icons.mark_chat_unread_outlined), @@ -1731,7 +1758,6 @@ class _ChatScreenState extends State { class _MessageBubble extends StatelessWidget { final Message message; final String senderName; - final bool isRoomServer; final VoidCallback? onTap; final VoidCallback? onLongPress; final void Function(Message message, String emoji)? onRetryReaction; @@ -1742,7 +1768,6 @@ class _MessageBubble extends StatelessWidget { required this.message, required this.senderName, required this.sourceId, - required this.isRoomServer, required this.textScale, this.onTap, this.onLongPress, @@ -1768,10 +1793,9 @@ class _MessageBubble extends StatelessWidget { : (isOutgoing ? colorScheme.onPrimary : colorScheme.onSurface); final metaColor = textColor.withValues(alpha: 0.7); const bodyFontSize = 14.0; - String messageText = message.text; - if (isRoomServer && !isOutgoing) { - messageText = message.text.substring(4.clamp(0, message.text.length)); - } + // Do not strip room-server author bytes here: the parser stores them in + // fourByteRoomContactKey, so message.text is safe to render as-is. + final messageText = message.text; final translatedDisplayText = message.translatedText != null && message.translatedText!.trim().isNotEmpty diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index bedb24e2..2ebe3a23 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -27,6 +27,7 @@ import '../widgets/empty_state.dart'; import '../widgets/quick_switch_bar.dart'; import '../widgets/repeater_login_dialog.dart'; import '../widgets/room_login_dialog.dart'; +import '../widgets/sync_progress_overlay.dart'; import '../widgets/unread_badge.dart'; import '../helpers/snack_bar_builder.dart'; import 'channels_screen.dart'; @@ -318,6 +319,7 @@ class _ContactsScreenState extends State appBar: AppBar( title: AppBarTitle(context.l10n.contacts_title), automaticallyImplyLeading: false, + bottom: const SyncProgressAppBarBottom(), actions: [ PopupMenuButton( itemBuilder: (context) => [ @@ -430,6 +432,8 @@ class _ContactsScreenState extends State selectedIndex: 0, onDestinationSelected: (index) => _handleQuickSwitch(index, context), + contactsUnreadCount: connector.getTotalContactsUnreadCount(), + channelsUnreadCount: connector.getTotalChannelsUnreadCount(), ), ), ), @@ -604,15 +608,14 @@ class _ContactsScreenState extends State Widget _buildContactsBody(BuildContext context, MeshCoreConnector connector) { final viewState = context.watch(); final contacts = connector.contacts; - final shouldShowStartupSpinner = - contacts.isEmpty && - _groups.isEmpty && + final waitingForInitialContacts = connector.isConnected && - (connector.isLoadingContacts || - connector.isLoadingChannels || - connector.selfPublicKey == null); + !connector.hasLoadedContacts && + !connector.isLoadingContacts; + final waitingForFirstContact = + connector.isLoadingContacts && contacts.isEmpty; - if (shouldShowStartupSpinner) { + if (waitingForInitialContacts || waitingForFirstContact) { return const Center(child: CircularProgressIndicator()); } diff --git a/lib/screens/line_of_sight_map_screen.dart b/lib/screens/line_of_sight_map_screen.dart index 3ba79d59..f908f5ea 100644 --- a/lib/screens/line_of_sight_map_screen.dart +++ b/lib/screens/line_of_sight_map_screen.dart @@ -539,6 +539,12 @@ class _LineOfSightMapScreenState extends State { child: QuickSwitchBar( selectedIndex: 2, onDestinationSelected: (index) => _handleQuickSwitch(index, context), + contactsUnreadCount: context + .watch() + .getTotalContactsUnreadCount(), + channelsUnreadCount: context + .watch() + .getTotalChannelsUnreadCount(), ), ), ); diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index efd00718..92decf22 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -24,6 +24,7 @@ import '../services/map_tile_cache_service.dart'; import '../utils/contact_search.dart'; import '../utils/route_transitions.dart'; import '../widgets/quick_switch_bar.dart'; +import '../widgets/sync_progress_overlay.dart'; import '../icons/los_icon.dart'; import 'channels_screen.dart'; import 'chat_screen.dart'; @@ -417,6 +418,7 @@ class _MapScreenState extends State { title: AppBarTitle(context.l10n.map_title), centerTitle: true, automaticallyImplyLeading: false, + bottom: const SyncProgressAppBarBottom(), actions: [ if (!_isBuildingPathTrace) IconButton( @@ -679,6 +681,8 @@ class _MapScreenState extends State { selectedIndex: 2, onDestinationSelected: (index) => _handleQuickSwitch(index, context), + contactsUnreadCount: connector.getTotalContactsUnreadCount(), + channelsUnreadCount: connector.getTotalChannelsUnreadCount(), ), ), floatingActionButton: FloatingActionButton( diff --git a/lib/screens/scanner_screen.dart b/lib/screens/scanner_screen.dart index a503ec0e..ec148c5f 100644 --- a/lib/screens/scanner_screen.dart +++ b/lib/screens/scanner_screen.dart @@ -11,7 +11,7 @@ import '../utils/app_logger.dart'; import '../widgets/adaptive_app_bar_title.dart'; import '../widgets/device_tile.dart'; import '../helpers/snack_bar_builder.dart'; -import 'contacts_screen.dart'; +import 'channels_screen.dart'; import 'tcp_screen.dart'; import 'usb_screen.dart'; @@ -46,7 +46,7 @@ class _ScannerScreenState extends State { _changedNavigation = true; if (mounted) { Navigator.of(context).push( - MaterialPageRoute(builder: (context) => const ContactsScreen()), + MaterialPageRoute(builder: (context) => const ChannelsScreen()), ); } } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 49181fcc..eccf9bb8 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -16,6 +16,7 @@ import 'app_settings_screen.dart'; import 'app_debug_log_screen.dart'; import 'ble_debug_log_screen.dart'; import '../widgets/radio_stats_entry.dart'; +import '../widgets/sync_progress_overlay.dart'; /// Convert device coding-rate value (1-4 on some firmware, 5-8 on others) /// to the UI enum range (always 5-8). @@ -67,6 +68,7 @@ class _SettingsScreenState extends State { indicators: false, subtitle: false, ), + bottom: const SyncProgressAppBarBottom(), ), body: SafeArea( top: false, diff --git a/lib/screens/tcp_screen.dart b/lib/screens/tcp_screen.dart index 3bd1b0bf..a0d71922 100644 --- a/lib/screens/tcp_screen.dart +++ b/lib/screens/tcp_screen.dart @@ -9,7 +9,7 @@ import '../services/app_settings_service.dart'; import '../utils/platform_info.dart'; import '../widgets/adaptive_app_bar_title.dart'; import '../helpers/snack_bar_builder.dart'; -import 'contacts_screen.dart'; +import 'channels_screen.dart'; import 'usb_screen.dart'; class TcpScreen extends StatefulWidget { @@ -24,7 +24,7 @@ class _TcpScreenState extends State { late final TextEditingController _portController; late final MeshCoreConnector _connector; late final VoidCallback _connectionListener; - bool _navigatedToContacts = false; + bool _navigatedToChannels = false; @override void initState() { @@ -42,20 +42,20 @@ class _TcpScreenState extends State { _connectionListener = () { if (!mounted) return; if (_connector.state == MeshCoreConnectionState.disconnected) { - _navigatedToContacts = false; + _navigatedToChannels = false; } if (_connector.state == MeshCoreConnectionState.connected && _connector.isTcpTransportConnected && - !_navigatedToContacts) { + !_navigatedToChannels) { context.read().setTcpServerAddress( _hostController.text, ); context.read().setTcpServerPort( int.tryParse(_portController.text) ?? 0, ); - _navigatedToContacts = true; + _navigatedToChannels = true; Navigator.of(context).pushReplacement( - MaterialPageRoute(builder: (_) => const ContactsScreen()), + MaterialPageRoute(builder: (_) => const ChannelsScreen()), ); } }; @@ -67,7 +67,7 @@ class _TcpScreenState extends State { _hostController.dispose(); _portController.dispose(); _connector.removeListener(_connectionListener); - if (!_navigatedToContacts && + if (!_navigatedToChannels && _connector.activeTransport == MeshCoreTransportType.tcp && _connector.state != MeshCoreConnectionState.disconnected) { WidgetsBinding.instance.addPostFrameCallback((_) { diff --git a/lib/screens/telemetry_screen.dart b/lib/screens/telemetry_screen.dart index 47593a3f..873ca3b1 100644 --- a/lib/screens/telemetry_screen.dart +++ b/lib/screens/telemetry_screen.dart @@ -15,6 +15,7 @@ import '../widgets/path_management_dialog.dart'; import '../helpers/cayenne_lpp.dart'; import '../utils/battery_utils.dart'; import '../helpers/snack_bar_builder.dart'; +import '../widgets/sync_progress_overlay.dart'; class TelemetryScreen extends StatefulWidget { final Contact contact; @@ -239,6 +240,7 @@ class _TelemetryScreenState extends State { ], ), centerTitle: false, + bottom: const SyncProgressAppBarBottom(), actions: [ PopupMenuButton( icon: Icon(isFloodMode ? Icons.waves : Icons.route), diff --git a/lib/screens/usb_screen.dart b/lib/screens/usb_screen.dart index 6b8fe9d9..25992de8 100644 --- a/lib/screens/usb_screen.dart +++ b/lib/screens/usb_screen.dart @@ -11,7 +11,7 @@ import '../utils/platform_info.dart'; import '../utils/usb_port_labels.dart'; import '../widgets/adaptive_app_bar_title.dart'; import '../helpers/snack_bar_builder.dart'; -import 'contacts_screen.dart'; +import 'channels_screen.dart'; import 'scanner_screen.dart'; import 'tcp_screen.dart'; @@ -25,7 +25,7 @@ class UsbScreen extends StatefulWidget { class _UsbScreenState extends State { final List _ports = []; bool _isLoadingPorts = true; - bool _navigatedToContacts = false; + bool _navigatedToChannels = false; bool _didScheduleInitialLoad = false; Timer? _hotPlugTimer; late final MeshCoreConnector _connector; @@ -41,14 +41,14 @@ class _UsbScreenState extends State { _connectionListener = () { if (!mounted) return; if (_connector.state == MeshCoreConnectionState.disconnected) { - _navigatedToContacts = false; + _navigatedToChannels = false; } if (_connector.state == MeshCoreConnectionState.connected && _connector.isUsbTransportConnected && - !_navigatedToContacts) { - _navigatedToContacts = true; + !_navigatedToChannels) { + _navigatedToChannels = true; Navigator.of(context).pushReplacement( - MaterialPageRoute(builder: (_) => const ContactsScreen()), + MaterialPageRoute(builder: (_) => const ChannelsScreen()), ); } }; @@ -72,7 +72,7 @@ class _UsbScreenState extends State { _hotPlugTimer?.cancel(); _hotPlugTimer = null; _connector.removeListener(_connectionListener); - if (!_navigatedToContacts && + if (!_navigatedToChannels && _connector.activeTransport == MeshCoreTransportType.usb && _connector.state != MeshCoreConnectionState.disconnected) { WidgetsBinding.instance.addPostFrameCallback((_) { diff --git a/lib/services/app_settings_service.dart b/lib/services/app_settings_service.dart index 7b3d5848..88ff0f47 100644 --- a/lib/services/app_settings_service.dart +++ b/lib/services/app_settings_service.dart @@ -235,6 +235,12 @@ class AppSettingsService extends ChangeNotifier { await updateSettings(_settings.copyWith(translationEnabled: value)); } + Future setAutoTranslateIncomingMessages(bool value) async { + await updateSettings( + _settings.copyWith(autoTranslateIncomingMessages: value), + ); + } + Future setTranslationTargetLanguageCode(String? value) async { await updateSettings( _settings.copyWith(translationTargetLanguageCode: value), diff --git a/lib/services/translation_service.dart b/lib/services/translation_service.dart index 294a7848..7b1d7f5f 100644 --- a/lib/services/translation_service.dart +++ b/lib/services/translation_service.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:llamadart/llamadart.dart'; +import 'package:flutter_langdetect/flutter_langdetect.dart'; import '../models/app_settings.dart'; import '../models/translation_support.dart'; @@ -41,7 +42,10 @@ class TranslationService extends ChangeNotifier { TranslationService( this._appSettingsService, { TranslationFileStore? fileStore, - }) : _fileStore = fileStore ?? TranslationFileStore(); + }) : _fileStore = fileStore ?? TranslationFileStore() { + // Initialize langdetect once at service construction. + _langDetectInit = initLangDetect(); + } bool _isBusy = false; bool _isDownloading = false; @@ -51,6 +55,7 @@ class TranslationService extends ChangeNotifier { LlamaEngine? _engine; String? _loadedModelPath; String? _failedModelPath; + Future? _langDetectInit; int _downloadedBytes = 0; int? _downloadTotalBytes; String? _downloadFileName; @@ -84,7 +89,22 @@ class TranslationService extends ChangeNotifier { '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 bool isCli, required bool isOutgoing, @@ -368,7 +388,9 @@ class TranslationService extends ChangeNotifier { if (targetLanguageCode == null || !_isPlainTextEligible(text)) { return null; } - final detectedLanguageCode = await detectLanguage(text); + final detectedLanguageCode = await detectLanguage( + _stripReplyInfoForDetection(text), + ); if (detectedLanguageCode != null && detectedLanguageCode == targetLanguageCode) { return const TranslationResult( @@ -409,7 +431,9 @@ class TranslationService extends ChangeNotifier { if (targetLanguageCode == null || !_isPlainTextEligible(text)) { return null; } - final detectedLanguageCode = await detectLanguage(text); + final detectedLanguageCode = await detectLanguage( + _stripReplyInfoForDetection(text), + ); if (detectedLanguageCode != null && detectedLanguageCode == targetLanguageCode) { return const TranslationResult( @@ -436,7 +460,26 @@ class TranslationService extends ChangeNotifier { } Future 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 _translateText({ @@ -518,72 +561,6 @@ class TranslationService extends ChangeNotifier { 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 = { - '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 = {}; - 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) { for (final option in supportedTranslationLanguages) { if (option.code == code) { diff --git a/lib/widgets/quick_switch_bar.dart b/lib/widgets/quick_switch_bar.dart index 134091ff..40dcb59a 100644 --- a/lib/widgets/quick_switch_bar.dart +++ b/lib/widgets/quick_switch_bar.dart @@ -6,11 +6,15 @@ import '../l10n/l10n.dart'; class QuickSwitchBar extends StatelessWidget { final int selectedIndex; final ValueChanged onDestinationSelected; + final int contactsUnreadCount; + final int channelsUnreadCount; const QuickSwitchBar({ super.key, required this.selectedIndex, required this.onDestinationSelected, + this.contactsUnreadCount = 0, + this.channelsUnreadCount = 0, }); @override @@ -62,15 +66,30 @@ class QuickSwitchBar extends StatelessWidget { onDestinationSelected: onDestinationSelected, destinations: [ 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, ), 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, ), NavigationDestination( icon: const Icon(Icons.map_outlined), + selectedIcon: const Icon(Icons.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, + ), + ), + ), + ], + ); + } } diff --git a/lib/widgets/sync_progress_overlay.dart b/lib/widgets/sync_progress_overlay.dart new file mode 100644 index 00000000..4aef90ea --- /dev/null +++ b/lib/widgets/sync_progress_overlay.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../connector/meshcore_connector.dart'; + +class SyncProgressAppBarBottom extends StatelessWidget + implements PreferredSizeWidget { + static const double height = 3; + + const SyncProgressAppBarBottom({super.key}); + + @override + Size get preferredSize => const Size.fromHeight(height); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, connector, _) { + final state = _SyncProgressState.fromConnector(connector); + if (state == null) return const SizedBox(height: height); + + return SizedBox( + height: height, + child: LinearProgressIndicator( + value: state.value, + minHeight: height, + color: state.color, + backgroundColor: state.color.withValues(alpha: 0.18), + ), + ); + }, + ); + } +} + +class _SyncProgressState { + final double? value; + final Color color; + + const _SyncProgressState({required this.value, required this.color}); + + static _SyncProgressState? fromConnector(MeshCoreConnector connector) { + if (connector.isLoadingContacts) { + return _SyncProgressState( + value: connector.contactSyncProgress, + color: Colors.red, + ); + } + if (connector.isSyncingChannels) { + return _SyncProgressState( + value: connector.channelSyncProgress / 100, + color: Colors.blue, + ); + } + if (connector.isShowingQueuedMessageSyncProgress) { + return const _SyncProgressState(value: null, color: Colors.green); + } + return null; + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 7b43dded..72cca35f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -72,6 +72,7 @@ dependencies: ml_algo: ^16.0.0 ml_dataframe: ^1.0.0 llamadart: '>=0.6.8 <0.7.0' + flutter_langdetect: ^0.0.1 hooks: user_defines: