From d2a6fbe1821e0e80837799a62386c2fbd1b1c6ac Mon Sep 17 00:00:00 2001 From: ericz Date: Sun, 10 May 2026 11:09:25 +0200 Subject: [PATCH 01/10] translate notifications. --- lib/connector/meshcore_connector.dart | 99 +++++++++++++++++++++------ lib/services/translation_service.dart | 2 +- 2 files changed, 79 insertions(+), 22 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 538ee38a..ba18d3b1 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -4408,22 +4408,38 @@ class MeshCoreConnector extends ChangeNotifier { _appSettingsService != null) { final settings = _appSettingsService!.settings; if (settings.notificationsEnabled && settings.notifyOnNewMessage) { + // Capture non-null local for async closure type promotion + final msg = message; if (contact?.type == advTypeChat) { - _notificationService.showMessageNotification( - contactName: contact?.name ?? 'Unknown', - message: message.text, - contactId: message.senderKeyHex, - badgeCount: getTotalUnreadCount(), - ); + unawaited(() async { + final resolvedText = await _maybeTranslateForNotification( + msg.text, + isCli: msg.isCli, + ); + await _notificationService.showMessageNotification( + contactName: contact?.name ?? 'Unknown', + message: resolvedText, + contactId: msg.senderKeyHex, + badgeCount: getTotalUnreadCount(), + ); + }()); } 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(), - ); + // Room server messages include a 4-char prefix; strip it for notifications + final bodyText = msg.text.length > 4 + ? msg.text.substring(4) + : msg.text; + unawaited(() async { + final resolvedText = await _maybeTranslateForNotification( + bodyText, + isCli: msg.isCli, + ); + await _notificationService.showMessageNotification( + contactName: contact?.name ?? 'Unknown Room', + message: resolvedText, + contactId: msg.senderKeyHex, + badgeCount: getTotalUnreadCount(), + ); + }()); } } } @@ -4705,13 +4721,54 @@ 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(), - ); + // Translate channel notification text if enabled + unawaited(() async { + final resolvedText = await _maybeTranslateForNotification( + message.text, + isCli: false, + ); + await _notificationService.showChannelMessageNotification( + channelName: label, + senderName: message.senderName, + message: resolvedText, + channelIndex: message.channelIndex, + badgeCount: getTotalUnreadCount(), + ); + }()); + } + + Future _maybeTranslateForNotification( + String text, { + required bool isCli, + }) async { + final service = _translationService; + if (service == null) return text; + try { + if (!service.shouldTranslateIncoming( + text: text, + isCli: isCli, + isOutgoing: false, + )) { + return text; + } + final targetLanguageCode = service.resolvedIncomingLanguageCode( + _appSettingsService?.settings.languageOverride, + ); + if (targetLanguageCode == null || targetLanguageCode.isEmpty) { + return text; + } + final result = await service.translateIncomingText( + text: text, + targetLanguageCode: targetLanguageCode, + ); + final translated = result?.translatedText.trim(); + if (translated != null && translated.isNotEmpty) { + return translated; + } + } catch (_) { + // Fallback to original text on any error + } + return text; } void _handleIncomingChannelMessage(Uint8List frame) { diff --git a/lib/services/translation_service.dart b/lib/services/translation_service.dart index 294a7848..7f2d4495 100644 --- a/lib/services/translation_service.dart +++ b/lib/services/translation_service.dart @@ -454,7 +454,7 @@ class TranslationService extends ChangeNotifier { final targetLabel = _languageLabel(targetLanguageCode); final instruction = targetLanguageCode == 'zh' ? '将以下文本翻译为中文,注意只需要输出翻译后的结果,不要额外解释:\n\n$text' - : 'Translate the following segment into $targetLabel, without additional explanation.\n\n$text'; + : 'Translate the following segment into $targetLabel, without additional explanation. If it is already in $targetLabel, then return the exact same String.\n\n$text'; try { return await _runExclusive(() async { final engine = await _ensureContext(model.localPath); From 1f6b2dacf95a8727b87f5ad60f4f402467a7508b Mon Sep 17 00:00:00 2001 From: ericz Date: Mon, 11 May 2026 07:15:39 +0200 Subject: [PATCH 02/10] changed translation prompt back to specific input. --- lib/services/translation_service.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/services/translation_service.dart b/lib/services/translation_service.dart index 7f2d4495..294a7848 100644 --- a/lib/services/translation_service.dart +++ b/lib/services/translation_service.dart @@ -454,7 +454,7 @@ class TranslationService extends ChangeNotifier { final targetLabel = _languageLabel(targetLanguageCode); final instruction = targetLanguageCode == 'zh' ? '将以下文本翻译为中文,注意只需要输出翻译后的结果,不要额外解释:\n\n$text' - : 'Translate the following segment into $targetLabel, without additional explanation. If it is already in $targetLabel, then return the exact same String.\n\n$text'; + : 'Translate the following segment into $targetLabel, without additional explanation.\n\n$text'; try { return await _runExclusive(() async { final engine = await _ensureContext(model.localPath); From 75ae903b99b4f341d5a419ccd4aa813f46e492cf Mon Sep 17 00:00:00 2001 From: ZIER Date: Tue, 12 May 2026 10:24:01 +0200 Subject: [PATCH 03/10] implement flutter_langdetect --- lib/services/translation_service.dart | 86 ++++++--------------------- pubspec.yaml | 1 + 2 files changed, 19 insertions(+), 68 deletions(-) diff --git a/lib/services/translation_service.dart b/lib/services/translation_service.dart index 294a7848..148650ca 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; @@ -436,7 +441,18 @@ 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; + } } Future _translateText({ @@ -518,72 +534,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/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: From f941f0dbfa937417f624d0a51c359f5acee9bebb Mon Sep 17 00:00:00 2001 From: ZIER Date: Wed, 13 May 2026 12:08:41 +0200 Subject: [PATCH 04/10] avoid double translation and strip replyInfo from translated text. --- lib/connector/meshcore_connector.dart | 114 ++++++++++++-------------- 1 file changed, 51 insertions(+), 63 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index ba18d3b1..1f422b88 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -1072,7 +1072,7 @@ class MeshCoreConnector extends ChangeNotifier { } } - Future _translateIncomingContactMessage( + Future _translateIncomingContactMessage( String contactKeyHex, Message message, ) async { @@ -1084,7 +1084,7 @@ class MeshCoreConnector extends ChangeNotifier { isCli: message.isCli, isOutgoing: message.isOutgoing, )) { - return; + return null; } final targetLanguageCode = service.resolvedIncomingLanguageCode( _appSettingsService?.settings.languageOverride, @@ -1094,7 +1094,7 @@ class MeshCoreConnector extends ChangeNotifier { targetLanguageCode: targetLanguageCode, ); if (result == null) { - return; + return null; } final translated = result.status == MessageTranslationStatus.completed ? result.translatedText @@ -1109,12 +1109,14 @@ class MeshCoreConnector extends ChangeNotifier { translationModelId: result.modelId, ), ); + return result; } catch (error) { appLogger.warn('Translation failed for contact message: $error'); + return null; } } - Future _translateIncomingChannelMessage( + Future _translateIncomingChannelMessage( int channelIndex, ChannelMessage message, ) async { @@ -1126,7 +1128,7 @@ class MeshCoreConnector extends ChangeNotifier { isCli: false, isOutgoing: message.isOutgoing, )) { - return; + return null; } final targetLanguageCode = service.resolvedIncomingLanguageCode( _appSettingsService?.settings.languageOverride, @@ -1136,11 +1138,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, @@ -1151,8 +1158,10 @@ class MeshCoreConnector extends ChangeNotifier { translationModelId: result.modelId, ), ); + return result; } catch (error) { appLogger.warn('Translation failed for channel message: $error'); + return null; } } @@ -4394,9 +4403,11 @@ class MeshCoreConnector extends ChangeNotifier { } } _addMessage(message.senderKeyHex, message); + TranslationResult? translationResult; if (!message.isOutgoing) { - unawaited( - _translateIncomingContactMessage(message.senderKeyHex, message), + translationResult = await _translateIncomingContactMessage( + message.senderKeyHex, + message, ); } _maybeIncrementContactUnread(message); @@ -4412,10 +4423,9 @@ class MeshCoreConnector extends ChangeNotifier { final msg = message; if (contact?.type == advTypeChat) { unawaited(() async { - final resolvedText = await _maybeTranslateForNotification( - msg.text, - isCli: msg.isCli, - ); + final resolvedText = translationResult != null + ? translationResult.translatedText.trim() + : msg.text; await _notificationService.showMessageNotification( contactName: contact?.name ?? 'Unknown', message: resolvedText, @@ -4429,10 +4439,9 @@ class MeshCoreConnector extends ChangeNotifier { ? msg.text.substring(4) : msg.text; unawaited(() async { - final resolvedText = await _maybeTranslateForNotification( - bodyText, - isCli: msg.isCli, - ); + final resolvedText = translationResult != null + ? translationResult.translatedText.trim() + : bodyText; await _notificationService.showMessageNotification( contactName: contact?.name ?? 'Unknown Room', message: resolvedText, @@ -4708,6 +4717,7 @@ class MeshCoreConnector extends ChangeNotifier { void _maybeNotifyChannelMessage( ChannelMessage message, { String? channelName, + TranslationResult? translationResult, }) { if (message.isOutgoing || _appSettingsService == null) return; final channelIndex = message.channelIndex; @@ -4721,12 +4731,11 @@ class MeshCoreConnector extends ChangeNotifier { final label = channelName ?? _channelDisplayName(channelIndex); if (_appSettingsService!.isChannelMuted(label)) return; - // Translate channel notification text if enabled + // Reuse translation result from earlier translation, or use original text + final resolvedText = translationResult != null + ? translationResult.translatedText.trim() + : message.text; unawaited(() async { - final resolvedText = await _maybeTranslateForNotification( - message.text, - isCli: false, - ); await _notificationService.showChannelMessageNotification( channelName: label, senderName: message.senderName, @@ -4737,41 +4746,7 @@ class MeshCoreConnector extends ChangeNotifier { }()); } - Future _maybeTranslateForNotification( - String text, { - required bool isCli, - }) async { - final service = _translationService; - if (service == null) return text; - try { - if (!service.shouldTranslateIncoming( - text: text, - isCli: isCli, - isOutgoing: false, - )) { - return text; - } - final targetLanguageCode = service.resolvedIncomingLanguageCode( - _appSettingsService?.settings.languageOverride, - ); - if (targetLanguageCode == null || targetLanguageCode.isEmpty) { - return text; - } - final result = await service.translateIncomingText( - text: text, - targetLanguageCode: targetLanguageCode, - ); - final translated = result?.translatedText.trim(); - if (translated != null && translated.isNotEmpty) { - return translated; - } - } catch (_) { - // Fallback to original text on any error - } - return text; - } - - void _handleIncomingChannelMessage(Uint8List frame) { + void _handleIncomingChannelMessage(Uint8List frame) async { final parsed = ChannelMessage.fromFrame(frame); if (parsed != null && parsed.channelIndex != null) { if (_shouldDropSelfChannelMessage(parsed.senderName, parsed.pathBytes)) { @@ -4790,15 +4765,20 @@ class MeshCoreConnector extends ChangeNotifier { pathBytes: message.pathBytes, ); final isNew = _addChannelMessage(message.channelIndex!, message); + TranslationResult? translationResult; if (isNew && !message.isOutgoing) { - unawaited( - _translateIncomingChannelMessage(message.channelIndex!, message), + translationResult = await _translateIncomingChannelMessage( + message.channelIndex!, + message, ); } _maybeIncrementChannelUnread(message, isNew: isNew); notifyListeners(); if (isNew) { - _maybeNotifyChannelMessage(message); + _maybeNotifyChannelMessage( + message, + translationResult: translationResult, + ); } _handleQueuedMessageReceived(); } else if (_isSyncingQueuedMessages) { @@ -4806,7 +4786,7 @@ class MeshCoreConnector extends ChangeNotifier { } } - void _handleLogRxData(Uint8List frame) { + void _handleLogRxData(Uint8List frame) async { if (frame.length < 4) return; try { final reader = BufferReader(frame); @@ -4874,8 +4854,12 @@ class MeshCoreConnector extends ChangeNotifier { pathBytes: message.pathBytes, ); final isNew = _addChannelMessage(channel.index, message); + TranslationResult? translationResult; if (isNew && !message.isOutgoing) { - unawaited(_translateIncomingChannelMessage(channel.index, message)); + translationResult = await _translateIncomingChannelMessage( + channel.index, + message, + ); } _maybeIncrementChannelUnread(message, isNew: isNew); notifyListeners(); @@ -4883,7 +4867,11 @@ class MeshCoreConnector extends ChangeNotifier { final label = channel.name.isEmpty ? 'Channel ${channel.index}' : channel.name; - _maybeNotifyChannelMessage(message, channelName: label); + _maybeNotifyChannelMessage( + message, + channelName: label, + translationResult: translationResult, + ); } return; } catch (e) { From 1fbe1823cbb60953a74a37253ecee797db8f2c1c Mon Sep 17 00:00:00 2001 From: ericz Date: Wed, 13 May 2026 18:06:26 +0200 Subject: [PATCH 05/10] only take translated result if status==completed --- lib/connector/meshcore_connector.dart | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 1f422b88..a3dc4650 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -4423,9 +4423,13 @@ class MeshCoreConnector extends ChangeNotifier { final msg = message; if (contact?.type == advTypeChat) { unawaited(() async { - final resolvedText = translationResult != null + final resolvedText = + (translationResult != null && + translationResult.status == + MessageTranslationStatus.completed && + translationResult.translatedText.trim().isNotEmpty) ? translationResult.translatedText.trim() - : msg.text; + : msg.text.trim(); await _notificationService.showMessageNotification( contactName: contact?.name ?? 'Unknown', message: resolvedText, @@ -4439,9 +4443,13 @@ class MeshCoreConnector extends ChangeNotifier { ? msg.text.substring(4) : msg.text; unawaited(() async { - final resolvedText = translationResult != null + final resolvedText = + (translationResult != null && + translationResult.status == + MessageTranslationStatus.completed && + translationResult.translatedText.trim().isNotEmpty) ? translationResult.translatedText.trim() - : bodyText; + : bodyText.trim(); await _notificationService.showMessageNotification( contactName: contact?.name ?? 'Unknown Room', message: resolvedText, @@ -4731,10 +4739,13 @@ class MeshCoreConnector extends ChangeNotifier { final label = channelName ?? _channelDisplayName(channelIndex); if (_appSettingsService!.isChannelMuted(label)) return; - // Reuse translation result from earlier translation, or use original text - final resolvedText = translationResult != null + // 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; + : message.text.trim(); unawaited(() async { await _notificationService.showChannelMessageNotification( channelName: label, From bc5f2993501d9a5213b8cd813ceed81d3a451422 Mon Sep 17 00:00:00 2001 From: ericz Date: Sun, 17 May 2026 10:47:50 +0200 Subject: [PATCH 06/10] try fix codex sync problem --- lib/connector/meshcore_connector.dart | 109 +++++++++++++------------- 1 file changed, 53 insertions(+), 56 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index a3dc4650..982f7e75 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -4338,6 +4338,12 @@ class MeshCoreConnector extends ChangeNotifier { void _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 @@ -4403,26 +4409,23 @@ class MeshCoreConnector extends ChangeNotifier { } } _addMessage(message.senderKeyHex, message); - TranslationResult? translationResult; - if (!message.isOutgoing) { - translationResult = await _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) { - // Capture non-null local for async closure type promotion - final msg = message; - if (contact?.type == advTypeChat) { - unawaited(() async { + final msg = message; // capture for closure + final c = contact; // capture contact reference + unawaited(() async { + final translationResult = await _translateIncomingContactMessage( + msg.senderKeyHex, + msg, + ); + if (c?.type == advTypeChat) { final resolvedText = (translationResult != null && translationResult.status == @@ -4431,18 +4434,16 @@ class MeshCoreConnector extends ChangeNotifier { ? translationResult.translatedText.trim() : msg.text.trim(); await _notificationService.showMessageNotification( - contactName: contact?.name ?? 'Unknown', + contactName: c?.name ?? 'Unknown', message: resolvedText, contactId: msg.senderKeyHex, badgeCount: getTotalUnreadCount(), ); - }()); - } else if (contact?.type == advTypeRoom) { - // Room server messages include a 4-char prefix; strip it for notifications - final bodyText = msg.text.length > 4 - ? msg.text.substring(4) - : msg.text; - unawaited(() async { + } else if (c?.type == advTypeRoom) { + // Room server messages include a 4-char prefix; strip it for notifications + final bodyText = msg.text.length > 4 + ? msg.text.substring(4) + : msg.text; final resolvedText = (translationResult != null && translationResult.status == @@ -4451,18 +4452,15 @@ class MeshCoreConnector extends ChangeNotifier { ? translationResult.translatedText.trim() : bodyText.trim(); await _notificationService.showMessageNotification( - contactName: contact?.name ?? 'Unknown Room', + contactName: c?.name ?? 'Unknown Room', message: resolvedText, contactId: msg.senderKeyHex, badgeCount: getTotalUnreadCount(), ); - }()); - } + } + }()); } } - _handleQueuedMessageReceived(); - } else if (_isSyncingQueuedMessages) { - _handleQueuedMessageReceived(); } } @@ -4758,6 +4756,11 @@ class MeshCoreConnector extends ChangeNotifier { } 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)) { @@ -4776,24 +4779,18 @@ class MeshCoreConnector extends ChangeNotifier { pathBytes: message.pathBytes, ); final isNew = _addChannelMessage(message.channelIndex!, message); - TranslationResult? translationResult; - if (isNew && !message.isOutgoing) { - translationResult = await _translateIncomingChannelMessage( - message.channelIndex!, - message, - ); - } _maybeIncrementChannelUnread(message, isNew: isNew); notifyListeners(); - if (isNew) { - _maybeNotifyChannelMessage( - message, - translationResult: translationResult, - ); + if (isNew && !message.isOutgoing) { + final msg = message; // capture for closure + unawaited(() async { + final translationResult = await _translateIncomingChannelMessage( + msg.channelIndex!, + msg, + ); + _maybeNotifyChannelMessage(msg, translationResult: translationResult); + }()); } - _handleQueuedMessageReceived(); - } else if (_isSyncingQueuedMessages) { - _handleQueuedMessageReceived(); } } @@ -4865,24 +4862,24 @@ class MeshCoreConnector extends ChangeNotifier { pathBytes: message.pathBytes, ); final isNew = _addChannelMessage(channel.index, message); - TranslationResult? translationResult; - if (isNew && !message.isOutgoing) { - translationResult = await _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, - translationResult: translationResult, - ); + // Run translation + notification asynchronously to avoid blocking + unawaited(() async { + final translationResult = await _translateIncomingChannelMessage( + channel.index, + message, + ); + final label = channel.name.isEmpty + ? 'Channel ${channel.index}' + : channel.name; + _maybeNotifyChannelMessage( + message, + channelName: label, + translationResult: translationResult, + ); + }()); } return; } catch (e) { From 9ada4ea601513a4f49d1508a99c7307f08418395 Mon Sep 17 00:00:00 2001 From: ericz Date: Wed, 20 May 2026 21:24:54 +0200 Subject: [PATCH 07/10] add toggle for autmatically translated messages for notification and chat or manual translation on message action. Due to heavy battery usage. --- lib/connector/meshcore_connector.dart | 62 ++++++++++++++++++-------- lib/l10n/app_bg.arb | 3 ++ lib/l10n/app_de.arb | 3 ++ lib/l10n/app_en.arb | 3 ++ lib/l10n/app_es.arb | 3 ++ lib/l10n/app_fr.arb | 3 ++ lib/l10n/app_hu.arb | 3 ++ lib/l10n/app_it.arb | 3 ++ lib/l10n/app_ja.arb | 3 ++ lib/l10n/app_ko.arb | 3 ++ lib/l10n/app_localizations.dart | 18 ++++++++ lib/l10n/app_localizations_bg.dart | 10 +++++ lib/l10n/app_localizations_de.dart | 11 +++++ lib/l10n/app_localizations_en.dart | 11 +++++ lib/l10n/app_localizations_es.dart | 11 +++++ lib/l10n/app_localizations_fr.dart | 11 +++++ lib/l10n/app_localizations_hu.dart | 10 +++++ lib/l10n/app_localizations_it.dart | 11 +++++ lib/l10n/app_localizations_ja.dart | 10 +++++ lib/l10n/app_localizations_ko.dart | 10 +++++ lib/l10n/app_localizations_nl.dart | 10 +++++ lib/l10n/app_localizations_pl.dart | 11 +++++ lib/l10n/app_localizations_pt.dart | 11 +++++ lib/l10n/app_localizations_ru.dart | 11 +++++ lib/l10n/app_localizations_sk.dart | 10 +++++ lib/l10n/app_localizations_sl.dart | 10 +++++ lib/l10n/app_localizations_sv.dart | 11 +++++ lib/l10n/app_localizations_uk.dart | 11 +++++ lib/l10n/app_localizations_zh.dart | 9 ++++ lib/l10n/app_nl.arb | 3 ++ lib/l10n/app_pl.arb | 3 ++ lib/l10n/app_pt.arb | 3 ++ lib/l10n/app_ru.arb | 3 ++ lib/l10n/app_sk.arb | 3 ++ lib/l10n/app_sl.arb | 3 ++ lib/l10n/app_sv.arb | 3 ++ lib/l10n/app_uk.arb | 3 ++ lib/l10n/app_zh.arb | 3 ++ lib/models/app_settings.dart | 8 ++++ lib/screens/app_settings_screen.dart | 39 ++++++++++++++-- lib/screens/channel_chat_screen.dart | 28 ++++++++++++ lib/screens/chat_screen.dart | 27 +++++++++++ lib/services/app_settings_service.dart | 6 +++ lib/services/translation_service.dart | 17 ++++++- 44 files changed, 424 insertions(+), 24 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 982f7e75..7eca6e88 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -1072,18 +1072,30 @@ 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, - )) { + !(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( @@ -1116,18 +1128,30 @@ class MeshCoreConnector extends ChangeNotifier { } } - 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, - )) { + !(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( @@ -4421,7 +4445,7 @@ class MeshCoreConnector extends ChangeNotifier { final msg = message; // capture for closure final c = contact; // capture contact reference unawaited(() async { - final translationResult = await _translateIncomingContactMessage( + final translationResult = await translateContactMessage( msg.senderKeyHex, msg, ); @@ -4784,7 +4808,7 @@ class MeshCoreConnector extends ChangeNotifier { if (isNew && !message.isOutgoing) { final msg = message; // capture for closure unawaited(() async { - final translationResult = await _translateIncomingChannelMessage( + final translationResult = await translateChannelMessage( msg.channelIndex!, msg, ); @@ -4867,7 +4891,7 @@ class MeshCoreConnector extends ChangeNotifier { if (isNew) { // Run translation + notification asynchronously to avoid blocking unawaited(() async { - final translationResult = await _translateIncomingChannelMessage( + final translationResult = await translateChannelMessage( channel.index, message, ); diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb index 45536cf6..c70ca78d 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 7f491d4f..4a9be70b 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 456fab56..b3c5774a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2288,6 +2288,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 d4cf27da..b85e7652 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 7817c20d..fd6b53e0 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 97426183..d41c62f2 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 664bceef..e60cfc4a 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 9d2cef21..c68fc98d 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 d778429b..7418068c 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 159d755c..b8c6558c 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -7114,6 +7114,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 409803b2..19883f16 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -4161,6 +4161,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 9f06906d..20d64c39 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -4176,6 +4176,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 6def06b7..5da877e1 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -4085,6 +4085,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 bb4f40e8..f55bb1c7 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -4163,6 +4163,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 44e40657..a1770dc3 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -4193,6 +4193,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 298a6fd6..ee41febf 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -4181,6 +4181,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 e3f7ee58..55804809 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -4169,6 +4169,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 263a906e..844f3b66 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -3941,6 +3941,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 f9c0d760..1bc02940 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -3942,6 +3942,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 ad140bf3..cf78b5e7 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -4146,6 +4146,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 6c7c3696..e4b33f9f 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -4184,6 +4184,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 e6fd49e3..f0ad632b 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -4159,6 +4159,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 25452003..46ef46b6 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -4177,6 +4177,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 91461385..29a0b8a9 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -4141,6 +4141,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 48103647..125aa604 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -4139,6 +4139,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 7453c18f..4bf43719 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -4113,6 +4113,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 9c30fd8e..58f381cf 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -4176,6 +4176,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 d2304a66..ec03b8e8 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -3816,6 +3816,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 7e83215b..68d53148 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 4366e273..f79a589a 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 6b8abbbc..994ff8a2 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 5d5bcfc3..2b0978a6 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 713c3976..1a65b35f 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 00930c85..eff2db17 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 00fd3b08..044abbc7 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 8330d364..37300095 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 1e289315..5603034f 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/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..4d791381 100644 --- a/lib/screens/app_settings_screen.dart +++ b/lib/screens/app_settings_screen.dart @@ -559,6 +559,7 @@ class AppSettingsScreen extends StatelessWidget { TranslationService translationService, ) { final settings = settingsService.settings; + final translationEnabled = settings.translationEnabled; return Card( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -579,11 +580,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 fcd50861..b57bd235 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; @@ -1320,6 +1321,18 @@ class _ChannelChatScreenState extends State { } void _showMessageActions(ChannelMessage message) { + final settings = context.read().settings; + final translationService = context.read(); + final canTranslateMessage = + settings.translationEnabled && + !settings.autoTranslateIncomingMessages && + translationService.canTranslateIncoming( + text: message.text, + isCli: false, + isOutgoing: message.isOutgoing, + ) && + (message.translatedText?.trim().isEmpty ?? true); + showModalBottomSheet( context: context, builder: (sheetContext) => SafeArea( @@ -1361,6 +1374,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/chat_screen.dart b/lib/screens/chat_screen.dart index 8d3bc66c..07884b96 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -1577,6 +1577,18 @@ class _ChatScreenState extends State { } void _showMessageActions(Message message, Contact contact) { + final settings = context.read().settings; + final translationService = context.read(); + final canTranslateMessage = + settings.translationEnabled && + !settings.autoTranslateIncomingMessages && + translationService.canTranslateIncoming( + text: message.text, + isCli: message.isCli, + isOutgoing: message.isOutgoing, + ) && + (message.translatedText?.trim().isEmpty ?? true); + showModalBottomSheet( context: context, builder: (sheetContext) => SafeArea( @@ -1610,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/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 148650ca..4437d1f1 100644 --- a/lib/services/translation_service.dart +++ b/lib/services/translation_service.dart @@ -89,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, From 3fe5cdf55d27ee8eb6b41ae1bc8bdb00fa39286a Mon Sep 17 00:00:00 2001 From: ericz Date: Wed, 20 May 2026 23:20:16 +0200 Subject: [PATCH 08/10] update to current dev a50c0d0b2dcfb33cf57b553db6b909802262dfe6 --- README.md | 1 + lib/connector/meshcore_connector.dart | 96 ++++++-- lib/connector/meshcore_uuids.dart | 1 + lib/models/channel.dart | 34 +++ lib/models/community.dart | 33 +++ lib/models/radio_settings.dart | 270 ++++++++++++++++++++++ lib/screens/channel_chat_screen.dart | 77 +++++- lib/screens/channels_screen.dart | 84 +++---- lib/screens/chat_screen.dart | 15 +- lib/screens/contacts_screen.dart | 2 + lib/screens/line_of_sight_map_screen.dart | 6 + lib/screens/map_screen.dart | 2 + lib/services/translation_service.dart | 16 +- lib/widgets/quick_switch_bar.dart | 46 +++- 14 files changed, 583 insertions(+), 100 deletions(-) diff --git a/README.md b/README.md index 94d54bac..041ea6e9 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,7 @@ Devices are discovered by scanning for BLE advertisements with known MeshCore de - `WisCore-` - `HT-` - `LowMesh_MC_` + - `NRF52` New device prefixes can be added in `lib/connector/meshcore_uuids.dart`. diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 7eca6e88..0a7056d5 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -302,6 +302,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; @@ -606,16 +608,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) { @@ -649,11 +677,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) { @@ -680,6 +710,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', @@ -721,6 +753,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', @@ -3156,6 +3190,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), @@ -3549,6 +3586,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(); @@ -4101,6 +4139,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), @@ -4191,6 +4232,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), @@ -4464,17 +4508,13 @@ class MeshCoreConnector extends ChangeNotifier { badgeCount: getTotalUnreadCount(), ); } else if (c?.type == advTypeRoom) { - // Room server messages include a 4-char prefix; strip it for notifications - final bodyText = msg.text.length > 4 - ? msg.text.substring(4) - : msg.text; final resolvedText = (translationResult != null && translationResult.status == MessageTranslationStatus.completed && translationResult.translatedText.trim().isNotEmpty) ? translationResult.translatedText.trim() - : bodyText.trim(); + : msg.text.trim(); await _notificationService.showMessageNotification( contactName: c?.name ?? 'Unknown Room', message: resolvedText, @@ -4522,16 +4562,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( @@ -4568,9 +4616,7 @@ class MeshCoreConnector extends ChangeNotifier { status: MessageStatus.delivered, pathLength: pathLength == 0xFF ? 0 : pathLength, 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'); @@ -5247,6 +5293,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', @@ -5291,6 +5338,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', diff --git a/lib/connector/meshcore_uuids.dart b/lib/connector/meshcore_uuids.dart index ae6697b6..084cc424 100644 --- a/lib/connector/meshcore_uuids.dart +++ b/lib/connector/meshcore_uuids.dart @@ -11,5 +11,6 @@ class MeshCoreUuids { "Lilygo", "HT-", "LowMesh_MC_", + "NRF52", ]; } 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/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index b57bd235..f34bab04 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -9,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'; @@ -57,8 +59,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 +87,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 +114,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 +213,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( @@ -1321,11 +1387,8 @@ class _ChannelChatScreenState extends State { } void _showMessageActions(ChannelMessage message) { - final settings = context.read().settings; final translationService = context.read(); final canTranslateMessage = - settings.translationEnabled && - !settings.autoTranslateIncomingMessages && translationService.canTranslateIncoming( text: message.text, isCli: false, diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 4613a8ee..8b05b8ce 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -44,11 +44,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 +69,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(); @@ -360,6 +332,8 @@ class _ChannelsScreenState extends State selectedIndex: 1, onDestinationSelected: (index) => _handleQuickSwitch(index, context), + contactsUnreadCount: connector.getTotalContactsUnreadCount(), + channelsUnreadCount: connector.getTotalChannelsUnreadCount(), ), ), ), @@ -375,37 +349,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 07884b96..7c71d597 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -485,6 +485,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 +511,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), @@ -1577,11 +1578,8 @@ class _ChatScreenState extends State { } void _showMessageActions(Message message, Contact contact) { - final settings = context.read().settings; final translationService = context.read(); final canTranslateMessage = - settings.translationEnabled && - !settings.autoTranslateIncomingMessages && translationService.canTranslateIncoming( text: message.text, isCli: message.isCli, @@ -1748,7 +1746,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; @@ -1759,7 +1756,6 @@ class _MessageBubble extends StatelessWidget { required this.message, required this.senderName, required this.sourceId, - required this.isRoomServer, required this.textScale, this.onTap, this.onLongPress, @@ -1785,10 +1781,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 a4cc35ca..d5e1b79a 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -430,6 +430,8 @@ class _ContactsScreenState extends State selectedIndex: 0, onDestinationSelected: (index) => _handleQuickSwitch(index, context), + contactsUnreadCount: connector.getTotalContactsUnreadCount(), + channelsUnreadCount: connector.getTotalChannelsUnreadCount(), ), ), ), 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 4ae2fd43..be133240 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -676,6 +676,8 @@ class _MapScreenState extends State { selectedIndex: 2, onDestinationSelected: (index) => _handleQuickSwitch(index, context), + contactsUnreadCount: connector.getTotalContactsUnreadCount(), + channelsUnreadCount: connector.getTotalChannelsUnreadCount(), ), ), floatingActionButton: FloatingActionButton( diff --git a/lib/services/translation_service.dart b/lib/services/translation_service.dart index 4437d1f1..7b1d7f5f 100644 --- a/lib/services/translation_service.dart +++ b/lib/services/translation_service.dart @@ -388,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( @@ -429,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( @@ -470,6 +474,14 @@ class TranslationService extends ChangeNotifier { } } + String _stripReplyInfoForDetection(String text) { + final match = RegExp( + r'@\[([^\]]+)\]\s+(.+)$', + dotAll: true, + ).firstMatch(text); + return match?.group(2) ?? text; + } + Future _translateText({ required String text, required String targetLanguageCode, 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, + ), + ), + ), + ], + ); + } } From 8682e6ea6762c475e6d3750f68103b2e268c0d81 Mon Sep 17 00:00:00 2001 From: ericz Date: Thu, 21 May 2026 00:02:21 +0200 Subject: [PATCH 09/10] fix missing _handleQueuedMessageReceived after merge of dev --- lib/connector/meshcore_connector.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 0a7056d5..bb0def43 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -4525,6 +4525,9 @@ class MeshCoreConnector extends ChangeNotifier { }()); } } + _handleQueuedMessageReceived(); + } else if (_isSyncingQueuedMessages) { + _handleQueuedMessageReceived(); } } @@ -4861,6 +4864,9 @@ class MeshCoreConnector extends ChangeNotifier { _maybeNotifyChannelMessage(msg, translationResult: translationResult); }()); } + _handleQueuedMessageReceived(); + } else if (_isSyncingQueuedMessages) { + _handleQueuedMessageReceived(); } } From bac82dc9e8b1e464613c498ade1b6e5f86f18df9 Mon Sep 17 00:00:00 2001 From: ericz Date: Thu, 21 May 2026 00:11:54 +0200 Subject: [PATCH 10/10] Fix Flutter SDK update --- lib/screens/channels_screen.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 8b05b8ce..0737a7d0 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -273,7 +273,7 @@ class _ChannelsScreenState extends State ), buildDefaultDragHandles: false, itemCount: filteredChannels.length, - onReorder: (oldIndex, newIndex) { + onReorderItem: (oldIndex, newIndex) { if (newIndex > oldIndex) newIndex -= 1; final reordered = List.from( filteredChannels,