diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 7cbc8ff0..7912962b 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -199,6 +199,7 @@ class MeshCoreConnector extends ChangeNotifier { double? _selfLongitude; final List _directRepeaters = List.empty(growable: true); bool _isLoadingContacts = false; + bool _hasLoadedContacts = false; bool _isLoadingChannels = false; bool _hasLoadedChannels = false; TimeoutPredictionService? _timeoutPredictionService; @@ -220,10 +221,13 @@ class MeshCoreConnector extends ChangeNotifier { bool _batteryRequested = false; bool _awaitingSelfInfo = false; bool _hasReceivedDeviceInfo = false; + // Initial sync is serialized for predictable progress. Firmware exposes one + // FIFO queued-message stream, so direct/room frames are buffered until after + // contacts are known. bool _pendingInitialChannelSync = false; bool _pendingInitialContactsSync = false; + bool _pendingInitialQueuedMessageSync = false; bool _bleInitialSyncStarted = false; - bool _pendingDeferredChannelSyncAfterContacts = false; bool _webInitialHandshakeRequestSent = false; bool _preserveContactsOnRefresh = false; bool _autoAddUsers = false; @@ -238,13 +242,18 @@ class MeshCoreConnector extends ChangeNotifier { int _advertLocPolicy = 0; int _multiAcks = 0; - static const int _defaultMaxContacts = 32; - static const int _defaultMaxChannels = 8; + static const int _defaultMaxContacts = 350; + static const int _defaultMaxChannels = 40; int _maxContacts = _defaultMaxContacts; int _maxChannels = _defaultMaxChannels; + int? _contactSyncTotal; + int _contactSyncReceived = 0; + bool _contactSyncUsesSinceFilter = false; bool _isSyncingQueuedMessages = false; + bool _deferQueuedContactMessagesUntilContacts = false; + bool _isProcessingDeferredQueuedContactMessages = false; bool _queuedMessageSyncInFlight = false; - bool _didInitialQueueSync = false; + final List _deferredQueuedContactMessageFrames = []; bool _pendingQueueSync = false; Timer? _queueSyncTimeout; int _queueSyncRetries = 0; @@ -373,7 +382,9 @@ class MeshCoreConnector extends ChangeNotifier { List get channels => List.unmodifiable(_channels); bool get isConnected => _state == MeshCoreConnectionState.connected; bool get isLoadingContacts => _isLoadingContacts; + bool get hasLoadedContacts => _hasLoadedContacts; bool get isLoadingChannels => _isLoadingChannels; + bool get hasLoadedChannels => _hasLoadedChannels; Stream get receivedFrames => _receivedFramesController.stream; Uint8List? get selfPublicKey => _selfPublicKey; String get selfPublicKeyHex => pubKeyToHex(_selfPublicKey ?? Uint8List(0)); @@ -436,7 +447,16 @@ class MeshCoreConnector extends ChangeNotifier { int get maxContacts => _maxContacts; int get maxChannels => _maxChannels; Set get knownContactKeys => Set.unmodifiable(_knownContactKeys); - bool get isSyncingQueuedMessages => _isSyncingQueuedMessages; + double? get contactSyncProgress { + final total = _contactSyncTotal; + if (!_isLoadingContacts || total == null || total <= 0) return null; + return (_contactSyncReceived / total).clamp(0.0, 1.0).toDouble(); + } + + bool get isSyncingQueuedMessages => + _isSyncingQueuedMessages || _isProcessingDeferredQueuedContactMessages; + bool get isShowingQueuedMessageSyncProgress => + _deferQueuedContactMessagesUntilContacts && isSyncingQueuedMessages; bool get isSyncingChannels => _isSyncingChannels; int get channelSyncProgress => _isSyncingChannels && _totalChannelsToRequest > 0 @@ -1106,19 +1126,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, @@ -1128,7 +1160,7 @@ class MeshCoreConnector extends ChangeNotifier { targetLanguageCode: targetLanguageCode, ); if (result == null) { - return; + return null; } final translated = result.status == MessageTranslationStatus.completed ? result.translatedText @@ -1143,24 +1175,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, @@ -1170,11 +1216,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, @@ -1185,8 +1236,10 @@ class MeshCoreConnector extends ChangeNotifier { translationModelId: result.modelId, ), ); + return result; } catch (error) { appLogger.warn('Translation failed for channel message: $error'); + return null; } } @@ -1515,6 +1568,8 @@ class MeshCoreConnector extends ChangeNotifier { _setState(MeshCoreConnectionState.connected); _pendingInitialChannelSync = true; + _pendingInitialQueuedMessageSync = true; + _pendingInitialContactsSync = true; _appDebugLogService?.info( 'connectUsb: requesting device info…', tag: 'USB', @@ -1625,6 +1680,8 @@ class MeshCoreConnector extends ChangeNotifier { _setState(MeshCoreConnectionState.connected); _pendingInitialChannelSync = true; + _pendingInitialQueuedMessageSync = true; + _pendingInitialContactsSync = true; await _requestDeviceInfo(); _startBatteryPolling(); if (_radioStatsPollRefCount > 0) _startRadioStatsPolling(); @@ -2259,7 +2316,9 @@ class MeshCoreConnector extends ChangeNotifier { return; } _bleInitialSyncStarted = true; + _pendingInitialChannelSync = true; _pendingInitialContactsSync = true; + _pendingInitialQueuedMessageSync = true; await _requestDeviceInfo(); _startBatteryPolling(); @@ -2274,7 +2333,7 @@ class MeshCoreConnector extends ChangeNotifier { } await syncTime(); - unawaited(getChannels()); + _maybeStartInitialChannelSync(); } void _resetConnectionHandshakeState() { @@ -2287,11 +2346,39 @@ class MeshCoreConnector extends ChangeNotifier { _selfInfoRetryTimer?.cancel(); _selfInfoRetryTimer = null; _hasReceivedDeviceInfo = false; + _resetSyncProgressState(); + _bleInitialSyncStarted = false; + _pathHashByteWidth = 1; + } + + void _resetSyncProgressState() { _pendingInitialChannelSync = false; _pendingInitialContactsSync = false; - _bleInitialSyncStarted = false; - _pendingDeferredChannelSyncAfterContacts = false; - _pathHashByteWidth = 1; + _pendingInitialQueuedMessageSync = false; + _contactSyncTotal = null; + _contactSyncReceived = 0; + _contactSyncUsesSinceFilter = false; + _isLoadingContacts = false; + _hasLoadedContacts = false; + _isLoadingChannels = false; + _hasLoadedChannels = false; + _isSyncingQueuedMessages = false; + _deferQueuedContactMessagesUntilContacts = false; + _isProcessingDeferredQueuedContactMessages = false; + _queuedMessageSyncInFlight = false; + _deferredQueuedContactMessageFrames.clear(); + _pendingQueueSync = false; + _queueSyncTimeout?.cancel(); + _queueSyncTimeout = null; + _queueSyncRetries = 0; + _isSyncingChannels = false; + _channelSyncInFlight = false; + _channelSyncTimeout?.cancel(); + _channelSyncTimeout = null; + _channelSyncRetries = 0; + _nextChannelIndexToRequest = 0; + _totalChannelsToRequest = 0; + _previousChannelsCache.clear(); } bool get _shouldAutoReconnect => @@ -2428,17 +2515,9 @@ class MeshCoreConnector extends ChangeNotifier { _batteryRequested = false; _awaitingSelfInfo = false; _hasReceivedDeviceInfo = false; - _pendingInitialChannelSync = false; - _pendingInitialContactsSync = false; _maxContacts = _defaultMaxContacts; _maxChannels = _defaultMaxChannels; - _isSyncingQueuedMessages = false; - _queuedMessageSyncInFlight = false; - _didInitialQueueSync = false; - _pendingQueueSync = false; - _isSyncingChannels = false; - _channelSyncInFlight = false; - _hasLoadedChannels = false; + _resetSyncProgressState(); _pendingChannelSentQueue.clear(); _pendingGenericAckQueue.clear(); _reactionSendQueueSequence = 0; @@ -2691,10 +2770,14 @@ class MeshCoreConnector extends ChangeNotifier { _isLoadingContacts = true; _preserveContactsOnRefresh = preserveExisting; + _contactSyncTotal = null; + _contactSyncReceived = 0; + _contactSyncUsesSinceFilter = since != null; if (!preserveExisting) { + _hasLoadedContacts = false; _contacts.clear(); - notifyListeners(); } + notifyListeners(); await sendFrame(buildGetContactsFrame(since: since)); } @@ -3292,11 +3375,20 @@ class MeshCoreConnector extends ChangeNotifier { Future syncQueuedMessages({bool force = false}) async { if (!isConnected) return; if (!force && _isSyncingQueuedMessages) return; + if (_isProcessingDeferredQueuedContactMessages) { + _pendingQueueSync = true; + return; + } if (_awaitingSelfInfo || _isLoadingContacts) { _pendingQueueSync = true; return; } + if (_isSyncingChannels || _channelSyncInFlight) { + _pendingQueueSync = true; + return; + } _isSyncingQueuedMessages = true; + notifyListeners(); await _requestNextQueuedMessage(); } @@ -3330,6 +3422,8 @@ class MeshCoreConnector extends ChangeNotifier { _isSyncingQueuedMessages = false; _queueSyncTimeout?.cancel(); _queueSyncRetries = 0; + notifyListeners(); + _continueAfterQueuedMessageSync(); } } @@ -3349,6 +3443,8 @@ class MeshCoreConnector extends ChangeNotifier { _queuedMessageSyncInFlight = false; _isSyncingQueuedMessages = false; _queueSyncRetries = 0; + notifyListeners(); + _continueAfterQueuedMessageSync(); } } @@ -3448,6 +3544,7 @@ class MeshCoreConnector extends ChangeNotifier { _isLoadingChannels = true; _isSyncingChannels = true; + _hasLoadedChannels = false; _previousChannelsCache = List.from(_channels); _channels.clear(); _nextChannelIndexToRequest = 0; @@ -3537,6 +3634,7 @@ class MeshCoreConnector extends ChangeNotifier { _nextChannelIndexToRequest++; _channelSyncRetries = 0; _channelSyncInFlight = false; + notifyListeners(); unawaited(_requestNextChannel()); } } @@ -3572,16 +3670,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 { @@ -3633,6 +3746,20 @@ class MeshCoreConnector extends ChangeNotifier { _contacts.clear(); } _isLoadingContacts = true; + _contactSyncReceived = 0; + // Firmware v3+ includes total contacts after CONTACTS_START. + // Incremental sync reports total contacts, not filtered result count. + if (frame.length >= 5 && !_contactSyncUsesSinceFilter) { + final reader = BufferReader(frame); + reader.skipBytes(1); + _contactSyncTotal = reader.readUInt32LE(); + } else if (!_contactSyncUsesSinceFilter) { + // Older firmwares may omit the count; use the nRF node capacity as + // a conservative progress fallback instead of hiding the progress. + _contactSyncTotal = _defaultMaxContacts; + } else { + _contactSyncTotal = null; + } notifyListeners(); break; case pushCodeAdvert: @@ -3650,7 +3777,9 @@ class MeshCoreConnector extends ChangeNotifier { case respCodeEndOfContacts: debugPrint('Got END_OF_CONTACTS'); _isLoadingContacts = false; + _hasLoadedContacts = true; _preserveContactsOnRefresh = false; + _contactSyncUsesSinceFilter = false; unawaited(updateKnownDiscovered()); notifyListeners(); unawaited(_persistContacts()); @@ -3660,23 +3789,21 @@ class MeshCoreConnector extends ChangeNotifier { !_channelSyncInFlight) { unawaited(_requestNextChannel()); } - if (!_didInitialQueueSync || _pendingQueueSync) { - _didInitialQueueSync = true; + if (_deferQueuedContactMessagesUntilContacts) { + unawaited(_processDeferredQueuedContactMessages()); + } else if (_pendingQueueSync) { _pendingQueueSync = false; unawaited(syncQueuedMessages(force: true)); } - if (_pendingDeferredChannelSyncAfterContacts && - (_activeTransport == MeshCoreTransportType.bluetooth || - _activeTransport == MeshCoreTransportType.usb || - _activeTransport == MeshCoreTransportType.tcp)) { - _pendingDeferredChannelSyncAfterContacts = false; - _pendingInitialChannelSync = false; - unawaited(getChannels()); - } break; case respCodeContactMsgRecv: case respCodeContactMsgRecvV3: - _handleIncomingMessage(frame); + if (_shouldDeferQueuedContactMessage(frame)) { + _deferredQueuedContactMessageFrames.add(Uint8List.fromList(frame)); + _handleQueuedMessageReceived(); + } else { + unawaited(_handleIncomingMessage(frame)); + } break; case respCodeChannelMsgRecv: case respCodeChannelMsgRecvV3: @@ -3860,23 +3987,8 @@ class MeshCoreConnector extends ChangeNotifier { _selfInfoRetryTimer = null; notifyListeners(); - // Auto-fetch contacts after getting self info. On web BLE, defer this - // until after channel 0 so startup writes stay serialized. - if (PlatformInfo.isWeb && - _activeTransport == MeshCoreTransportType.bluetooth) { - _pendingInitialContactsSync = true; - } else if (_activeTransport == MeshCoreTransportType.usb || - _activeTransport == MeshCoreTransportType.tcp) { - _pendingDeferredChannelSyncAfterContacts = true; - getContacts(); - } else { - getContacts(); - } - if (_shouldGateInitialChannelSync && - _activeTransport != MeshCoreTransportType.usb && - _activeTransport != MeshCoreTransportType.tcp) { - _maybeStartInitialChannelSync(); - } + // Start the serialized initial sync pipeline after SELF_INFO. + _maybeStartInitialChannelSync(); } void _handleDeviceInfo(Uint8List frame) { @@ -3916,7 +4028,7 @@ class MeshCoreConnector extends ChangeNotifier { unawaited(loadAllChannelMessages(maxChannels: nextMaxChannels)); if (isConnected && _selfPublicKey != null && - (!_shouldGateInitialChannelSync || !_pendingInitialChannelSync)) { + !_pendingInitialChannelSync) { unawaited(getChannels(maxChannels: nextMaxChannels)); } } @@ -3931,12 +4043,13 @@ class MeshCoreConnector extends ChangeNotifier { if (!_pendingInitialChannelSync || !isConnected) { return; } - if (_selfPublicKey == null || !_hasReceivedDeviceInfo) { + if (_selfPublicKey == null || + (_shouldGateInitialChannelSync && !_hasReceivedDeviceInfo)) { return; } _pendingInitialChannelSync = false; - unawaited(getChannels(maxChannels: _maxChannels)); + unawaited(getChannels(maxChannels: _maxChannels, force: true)); } void _handleNoMoreMessages() { @@ -3945,6 +4058,64 @@ class MeshCoreConnector extends ChangeNotifier { _isSyncingQueuedMessages = false; _queuedMessageSyncInFlight = false; _queueSyncRetries = 0; // Reset retry counter on successful completion + notifyListeners(); + _continueAfterQueuedMessageSync(); + } + + bool _shouldDeferQueuedContactMessage(Uint8List frame) { + if (!_deferQueuedContactMessagesUntilContacts || + !_isSyncingQueuedMessages) { + return false; + } + if (frame.isEmpty) return false; + return frame[0] == respCodeContactMsgRecv || + frame[0] == respCodeContactMsgRecvV3; + } + + void _continueAfterQueuedMessageSync() { + if (!_deferQueuedContactMessagesUntilContacts) return; + if (_pendingInitialContactsSync && isConnected) { + _pendingInitialContactsSync = false; + unawaited(getContacts()); + return; + } + unawaited(_processDeferredQueuedContactMessages()); + } + + Future _processDeferredQueuedContactMessages() async { + if (!_deferQueuedContactMessagesUntilContacts || + _isProcessingDeferredQueuedContactMessages) { + return; + } + if (_deferredQueuedContactMessageFrames.isEmpty) { + _deferQueuedContactMessagesUntilContacts = false; + notifyListeners(); + if (_pendingQueueSync && isConnected) { + _pendingQueueSync = false; + unawaited(syncQueuedMessages(force: true)); + } + return; + } + + _isProcessingDeferredQueuedContactMessages = true; + notifyListeners(); + try { + // Replay direct/room queued messages only after contacts are loaded, so + // sender prefixes can be resolved against the current contact list. + while (_deferredQueuedContactMessageFrames.isNotEmpty) { + final frame = _deferredQueuedContactMessageFrames.removeAt(0); + await _handleIncomingMessage(frame); + } + } finally { + _deferQueuedContactMessagesUntilContacts = false; + _isProcessingDeferredQueuedContactMessages = false; + notifyListeners(); + } + + if (_pendingQueueSync && isConnected) { + _pendingQueueSync = false; + unawaited(syncQueuedMessages(force: true)); + } } void _handleQueuedMessageReceived() { @@ -3953,6 +4124,7 @@ class MeshCoreConnector extends ChangeNotifier { _queueSyncTimeout?.cancel(); // Cancel timeout - message arrived _queuedMessageSyncInFlight = false; _queueSyncRetries = 0; // Reset retry counter on successful message + notifyListeners(); unawaited(_requestNextQueuedMessage()); } @@ -4094,11 +4266,15 @@ class MeshCoreConnector extends ChangeNotifier { void _handleContact(Uint8List frame, {bool isContact = true}) { final contactTmp = Contact.fromFrame(frame); if (contactTmp != null) { + if (isContact && _isLoadingContacts) { + _contactSyncReceived++; + } if (listEquals(contactTmp.publicKey, _selfPublicKey)) { appLogger.info( 'Ignoring contact with self public key: ${contactTmp.name}', tag: 'Connector', ); + notifyListeners(); removeContact(contactTmp); return; } @@ -4162,6 +4338,7 @@ class MeshCoreConnector extends ChangeNotifier { "Discovered contact ${contact.name} (type ${contact.typeLabelRaw}) not added due to auto-add settings", tag: 'Connector', ); + notifyListeners(); return; } } @@ -4370,9 +4547,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 @@ -4438,35 +4621,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, - 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(); @@ -4740,6 +4940,7 @@ class MeshCoreConnector extends ChangeNotifier { void _maybeNotifyChannelMessage( ChannelMessage message, { String? channelName, + TranslationResult? translationResult, }) { if (message.isOutgoing || _appSettingsService == null) return; final channelIndex = message.channelIndex; @@ -4753,16 +4954,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)) { @@ -4781,15 +4996,17 @@ class MeshCoreConnector extends ChangeNotifier { pathBytes: message.pathBytes, ); 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) { @@ -4797,7 +5014,7 @@ class MeshCoreConnector extends ChangeNotifier { } } - void _handleLogRxData(Uint8List frame) { + void _handleLogRxData(Uint8List frame) async { if (frame.length < 4) return; try { final reader = BufferReader(frame); @@ -4865,16 +5082,24 @@ class MeshCoreConnector extends ChangeNotifier { pathBytes: message.pathBytes, ); 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) { @@ -5092,14 +5317,7 @@ class MeshCoreConnector extends ChangeNotifier { // Move to next channel _nextChannelIndexToRequest++; - if (PlatformInfo.isWeb && - _activeTransport == MeshCoreTransportType.bluetooth && - channel.index == 0 && - _pendingInitialContactsSync) { - _pendingInitialContactsSync = false; - unawaited(getContacts()); - return; - } + notifyListeners(); unawaited(_requestNextChannel()); return; } else { @@ -5799,14 +6017,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 9eac9105..496b3e3d 100644 --- a/lib/l10n/app_bg.arb +++ b/lib/l10n/app_bg.arb @@ -2138,6 +2138,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 e8aa0a78..f7cb8040 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -2166,6 +2166,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 1567db5b..1f05124a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2404,6 +2404,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 088752e7..f24fb52a 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2167,6 +2167,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 1c56e59a..710ee53a 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2138,6 +2138,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 4c7dd35a..1807fa68 100644 --- a/lib/l10n/app_hu.arb +++ b/lib/l10n/app_hu.arb @@ -2176,6 +2176,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 43c1b70a..d378d19d 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -2139,6 +2139,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 d08861cb..b525ce41 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -2176,6 +2176,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 01438cd5..8f6781e3 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -2176,6 +2176,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 300a4d1e..b45facfe 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -7348,6 +7348,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 079fc21f..af9e693d 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -4300,6 +4300,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 b23a9c29..3fa9c4a8 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -4315,6 +4315,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 4e4a8607..4fbfd5bb 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -4224,6 +4224,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 fb4a7148..383dd032 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -4302,6 +4302,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 ac7edb98..990c49c6 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -4332,6 +4332,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 24719c4e..58f263b3 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -4320,6 +4320,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 c97a6515..a0149bd0 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -4308,6 +4308,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 0b3683eb..12396086 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -4080,6 +4080,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 0a667380..606a33dc 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -4081,6 +4081,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 da915d17..90a446df 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -4285,6 +4285,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 daf68008..f30cbb7f 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -4323,6 +4323,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 011a90d1..5fe15028 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -4298,6 +4298,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 dae3a784..84929424 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -4316,6 +4316,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 937a965f..2072b634 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -4280,6 +4280,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 ebd19ede..e91086e9 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -4278,6 +4278,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 1a7ea146..95af0f64 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -4252,6 +4252,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 91ebe303..ae82d64e 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -4315,6 +4315,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 2fabf8b4..79e4e22e 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -3955,6 +3955,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 9f6426df..dfcf03b1 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -2140,6 +2140,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 cc5e056f..2dcdc7d8 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -2176,6 +2176,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 f6c569b1..9dff4417 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -2139,6 +2139,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 e2f69fc7..0ae3eda4 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1307,6 +1307,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 60ea5da8..3ade2e0d 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -2139,6 +2139,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 3827edde..fa3e2676 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -2138,6 +2138,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 a9a4af4c..1658f186 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -2139,6 +2139,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 66e558ea..990ecb90 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -2148,6 +2148,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 bf928064..170e7212 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -2144,6 +2144,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/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 be72eaa8..b5e151bd 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; @@ -34,6 +35,7 @@ import '../widgets/gif_picker.dart'; import '../widgets/message_translation_button.dart'; import '../widgets/message_status_icon.dart'; import '../widgets/radio_stats_entry.dart'; +import '../widgets/sync_progress_overlay.dart'; import '../widgets/translated_message_content.dart'; import '../widgets/unread_divider.dart'; import 'channel_message_path_screen.dart'; @@ -302,6 +304,7 @@ class _ChannelChatScreenState extends State { ], ), centerTitle: false, + bottom: const SyncProgressAppBarBottom(), actions: [ const RadioStatsIconButton(), PopupMenuButton( @@ -1386,6 +1389,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( @@ -1427,6 +1439,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 d305a6f3..40726021 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -23,6 +23,7 @@ import '../widgets/list_filter_widget.dart'; import '../widgets/empty_state.dart'; import '../widgets/qr_code_display.dart'; import '../widgets/quick_switch_bar.dart'; +import '../widgets/sync_progress_overlay.dart'; import '../widgets/unread_badge.dart'; import '../helpers/snack_bar_builder.dart'; import 'channel_chat_screen.dart'; @@ -103,6 +104,7 @@ class _ChannelsScreenState extends State title: AppBarTitle(context.l10n.channels_title), centerTitle: true, automaticallyImplyLeading: false, + bottom: const SyncProgressAppBarBottom(), actions: [ PopupMenuButton( itemBuilder: (context) => [ @@ -152,12 +154,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: [ diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 1548b44e..5e9f960e 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, _) { @@ -1578,6 +1580,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( @@ -1611,6 +1622,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), diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index d5e1b79a..bdadc2b6 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -27,6 +27,7 @@ import '../widgets/empty_state.dart'; import '../widgets/quick_switch_bar.dart'; import '../widgets/repeater_login_dialog.dart'; import '../widgets/room_login_dialog.dart'; +import '../widgets/sync_progress_overlay.dart'; import '../widgets/unread_badge.dart'; import '../helpers/snack_bar_builder.dart'; import 'channels_screen.dart'; @@ -318,6 +319,7 @@ class _ContactsScreenState extends State appBar: AppBar( title: AppBarTitle(context.l10n.contacts_title), automaticallyImplyLeading: false, + bottom: const SyncProgressAppBarBottom(), actions: [ PopupMenuButton( itemBuilder: (context) => [ @@ -606,15 +608,14 @@ class _ContactsScreenState extends State Widget _buildContactsBody(BuildContext context, MeshCoreConnector connector) { final viewState = context.watch(); final contacts = connector.contacts; - final shouldShowStartupSpinner = - contacts.isEmpty && - _groups.isEmpty && + final waitingForInitialContacts = connector.isConnected && - (connector.isLoadingContacts || - connector.isLoadingChannels || - connector.selfPublicKey == null); + !connector.hasLoadedContacts && + !connector.isLoadingContacts; + final waitingForFirstContact = + connector.isLoadingContacts && contacts.isEmpty; - if (shouldShowStartupSpinner) { + if (waitingForInitialContacts || waitingForFirstContact) { return const Center(child: CircularProgressIndicator()); } diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index be133240..766b1852 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -23,6 +23,7 @@ import '../services/map_tile_cache_service.dart'; import '../utils/contact_search.dart'; import '../utils/route_transitions.dart'; import '../widgets/quick_switch_bar.dart'; +import '../widgets/sync_progress_overlay.dart'; import '../icons/los_icon.dart'; import 'channels_screen.dart'; import 'chat_screen.dart'; @@ -414,6 +415,7 @@ class _MapScreenState extends State { title: AppBarTitle(context.l10n.map_title), centerTitle: true, automaticallyImplyLeading: false, + bottom: const SyncProgressAppBarBottom(), actions: [ if (!_isBuildingPathTrace) IconButton( diff --git a/lib/screens/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 5679f757..ead36eaf 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -16,6 +16,7 @@ import 'app_settings_screen.dart'; import 'app_debug_log_screen.dart'; import 'ble_debug_log_screen.dart'; import '../widgets/radio_stats_entry.dart'; +import '../widgets/sync_progress_overlay.dart'; /// Convert device coding-rate value (1-4 on some firmware, 5-8 on others) /// to the UI enum range (always 5-8). @@ -67,6 +68,7 @@ class _SettingsScreenState extends State { indicators: false, subtitle: false, ), + bottom: const SyncProgressAppBarBottom(), ), body: SafeArea( top: false, diff --git a/lib/screens/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 aafa8c51..f277a8e1 100644 --- a/lib/screens/telemetry_screen.dart +++ b/lib/screens/telemetry_screen.dart @@ -17,6 +17,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'; import '../widgets/telemetry_location_map.dart'; class TelemetryScreen extends StatefulWidget { @@ -344,6 +345,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/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: