diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index b99ecf7..c804340 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -14,6 +14,7 @@ import '../models/companion_radio_stats.dart'; import '../models/contact.dart'; import '../models/message.dart'; import '../models/path_selection.dart'; +import '../models/translation_support.dart'; import '../helpers/reaction_helper.dart'; import '../helpers/smaz.dart'; import '../services/app_debug_log_service.dart'; @@ -26,6 +27,7 @@ import '../services/path_history_service.dart'; import '../services/app_settings_service.dart'; import '../services/background_service.dart'; import '../services/timeout_prediction_service.dart'; +import '../services/translation_service.dart'; import '../services/notification_service.dart'; import 'meshcore_connector_usb.dart'; import 'meshcore_connector_tcp.dart'; @@ -180,6 +182,7 @@ class MeshCoreConnector extends ChangeNotifier { bool _isLoadingChannels = false; bool _hasLoadedChannels = false; TimeoutPredictionService? _timeoutPredictionService; + TranslationService? _translationService; // Intentionally global (not per-contact): tracks overall network activity. // Frequent RX from any source indicates a busy network with more collisions. DateTime _lastRxTime = DateTime.now(); @@ -482,7 +485,7 @@ class MeshCoreConnector extends ChangeNotifier { String _messageMergeKey(Message message) { final messageId = message.messageId; - if (messageId != null && messageId.isNotEmpty) { + if (messageId.isNotEmpty) { return 'id:$messageId'; } return 'fallback:${message.senderKeyHex}:${message.isOutgoing}:${message.isCli}:${message.timestamp.millisecondsSinceEpoch}:${message.text}'; @@ -721,6 +724,7 @@ class MeshCoreConnector extends ChangeNotifier { required MessageRetryService retryService, required PathHistoryService pathHistoryService, AppSettingsService? appSettingsService, + TranslationService? translationService, BleDebugLogService? bleDebugLogService, AppDebugLogService? appDebugLogService, BackgroundService? backgroundService, @@ -729,6 +733,7 @@ class MeshCoreConnector extends ChangeNotifier { _retryService = retryService; _pathHistoryService = pathHistoryService; _appSettingsService = appSettingsService; + _translationService = translationService; _bleDebugLogService = bleDebugLogService; _appDebugLogService = appDebugLogService; _backgroundService = backgroundService; @@ -952,6 +957,126 @@ class MeshCoreConnector extends ChangeNotifier { } } + Future _translateIncomingContactMessage( + String contactKeyHex, + Message message, + ) async { + try { + final service = _translationService; + if (service == null || + !service.shouldTranslateIncoming( + text: message.text, + isCli: message.isCli, + isOutgoing: message.isOutgoing, + )) { + return; + } + final targetLanguageCode = service.resolvedIncomingLanguageCode( + _appSettingsService?.settings.languageOverride, + ); + final result = await service.translateIncomingText( + text: message.text, + targetLanguageCode: targetLanguageCode, + ); + if (result == null) { + return; + } + final translated = result.status == MessageTranslationStatus.completed + ? result.translatedText + : null; + _updateStoredContactMessage( + contactKeyHex, + message.messageId, + (current) => current.copyWith( + translatedText: translated, + translatedLanguageCode: result.detectedLanguageCode, + translationStatus: result.status, + translationModelId: result.modelId, + ), + ); + } catch (error) { + appLogger.warn('Translation failed for contact message: $error'); + } + } + + Future _translateIncomingChannelMessage( + int channelIndex, + ChannelMessage message, + ) async { + try { + final service = _translationService; + if (service == null || + !service.shouldTranslateIncoming( + text: message.text, + isCli: false, + isOutgoing: message.isOutgoing, + )) { + return; + } + final targetLanguageCode = service.resolvedIncomingLanguageCode( + _appSettingsService?.settings.languageOverride, + ); + final result = await service.translateIncomingText( + text: message.text, + targetLanguageCode: targetLanguageCode, + ); + if (result == null) { + return; + } + final translated = result.status == MessageTranslationStatus.completed + ? result.translatedText + : null; + _updateStoredChannelMessage( + channelIndex, + message.messageId, + (current) => current.copyWith( + translatedText: translated, + translatedLanguageCode: result.detectedLanguageCode, + translationStatus: result.status, + translationModelId: result.modelId, + ), + ); + } catch (error) { + appLogger.warn('Translation failed for channel message: $error'); + } + } + + void _updateStoredContactMessage( + String contactKeyHex, + String messageId, + Message Function(Message current) update, + ) { + final messages = _conversations[contactKeyHex]; + if (messages == null) { + return; + } + final index = messages.indexWhere((entry) => entry.messageId == messageId); + if (index < 0) { + return; + } + messages[index] = update(messages[index]); + _messageStore.saveMessages(contactKeyHex, messages); + notifyListeners(); + } + + void _updateStoredChannelMessage( + int channelIndex, + String messageId, + ChannelMessage Function(ChannelMessage current) update, + ) { + final messages = _channelMessages[channelIndex]; + if (messages == null) { + return; + } + final index = messages.indexWhere((entry) => entry.messageId == messageId); + if (index < 0) { + return; + } + messages[index] = update(messages[index]); + _channelMessageStore.saveChannelMessages(channelIndex, messages); + notifyListeners(); + } + void _recordPathResult( String contactPubKeyHex, PathSelection selection, @@ -2116,6 +2241,7 @@ class MeshCoreConnector extends ChangeNotifier { _channelSyncTimeout?.cancel(); _channelSyncTimeout = null; _channelSyncRetries = 0; + await _translationService?.releaseModel(); if (!skipBleDeviceDisconnect) { try { @@ -2395,7 +2521,13 @@ class MeshCoreConnector extends ChangeNotifier { await sendFrame(buildGetContactByKeyFrame(pubKey)); } - Future sendMessage(Contact contact, String text) async { + Future sendMessage( + Contact contact, + String text, { + String? originalText, + String? translatedLanguageCode, + String? translationModelId, + }) async { if (!isConnected || text.isEmpty) return; // Check if this is a reaction - apply locally with pending status and route through retry service @@ -2426,7 +2558,13 @@ class MeshCoreConnector extends ChangeNotifier { } if (_retryService != null) { - await _retryService!.sendMessageWithRetry(contact: contact, text: text); + await _retryService!.sendMessageWithRetry( + contact: contact, + text: text, + originalText: originalText, + translatedLanguageCode: translatedLanguageCode, + translationModelId: translationModelId, + ); } else { // Fallback to old behavior if retry service not initialized final resolved = resolvePathSelection(contact); @@ -2435,6 +2573,9 @@ class MeshCoreConnector extends ChangeNotifier { text, pathLength: resolved.useFlood ? -1 : resolved.hopCount, pathBytes: Uint8List.fromList(resolved.pathBytes), + originalText: originalText, + translatedLanguageCode: translatedLanguageCode, + translationModelId: translationModelId, ); _addMessage(contact.publicKeyHex, message); notifyListeners(); @@ -2740,7 +2881,13 @@ class MeshCoreConnector extends ChangeNotifier { } } - Future sendChannelMessage(Channel channel, String text) async { + Future sendChannelMessage( + Channel channel, + String text, { + String? originalText, + String? translatedLanguageCode, + String? translationModelId, + }) async { if (!isConnected || text.isEmpty) return; // Check if this is a reaction - if so, process it immediately instead of adding as a message @@ -2787,6 +2934,9 @@ class MeshCoreConnector extends ChangeNotifier { text, _selfName ?? 'Me', channel.index, + originalText: originalText, + translatedLanguageCode: translatedLanguageCode, + translationModelId: translationModelId, ); _addChannelMessage(channel.index, message); _pendingChannelSentQueue.add(message.messageId); @@ -4061,6 +4211,11 @@ class MeshCoreConnector extends ChangeNotifier { } } _addMessage(message.senderKeyHex, message); + if (!message.isOutgoing) { + unawaited( + _translateIncomingContactMessage(message.senderKeyHex, message), + ); + } _maybeIncrementContactUnread(message); notifyListeners(); @@ -4283,6 +4438,11 @@ class MeshCoreConnector extends ChangeNotifier { pathBytes: message.pathBytes, ); final isNew = _addChannelMessage(message.channelIndex!, message); + if (isNew && !message.isOutgoing) { + unawaited( + _translateIncomingChannelMessage(message.channelIndex!, message), + ); + } _maybeIncrementChannelUnread(message, isNew: isNew); notifyListeners(); if (isNew) { @@ -4362,6 +4522,9 @@ class MeshCoreConnector extends ChangeNotifier { pathBytes: message.pathBytes, ); final isNew = _addChannelMessage(channel.index, message); + if (isNew && !message.isOutgoing) { + unawaited(_translateIncomingChannelMessage(channel.index, message)); + } _maybeIncrementChannelUnread(message, isNew: isNew); notifyListeners(); if (isNew) { @@ -5056,6 +5219,11 @@ class MeshCoreConnector extends ChangeNotifier { senderKey: message.senderKey, senderName: message.senderName, text: replyInfo.actualMessage, + originalText: message.originalText, + translatedText: message.translatedText, + translatedLanguageCode: message.translatedLanguageCode, + translationStatus: message.translationStatus, + translationModelId: message.translationModelId, timestamp: message.timestamp, isOutgoing: message.isOutgoing, status: message.status, diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb index 0f5145d..13e9de7 100644 --- a/lib/l10n/app_bg.arb +++ b/lib/l10n/app_bg.arb @@ -2007,6 +2007,34 @@ "radioStats_stripWaiting": "Извличане на данни за радиото…", "radioStats_settingsTile": "Статистически данни за радиостанции", "radioStats_settingsSubtitle": "Ниво на шума, RSSI, SNR и време на пренос", + "@translation_downloadFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "translation_enableTitle": "Активирайте превода", + "translation_title": "Превод", + "translation_composerTitle": "Преведете преди да изпратите", + "translation_enableSubtitle": "Превеждайте входящите съобщения и позволявайте предварително превеждане преди изпращане.", + "translation_composerSubtitle": "Контролира началния статус на иконата за превод, създадена от композитора.", + "translation_targetLanguage": "Целеви език", + "translation_useAppLanguage": "Използвайте езика на приложението", + "translation_downloadedModelLabel": "Изтегнат модел", + "translation_presetModelLabel": "Предварително конфигуриран модел от Hugging Face", + "translation_manualUrlLabel": "URL на ръководството", + "translation_downloadModel": "Изтеглете модела", + "translation_downloading": "Изтегляне...", + "translation_working": "Работа...", + "translation_stop": "Спрете", + "translation_mergingChunks": "Съединяване на изтеглените части в един файл...", + "translation_downloadedModels": "Изтеглени модели", + "translation_deleteModel": "Изтриване на модела", + "translation_modelDownloaded": "Моделът за превод е изтеглен.", + "translation_downloadStopped": "Изтеглянето беше прекъснато.", + "translation_downloadFailed": "Не успях да изтегля: {error}", + "translation_enterUrlFirst": "Въведете първо URL адрес на модела.", "@scanner_linuxPairingPinPrompt": { "placeholders": { "deviceName": { @@ -2014,8 +2042,22 @@ } } }, - "scanner_linuxPairingHidePin": "Скрий ПИН", + "scanner_linuxPairingPinTitle": "PIN за съвпадение чрез Bluetooth", + "scanner_linuxPairingPinPrompt": "Въведете PIN кода за {deviceName} (оставете празно, ако няма такъв).", + "scanner_linuxPairingHidePin": "Скриване на PIN кода", "scanner_linuxPairingShowPin": "Покажи PIN", - "scanner_linuxPairingPinTitle": "PIN код за сдвояване на Bluetooth", - "scanner_linuxPairingPinPrompt": "Въведете ПИН за {deviceName} (оставете празно, ако няма)." + "@translation_translateTo": { + "placeholders": { + "language": { + "type": "String" + } + } + }, + "translation_composerDisabledHint": "Изпращайте съобщения на оригиналния въведен език.", + "translation_translateBeforeSending": "Преведете преди да изпратите", + "translation_messageTranslation": "Превод на съобщението", + "translation_composerEnabledHint": "Съобщенията ще бъдат преведени, преди да бъдат изпратени.", + "translation_translateTo": "Превеждане на {language}", + "translation_translationOptions": "Опции за превод", + "translation_systemLanguage": "Език на системата" } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index c156a44..62badce 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -2035,6 +2035,34 @@ "radioStats_stripWaiting": "Abrufen von Radiostatus…", "radioStats_settingsTile": "Senderinformationen", "radioStats_settingsSubtitle": "Rauschpegel, RSSI, Signal-Rausch-Verhältnis (SNR) und Nutzzeit", + "@translation_downloadFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "translation_title": "Übersetzung", + "translation_composerTitle": "Übersetzen Sie vor dem Versenden", + "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_targetLanguage": "Zielsprache", + "translation_useAppLanguage": "Verwenden Sie die App-Sprache", + "translation_downloadedModelLabel": "Heruntergeladenes Modell", + "translation_presetModelLabel": "Vordefinierter Hugging Face-Modell", + "translation_manualUrlLabel": "URL für das manuelle Modell", + "translation_downloadModel": "Modell herunterladen", + "translation_downloading": "Herunterladen...", + "translation_working": "Arbeiten...", + "translation_stop": "Stopp", + "translation_mergingChunks": "Zusammenführen der heruntergeladenen Teile in die finale Datei...", + "translation_downloadedModels": "Heruntergeladene Modelle", + "translation_deleteModel": "Modell löschen", + "translation_modelDownloaded": "Übersetzungsmotor heruntergeladen.", + "translation_downloadStopped": "Herunterladen abgebrochen.", + "translation_downloadFailed": "Download fehlgeschlagen: {error}", + "translation_enterUrlFirst": "Geben Sie zunächst die URL eines Modells ein.", "@scanner_linuxPairingPinPrompt": { "placeholders": { "deviceName": { @@ -2042,8 +2070,22 @@ } } }, + "scanner_linuxPairingPinPrompt": "Geben Sie den PIN-Code für {deviceName} ein (lassen Sie das Feld leer, falls kein PIN-Code vorhanden ist).", "scanner_linuxPairingShowPin": "PIN anzeigen", - "scanner_linuxPairingHidePin": "PIN ausblenden", - "scanner_linuxPairingPinTitle": "Bluetooth-Paarungs-PIN", - "scanner_linuxPairingPinPrompt": "Geben Sie die PIN für {deviceName} ein (leer lassen, falls keine)." + "scanner_linuxPairingPinTitle": "PIN für die Bluetooth-Verbindung", + "scanner_linuxPairingHidePin": "PIN verbergen", + "@translation_translateTo": { + "placeholders": { + "language": { + "type": "String" + } + } + }, + "translation_messageTranslation": "Nachricht übersetzen", + "translation_composerEnabledHint": "Die Nachrichten werden vor dem Versenden übersetzt.", + "translation_translateBeforeSending": "Übersetzen Sie vor dem Versenden", + "translation_composerDisabledHint": "Nachrichten in der ursprünglichen, getippten Sprache senden.", + "translation_translateTo": "Übersetzen Sie auf {language}", + "translation_translationOptions": "Übersetzungsmöglichkeiten", + "translation_systemLanguage": "Sprache des Systems" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index d8d73ab..0617553 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2042,6 +2042,35 @@ "radioStats_stripWaiting": "Fetching radio stats…", "radioStats_settingsTile": "Radio stats", "radioStats_settingsSubtitle": "Noise floor, RSSI, SNR, and airtime", + + "translation_title": "Translation", + "translation_enableTitle": "Enable translation", + "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_targetLanguage": "Target language", + "translation_useAppLanguage": "Use app language", + "translation_downloadedModelLabel": "Downloaded model", + "translation_presetModelLabel": "Preset Hugging Face model", + "translation_manualUrlLabel": "Manual model URL", + "translation_downloadModel": "Download model", + "translation_downloading": "Downloading...", + "translation_working": "Working...", + "translation_stop": "Stop", + "translation_mergingChunks": "Merging downloaded chunks into final file...", + "translation_downloadedModels": "Downloaded models", + "translation_deleteModel": "Delete model", + "translation_modelDownloaded": "Translation model downloaded.", + "translation_downloadStopped": "Download stopped.", + "translation_downloadFailed": "Download failed: {error}", + "@translation_downloadFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "translation_enterUrlFirst": "Enter a model URL first.", "scanner_linuxPairingShowPin": "Show PIN", "scanner_linuxPairingHidePin": "Hide PIN", "scanner_linuxPairingPinTitle": "Bluetooth Pairing PIN", @@ -2052,5 +2081,19 @@ "type": "String" } } - } -} \ No newline at end of file + }, + "translation_messageTranslation": "Message translation", + "translation_translateBeforeSending": "Translate before sending", + "translation_composerEnabledHint": "Messages will be translated before send.", + "translation_composerDisabledHint": "Send messages in the original typed language.", + "translation_translateTo": "Translate to {language}", + "@translation_translateTo": { + "placeholders": { + "language": { + "type": "String" + } + } + }, + "translation_translationOptions": "Translation options", + "translation_systemLanguage": "System language" +} diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 245f732..4d465bb 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2035,6 +2035,34 @@ "radioStats_stripWaiting": "Obteniendo estadísticas de la radio…", "radioStats_settingsTile": "Estadísticas de radio", "radioStats_settingsSubtitle": "Nivel de ruido, RSSI, SNR y tiempo de transmisión", + "@translation_downloadFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "translation_title": "Traducción", + "translation_enableSubtitle": "Traducir los mensajes entrantes y permitir la traducción previa al envío.", + "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_targetLanguage": "Idioma de destino", + "translation_useAppLanguage": "Utilizar el idioma de la aplicación", + "translation_downloadedModelLabel": "Modelo descargado", + "translation_presetModelLabel": "Modelo predefinido de Hugging Face", + "translation_manualUrlLabel": "URL del modelo manual", + "translation_downloadModel": "Descargar el modelo", + "translation_downloading": "Descargando...", + "translation_working": "Trabajando...", + "translation_stop": "¡Detente!", + "translation_mergingChunks": "Combinando los fragmentos descargados en el archivo final...", + "translation_downloadedModels": "Modelos descargados", + "translation_deleteModel": "Eliminar modelo", + "translation_modelDownloaded": "Modelo de traducción descargado.", + "translation_downloadStopped": "La descarga se ha detenido.", + "translation_downloadFailed": "No se pudo descargar: {error}", + "translation_enterUrlFirst": "Primero, introduzca la URL del modelo.", "@scanner_linuxPairingPinPrompt": { "placeholders": { "deviceName": { @@ -2042,8 +2070,22 @@ } } }, - "scanner_linuxPairingShowPin": "Mostrar PIN", - "scanner_linuxPairingPinTitle": "PIN de emparejamiento Bluetooth", + "scanner_linuxPairingPinPrompt": "Introduzca el código PIN para {deviceName} (deje en blanco si no hay ninguno).", + "scanner_linuxPairingShowPin": "Mostrar código PIN", + "scanner_linuxPairingPinTitle": "PIN para emparejar dispositivos Bluetooth", "scanner_linuxPairingHidePin": "Ocultar PIN", - "scanner_linuxPairingPinPrompt": "Introduzca el PIN para {deviceName} (déjelo en blanco si no hay ninguno)." + "@translation_translateTo": { + "placeholders": { + "language": { + "type": "String" + } + } + }, + "translation_composerDisabledHint": "Envía mensajes utilizando el lenguaje escrito original.", + "translation_composerEnabledHint": "Los mensajes serán traducidos antes de ser enviados.", + "translation_messageTranslation": "Traducción del mensaje", + "translation_translateBeforeSending": "Traducir antes de enviar", + "translation_translateTo": "Traducir a {language}", + "translation_translationOptions": "Opciones de traducción", + "translation_systemLanguage": "Idioma del sistema" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index a8b2c8f..16e1d3d 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2007,6 +2007,34 @@ "radioStats_stripWaiting": "Récupération des statistiques de la radio…", "radioStats_settingsTile": "Statistiques de radio", "radioStats_settingsSubtitle": "Niveau de bruit, RSSI, rapport signal/bruit (SNR) et temps d'antenne", + "@translation_downloadFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "translation_composerTitle": "Traduire avant d'envoyer", + "translation_enableTitle": "Activer la traduction", + "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_targetLanguage": "Langue cible", + "translation_useAppLanguage": "Utiliser la langue de l'application", + "translation_downloadedModelLabel": "Modèle téléchargé", + "translation_presetModelLabel": "Modèle Hugging Face préconfiguré", + "translation_manualUrlLabel": "URL du modèle manuel", + "translation_downloadModel": "Télécharger le modèle", + "translation_downloading": "Téléchargement...", + "translation_working": "Au travail...", + "translation_stop": "Arrêtez", + "translation_mergingChunks": "Fusion des fragments téléchargés dans le fichier final...", + "translation_downloadedModels": "Modèles téléchargés", + "translation_deleteModel": "Supprimer le modèle", + "translation_modelDownloaded": "Modèle de traduction téléchargé.", + "translation_downloadStopped": "Le téléchargement a été interrompu.", + "translation_downloadFailed": "Échec du téléchargement : {error}", + "translation_enterUrlFirst": "Entrez d'abord l'URL du modèle.", "@scanner_linuxPairingPinPrompt": { "placeholders": { "deviceName": { @@ -2014,8 +2042,22 @@ } } }, - "scanner_linuxPairingShowPin": "Afficher le code PIN", + "scanner_linuxPairingPinTitle": "Code PIN pour la connexion Bluetooth", "scanner_linuxPairingHidePin": "Masquer le code PIN", - "scanner_linuxPairingPinTitle": "Code PIN d’appairage Bluetooth", - "scanner_linuxPairingPinPrompt": "Entrez le code PIN pour {deviceName} (laissez vide si aucun)." + "scanner_linuxPairingPinPrompt": "Entrez le code PIN pour {deviceName} (laissez vide si nécessaire).", + "scanner_linuxPairingShowPin": "Afficher le code PIN", + "@translation_translateTo": { + "placeholders": { + "language": { + "type": "String" + } + } + }, + "translation_composerEnabledHint": "Les messages seront traduits avant d'être envoyés.", + "translation_translateBeforeSending": "Traduire avant d'envoyer", + "translation_composerDisabledHint": "Envoyez des messages dans la langue originale, telle que vous l'avez tapée.", + "translation_messageTranslation": "Traduction du message", + "translation_translateTo": "Traduire en {language}", + "translation_translationOptions": "Options de traduction", + "translation_systemLanguage": "Langue du système" } diff --git a/lib/l10n/app_hu.arb b/lib/l10n/app_hu.arb index dc96020..cf42e1b 100644 --- a/lib/l10n/app_hu.arb +++ b/lib/l10n/app_hu.arb @@ -2045,6 +2045,34 @@ "contact_teleEnvSubtitle": "Engedje meg az érzékelő adatok megosztását", "map_showOverlaps": "Az ismétlő kulcsok ütköznek", "map_runTraceWithReturnPath": "Visszaforduljon az eredeti úton.", + "@translation_downloadFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "translation_title": "Fordítás", + "translation_enableTitle": "Engedje meg a fordítást", + "translation_enableSubtitle": "Fordítsa az érkező üzeneteket, és lehetővé tegye a küldés előtti fordítást.", + "translation_composerTitle": "Fordítsa el, mielőtt elküldi", + "translation_composerSubtitle": "Ellenőrzi a zeneszerző fordítási ikon alapértékét.", + "translation_targetLanguage": "Célnyelv", + "translation_useAppLanguage": "Használja az alkalmazás nyelvének beállítását.", + "translation_downloadedModelLabel": "Letöltött modell", + "translation_presetModelLabel": "Előre definiált Hugging Face-modell", + "translation_manualUrlLabel": "Manuális modell URL", + "translation_downloadModel": "Letöltés", + "translation_downloading": "Letöltés...", + "translation_working": "Munkában vagyok...", + "translation_stop": "Halt", + "translation_mergingChunks": "A letöltött részek összeállítása a végleges fájlba...", + "translation_downloadedModels": "Letöltött modelok", + "translation_deleteModel": "Törölje a modellt", + "translation_modelDownloaded": "Fordítási modell letöltve.", + "translation_downloadStopped": "A letöltés leállt.", + "translation_downloadFailed": "Letöltés sikertelen: {error}", + "translation_enterUrlFirst": "Addon először egy modell URL-t.", "@scanner_linuxPairingPinPrompt": { "placeholders": { "deviceName": { @@ -2052,8 +2080,22 @@ } } }, - "scanner_linuxPairingHidePin": "PIN elrejtése", - "scanner_linuxPairingShowPin": "PIN megjelenítése", + "scanner_linuxPairingShowPin": "Megjelenítse a PIN-kódot", + "scanner_linuxPairingPinPrompt": "Adja meg a PIN kódot a {deviceName} számára (hagyja üresen, ha nincs).", + "scanner_linuxPairingHidePin": "Rejtse el a PIN-kódot", "scanner_linuxPairingPinTitle": "Bluetooth párosítási PIN", - "scanner_linuxPairingPinPrompt": "Adja meg a(z) {deviceName} PIN-kódját (hagyja üresen, ha nincs)." + "@translation_translateTo": { + "placeholders": { + "language": { + "type": "String" + } + } + }, + "translation_translateBeforeSending": "Fordítsa el, mielőtt elküldi", + "translation_composerEnabledHint": "A üzenetek fordítását a küldés előtt elvégezzük.", + "translation_messageTranslation": "Üzenet fordítása", + "translation_composerDisabledHint": "Küldj üzeneteket az eredeti, nyomtatott nyelven.", + "translation_translateTo": "Fordítás {language}-ra", + "translation_translationOptions": "Fordítási lehetőségek", + "translation_systemLanguage": "Rendszer nyelvé" } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 13a9602..b9676bb 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -2007,6 +2007,34 @@ "radioStats_stripWaiting": "Recupero delle statistiche radio…", "radioStats_settingsTile": "Statistiche radio", "radioStats_settingsSubtitle": "Livello di rumore, RSSI, rapporto segnale/rumore (SNR) e tempo di trasmissione", + "@translation_downloadFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "translation_composerTitle": "Tradurre prima di inviare", + "translation_enableSubtitle": "Tradurre i messaggi in arrivo e consentire la traduzione preventiva prima dell'invio.", + "translation_enableTitle": "Abilitare la traduzione", + "translation_title": "Traduzione", + "translation_composerSubtitle": "Controlla lo stato predefinito dell'icona di traduzione del compositore.", + "translation_targetLanguage": "Lingua di destinazione", + "translation_useAppLanguage": "Utilizza la lingua dell'app", + "translation_downloadedModelLabel": "Modello scaricato", + "translation_presetModelLabel": "Modello predefinito di Hugging Face", + "translation_manualUrlLabel": "URL del modello manuale", + "translation_downloadModel": "Scarica il modello", + "translation_downloading": "Inizio download...", + "translation_working": "Lavoro...", + "translation_stop": "Smetta", + "translation_downloadedModels": "Modelli scaricati", + "translation_mergingChunks": "Unione dei frammenti scaricati in un unico file...", + "translation_deleteModel": "Elimina modello", + "translation_modelDownloaded": "Modello di traduzione scaricato.", + "translation_downloadStopped": "Il download è stato interrotto.", + "translation_downloadFailed": "Download fallito: {error}", + "translation_enterUrlFirst": "Inserite innanzitutto l'URL del modello.", "@scanner_linuxPairingPinPrompt": { "placeholders": { "deviceName": { @@ -2014,8 +2042,22 @@ } } }, + "scanner_linuxPairingPinPrompt": "Inserire il codice PIN per {deviceName} (lasciare vuoto se non presente).", "scanner_linuxPairingShowPin": "Mostra PIN", - "scanner_linuxPairingHidePin": "Nascondi PIN", - "scanner_linuxPairingPinTitle": "PIN di associazione Bluetooth", - "scanner_linuxPairingPinPrompt": "Inserisci il PIN per {deviceName} (lascia vuoto se non ce n'è)." + "scanner_linuxPairingPinTitle": "PIN per l'accoppiamento Bluetooth", + "scanner_linuxPairingHidePin": "Nascondi il PIN", + "@translation_translateTo": { + "placeholders": { + "language": { + "type": "String" + } + } + }, + "translation_messageTranslation": "Traduzione del messaggio", + "translation_translateBeforeSending": "Tradurre prima di inviare", + "translation_composerDisabledHint": "Invia messaggi utilizzando la lingua originale, scritta.", + "translation_composerEnabledHint": "I messaggi verranno tradotti prima di essere inviati.", + "translation_translateTo": "Tradurre in {language}", + "translation_translationOptions": "Opzioni di traduzione", + "translation_systemLanguage": "Lingua del sistema" } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index adb4eea..6a9c975 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -2045,6 +2045,34 @@ "contact_teleEnvSubtitle": "環境センサーのデータを共有することを許可する", "map_showOverlaps": "リピーターキーの重複", "map_runTraceWithReturnPath": "元の経路に戻る。", + "@translation_downloadFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "translation_enableSubtitle": "受信メッセージを翻訳し、送信前に翻訳を適用できるようにする。", + "translation_title": "翻訳", + "translation_composerTitle": "送信する前に翻訳する", + "translation_enableTitle": "翻訳機能を有効にする", + "translation_composerSubtitle": "作曲家翻訳アイコンのデフォルト状態を制御する。", + "translation_targetLanguage": "翻訳対象言語", + "translation_useAppLanguage": "アプリの言語設定", + "translation_downloadedModelLabel": "ダウンロードしたモデル", + "translation_presetModelLabel": "あらかじめ設定されたHugging Faceモデル", + "translation_manualUrlLabel": "マニュアルモデルのURL", + "translation_downloadModel": "モデルのダウンロード", + "translation_downloading": "ダウンロード中...", + "translation_working": "業務中…", + "translation_stop": "停止", + "translation_mergingChunks": "ダウンロードしたファイルを最終ファイルに結合中...", + "translation_downloadedModels": "ダウンロードされたモデル", + "translation_deleteModel": "モデルを削除", + "translation_modelDownloaded": "翻訳モデルのダウンロードが完了しました。", + "translation_downloadStopped": "ダウンロードが中断されました。", + "translation_downloadFailed": "ダウンロードに失敗しました:{error}", + "translation_enterUrlFirst": "まず、モデルのURLを入力してください。", "@scanner_linuxPairingPinPrompt": { "placeholders": { "deviceName": { @@ -2052,8 +2080,22 @@ } } }, - "scanner_linuxPairingShowPin": "PINを表示", - "scanner_linuxPairingHidePin": "PINを非表示", - "scanner_linuxPairingPinTitle": "Bluetooth ペアリング PIN", - "scanner_linuxPairingPinPrompt": "{deviceName}のPINを入力してください(なしの場合は空欄のまま)。" + "scanner_linuxPairingShowPin": "PINを表示する", + "scanner_linuxPairingHidePin": "PINを非表示にする", + "scanner_linuxPairingPinPrompt": "{deviceName} の PIN を入力してください(該当しない場合は空白で入力)。", + "scanner_linuxPairingPinTitle": "Bluetooth 接続のためのPIN", + "@translation_translateTo": { + "placeholders": { + "language": { + "type": "String" + } + } + }, + "translation_translateBeforeSending": "送信する前に翻訳する", + "translation_composerEnabledHint": "メッセージは送信前に翻訳されます。", + "translation_messageTranslation": "メッセージの翻訳", + "translation_composerDisabledHint": "元のタイプされた言語でメッセージを送信してください。", + "translation_translateTo": "{language} への翻訳", + "translation_translationOptions": "翻訳の選択肢", + "translation_systemLanguage": "システム言語" } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 6bccc19..2050e3b 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -2045,6 +2045,34 @@ "contact_teleEnvSubtitle": "환경 센서 데이터를 공유하도록 허용", "map_showOverlaps": "반복 키 중복", "map_runTraceWithReturnPath": "원래 경로로 돌아가세요.", + "@translation_downloadFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "translation_enableSubtitle": "입력 메시지를 번역하고, 미리 번역 기능을 제공합니다.", + "translation_title": "번역", + "translation_enableTitle": "번역 기능 활성화", + "translation_composerTitle": "보내기 전에 번역", + "translation_composerSubtitle": "컴포저 번역 아이콘의 기본 상태를 제어합니다.", + "translation_targetLanguage": "목표 언어", + "translation_useAppLanguage": "앱 언어 사용", + "translation_downloadedModelLabel": "다운로드한 모델", + "translation_presetModelLabel": "사전에 설정된 Hugging Face 모델", + "translation_manualUrlLabel": "수동 모델 URL", + "translation_downloadModel": "모델 다운로드", + "translation_downloading": "다운로드 중...", + "translation_working": "업무 중...", + "translation_stop": "멈춰", + "translation_mergingChunks": "다운로드한 파일 조각들을 최종 파일로 병합 중...", + "translation_downloadedModels": "다운로드한 모델", + "translation_deleteModel": "모델 삭제", + "translation_modelDownloaded": "번역 모델이 다운로드되었습니다.", + "translation_downloadStopped": "다운로드 중단됨.", + "translation_downloadFailed": "다운로드 실패: {error}", + "translation_enterUrlFirst": "먼저 모델 URL을 입력하세요.", "@scanner_linuxPairingPinPrompt": { "placeholders": { "deviceName": { @@ -2052,8 +2080,22 @@ } } }, - "scanner_linuxPairingShowPin": "PIN 표시", "scanner_linuxPairingPinTitle": "블루투스 페어링 PIN", "scanner_linuxPairingHidePin": "PIN 숨기기", - "scanner_linuxPairingPinPrompt": "{deviceName}에 대한 PIN을 입력하세요 (없으면 비워두세요)." + "scanner_linuxPairingShowPin": "PIN 보기", + "scanner_linuxPairingPinPrompt": "{deviceName}의 PIN을 입력하세요 (해당하는 경우에만 입력).", + "@translation_translateTo": { + "placeholders": { + "language": { + "type": "String" + } + } + }, + "translation_composerEnabledHint": "메시지는 전송하기 전에 번역될 것입니다.", + "translation_translateBeforeSending": "보내기 전에 번역", + "translation_messageTranslation": "메시지 번역", + "translation_composerDisabledHint": "원래 작성된 언어로 메시지를 보내세요.", + "translation_translateTo": "{language} 번역", + "translation_translationOptions": "번역 옵션", + "translation_systemLanguage": "시스템 언어" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index db787b3..d2d4040 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -6148,30 +6148,6 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Noise floor, RSSI, SNR, and airtime'** String get radioStats_settingsSubtitle; - - /// No description provided for @scanner_linuxPairingShowPin. - /// - /// In en, this message translates to: - /// **'Show PIN'** - String get scanner_linuxPairingShowPin; - - /// No description provided for @scanner_linuxPairingHidePin. - /// - /// In en, this message translates to: - /// **'Hide PIN'** - String get scanner_linuxPairingHidePin; - - /// No description provided for @scanner_linuxPairingPinTitle. - /// - /// In en, this message translates to: - /// **'Bluetooth Pairing PIN'** - String get scanner_linuxPairingPinTitle; - - /// No description provided for @scanner_linuxPairingPinPrompt. - /// - /// In en, this message translates to: - /// **'Enter PIN for {deviceName} (leave blank if none).'** - String scanner_linuxPairingPinPrompt(String deviceName); } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index 2909278..283860e 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -3568,18 +3568,112 @@ class AppLocalizationsBg extends AppLocalizations { String get radioStats_settingsSubtitle => 'Ниво на шума, RSSI, SNR и време на пренос'; + @override + String get translation_title => 'Превод'; + + @override + String get translation_enableTitle => 'Активирайте превода'; + + @override + String get translation_enableSubtitle => + 'Превеждайте входящите съобщения и позволявайте предварително превеждане преди изпращане.'; + + @override + String get translation_composerTitle => 'Преведете преди да изпратите'; + + @override + String get translation_composerSubtitle => + 'Контролира началния статус на иконата за превод, създадена от композитора.'; + + @override + String get translation_targetLanguage => 'Целеви език'; + + @override + String get translation_useAppLanguage => 'Използвайте езика на приложението'; + + @override + String get translation_downloadedModelLabel => 'Изтегнат модел'; + + @override + String get translation_presetModelLabel => + 'Предварително конфигуриран модел от Hugging Face'; + + @override + String get translation_manualUrlLabel => 'URL на ръководството'; + + @override + String get translation_downloadModel => 'Изтеглете модела'; + + @override + String get translation_downloading => 'Изтегляне...'; + + @override + String get translation_working => 'Работа...'; + + @override + String get translation_stop => 'Спрете'; + + @override + String get translation_mergingChunks => + 'Съединяване на изтеглените части в един файл...'; + + @override + String get translation_downloadedModels => 'Изтеглени модели'; + + @override + String get translation_deleteModel => 'Изтриване на модела'; + + @override + String get translation_modelDownloaded => 'Моделът за превод е изтеглен.'; + + @override + String get translation_downloadStopped => 'Изтеглянето беше прекъснато.'; + + @override + String translation_downloadFailed(String error) { + return 'Не успях да изтегля: $error'; + } + + @override + String get translation_enterUrlFirst => 'Въведете първо URL адрес на модела.'; + @override String get scanner_linuxPairingShowPin => 'Покажи PIN'; @override - String get scanner_linuxPairingHidePin => 'Скрий ПИН'; + String get scanner_linuxPairingHidePin => 'Скриване на PIN кода'; @override - String get scanner_linuxPairingPinTitle => - 'PIN код за сдвояване на Bluetooth'; + String get scanner_linuxPairingPinTitle => 'PIN за съвпадение чрез Bluetooth'; @override String scanner_linuxPairingPinPrompt(String deviceName) { - return 'Въведете ПИН за $deviceName (оставете празно, ако няма).'; + return 'Въведете PIN кода за $deviceName (оставете празно, ако няма такъв).'; } + + @override + String get translation_messageTranslation => 'Превод на съобщението'; + + @override + String get translation_translateBeforeSending => + 'Преведете преди да изпратите'; + + @override + String get translation_composerEnabledHint => + 'Съобщенията ще бъдат преведени, преди да бъдат изпратени.'; + + @override + String get translation_composerDisabledHint => + 'Изпращайте съобщения на оригиналния въведен език.'; + + @override + String translation_translateTo(String language) { + return 'Превеждане на $language'; + } + + @override + String get translation_translationOptions => 'Опции за превод'; + + @override + String get translation_systemLanguage => 'Език на системата'; } diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 4afefde..e29ae9e 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -3577,17 +3577,114 @@ class AppLocalizationsDe extends AppLocalizations { String get radioStats_settingsSubtitle => 'Rauschpegel, RSSI, Signal-Rausch-Verhältnis (SNR) und Nutzzeit'; + @override + String get translation_title => 'Übersetzung'; + + @override + String get translation_enableTitle => 'Aktivieren Sie die Übersetzung'; + + @override + String get translation_enableSubtitle => + 'Nachrichten empfangen und übersetzen sowie die Möglichkeit bieten, Nachrichten vor dem Versenden zu übersetzen.'; + + @override + String get translation_composerTitle => 'Übersetzen Sie vor dem Versenden'; + + @override + String get translation_composerSubtitle => + 'Steuert den Standardzustand des Icons für die Übersetzung des Komponisten.'; + + @override + String get translation_targetLanguage => 'Zielsprache'; + + @override + String get translation_useAppLanguage => 'Verwenden Sie die App-Sprache'; + + @override + String get translation_downloadedModelLabel => 'Heruntergeladenes Modell'; + + @override + String get translation_presetModelLabel => + 'Vordefinierter Hugging Face-Modell'; + + @override + String get translation_manualUrlLabel => 'URL für das manuelle Modell'; + + @override + String get translation_downloadModel => 'Modell herunterladen'; + + @override + String get translation_downloading => 'Herunterladen...'; + + @override + String get translation_working => 'Arbeiten...'; + + @override + String get translation_stop => 'Stopp'; + + @override + String get translation_mergingChunks => + 'Zusammenführen der heruntergeladenen Teile in die finale Datei...'; + + @override + String get translation_downloadedModels => 'Heruntergeladene Modelle'; + + @override + String get translation_deleteModel => 'Modell löschen'; + + @override + String get translation_modelDownloaded => + 'Übersetzungsmotor heruntergeladen.'; + + @override + String get translation_downloadStopped => 'Herunterladen abgebrochen.'; + + @override + String translation_downloadFailed(String error) { + return 'Download fehlgeschlagen: $error'; + } + + @override + String get translation_enterUrlFirst => + 'Geben Sie zunächst die URL eines Modells ein.'; + @override String get scanner_linuxPairingShowPin => 'PIN anzeigen'; @override - String get scanner_linuxPairingHidePin => 'PIN ausblenden'; + String get scanner_linuxPairingHidePin => 'PIN verbergen'; @override - String get scanner_linuxPairingPinTitle => 'Bluetooth-Paarungs-PIN'; + String get scanner_linuxPairingPinTitle => 'PIN für die Bluetooth-Verbindung'; @override String scanner_linuxPairingPinPrompt(String deviceName) { - return 'Geben Sie die PIN für $deviceName ein (leer lassen, falls keine).'; + return 'Geben Sie den PIN-Code für $deviceName ein (lassen Sie das Feld leer, falls kein PIN-Code vorhanden ist).'; } + + @override + String get translation_messageTranslation => 'Nachricht übersetzen'; + + @override + String get translation_translateBeforeSending => + 'Übersetzen Sie vor dem Versenden'; + + @override + String get translation_composerEnabledHint => + 'Die Nachrichten werden vor dem Versenden übersetzt.'; + + @override + String get translation_composerDisabledHint => + 'Nachrichten in der ursprünglichen, getippten Sprache senden.'; + + @override + String translation_translateTo(String language) { + return 'Übersetzen Sie auf $language'; + } + + @override + String get translation_translationOptions => 'Übersetzungsmöglichkeiten'; + + @override + String get translation_systemLanguage => 'Sprache des Systems'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index a420a55..877e11d 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -3502,6 +3502,74 @@ class AppLocalizationsEn extends AppLocalizations { String get radioStats_settingsSubtitle => 'Noise floor, RSSI, SNR, and airtime'; + @override + String get translation_title => 'Translation'; + + @override + String get translation_enableTitle => 'Enable translation'; + + @override + String get translation_enableSubtitle => + 'Translate incoming messages and allow pre-send translation.'; + + @override + String get translation_composerTitle => 'Translate before sending'; + + @override + String get translation_composerSubtitle => + 'Controls the default state of the composer translation icon.'; + + @override + String get translation_targetLanguage => 'Target language'; + + @override + String get translation_useAppLanguage => 'Use app language'; + + @override + String get translation_downloadedModelLabel => 'Downloaded model'; + + @override + String get translation_presetModelLabel => 'Preset Hugging Face model'; + + @override + String get translation_manualUrlLabel => 'Manual model URL'; + + @override + String get translation_downloadModel => 'Download model'; + + @override + String get translation_downloading => 'Downloading...'; + + @override + String get translation_working => 'Working...'; + + @override + String get translation_stop => 'Stop'; + + @override + String get translation_mergingChunks => + 'Merging downloaded chunks into final file...'; + + @override + String get translation_downloadedModels => 'Downloaded models'; + + @override + String get translation_deleteModel => 'Delete model'; + + @override + String get translation_modelDownloaded => 'Translation model downloaded.'; + + @override + String get translation_downloadStopped => 'Download stopped.'; + + @override + String translation_downloadFailed(String error) { + return 'Download failed: $error'; + } + + @override + String get translation_enterUrlFirst => 'Enter a model URL first.'; + @override String get scanner_linuxPairingShowPin => 'Show PIN'; @@ -3515,4 +3583,29 @@ class AppLocalizationsEn extends AppLocalizations { String scanner_linuxPairingPinPrompt(String deviceName) { return 'Enter PIN for $deviceName (leave blank if none).'; } + + @override + String get translation_messageTranslation => 'Message translation'; + + @override + String get translation_translateBeforeSending => 'Translate before sending'; + + @override + String get translation_composerEnabledHint => + 'Messages will be translated before send.'; + + @override + String get translation_composerDisabledHint => + 'Send messages in the original typed language.'; + + @override + String translation_translateTo(String language) { + return 'Translate to $language'; + } + + @override + String get translation_translationOptions => 'Translation options'; + + @override + String get translation_systemLanguage => 'System language'; } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 93a8bc9..c963902 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -3571,16 +3571,113 @@ class AppLocalizationsEs extends AppLocalizations { 'Nivel de ruido, RSSI, SNR y tiempo de transmisión'; @override - String get scanner_linuxPairingShowPin => 'Mostrar PIN'; + String get translation_title => 'Traducción'; + + @override + String get translation_enableTitle => 'Habilitar la traducción'; + + @override + String get translation_enableSubtitle => + 'Traducir los mensajes entrantes y permitir la traducción previa al envío.'; + + @override + String get translation_composerTitle => 'Traducir antes de enviar'; + + @override + String get translation_composerSubtitle => + 'Controla el estado predeterminado del icono de traducción del compositor.'; + + @override + String get translation_targetLanguage => 'Idioma de destino'; + + @override + String get translation_useAppLanguage => + 'Utilizar el idioma de la aplicación'; + + @override + String get translation_downloadedModelLabel => 'Modelo descargado'; + + @override + String get translation_presetModelLabel => + 'Modelo predefinido de Hugging Face'; + + @override + String get translation_manualUrlLabel => 'URL del modelo manual'; + + @override + String get translation_downloadModel => 'Descargar el modelo'; + + @override + String get translation_downloading => 'Descargando...'; + + @override + String get translation_working => 'Trabajando...'; + + @override + String get translation_stop => '¡Detente!'; + + @override + String get translation_mergingChunks => + 'Combinando los fragmentos descargados en el archivo final...'; + + @override + String get translation_downloadedModels => 'Modelos descargados'; + + @override + String get translation_deleteModel => 'Eliminar modelo'; + + @override + String get translation_modelDownloaded => 'Modelo de traducción descargado.'; + + @override + String get translation_downloadStopped => 'La descarga se ha detenido.'; + + @override + String translation_downloadFailed(String error) { + return 'No se pudo descargar: $error'; + } + + @override + String get translation_enterUrlFirst => + 'Primero, introduzca la URL del modelo.'; + + @override + String get scanner_linuxPairingShowPin => 'Mostrar código PIN'; @override String get scanner_linuxPairingHidePin => 'Ocultar PIN'; @override - String get scanner_linuxPairingPinTitle => 'PIN de emparejamiento Bluetooth'; + String get scanner_linuxPairingPinTitle => + 'PIN para emparejar dispositivos Bluetooth'; @override String scanner_linuxPairingPinPrompt(String deviceName) { - return 'Introduzca el PIN para $deviceName (déjelo en blanco si no hay ninguno).'; + return 'Introduzca el código PIN para $deviceName (deje en blanco si no hay ninguno).'; } + + @override + String get translation_messageTranslation => 'Traducción del mensaje'; + + @override + String get translation_translateBeforeSending => 'Traducir antes de enviar'; + + @override + String get translation_composerEnabledHint => + 'Los mensajes serán traducidos antes de ser enviados.'; + + @override + String get translation_composerDisabledHint => + 'Envía mensajes utilizando el lenguaje escrito original.'; + + @override + String translation_translateTo(String language) { + return 'Traducir a $language'; + } + + @override + String get translation_translationOptions => 'Opciones de traducción'; + + @override + String get translation_systemLanguage => 'Idioma del sistema'; } diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 5d0d90c..eea88f5 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -3595,6 +3595,76 @@ class AppLocalizationsFr extends AppLocalizations { String get radioStats_settingsSubtitle => 'Niveau de bruit, RSSI, rapport signal/bruit (SNR) et temps d\'antenne'; + @override + String get translation_title => 'Traduction'; + + @override + String get translation_enableTitle => 'Activer la traduction'; + + @override + String get translation_enableSubtitle => + 'Traduire les messages entrants et permettre la traduction avant l\'envoi.'; + + @override + String get translation_composerTitle => 'Traduire avant d\'envoyer'; + + @override + String get translation_composerSubtitle => + 'Contrôle l\'état par défaut de l\'icône de traduction du composant.'; + + @override + String get translation_targetLanguage => 'Langue cible'; + + @override + String get translation_useAppLanguage => + 'Utiliser la langue de l\'application'; + + @override + String get translation_downloadedModelLabel => 'Modèle téléchargé'; + + @override + String get translation_presetModelLabel => 'Modèle Hugging Face préconfiguré'; + + @override + String get translation_manualUrlLabel => 'URL du modèle manuel'; + + @override + String get translation_downloadModel => 'Télécharger le modèle'; + + @override + String get translation_downloading => 'Téléchargement...'; + + @override + String get translation_working => 'Au travail...'; + + @override + String get translation_stop => 'Arrêtez'; + + @override + String get translation_mergingChunks => + 'Fusion des fragments téléchargés dans le fichier final...'; + + @override + String get translation_downloadedModels => 'Modèles téléchargés'; + + @override + String get translation_deleteModel => 'Supprimer le modèle'; + + @override + String get translation_modelDownloaded => 'Modèle de traduction téléchargé.'; + + @override + String get translation_downloadStopped => + 'Le téléchargement a été interrompu.'; + + @override + String translation_downloadFailed(String error) { + return 'Échec du téléchargement : $error'; + } + + @override + String get translation_enterUrlFirst => 'Entrez d\'abord l\'URL du modèle.'; + @override String get scanner_linuxPairingShowPin => 'Afficher le code PIN'; @@ -3602,10 +3672,36 @@ class AppLocalizationsFr extends AppLocalizations { String get scanner_linuxPairingHidePin => 'Masquer le code PIN'; @override - String get scanner_linuxPairingPinTitle => 'Code PIN d’appairage Bluetooth'; + String get scanner_linuxPairingPinTitle => + 'Code PIN pour la connexion Bluetooth'; @override String scanner_linuxPairingPinPrompt(String deviceName) { - return 'Entrez le code PIN pour $deviceName (laissez vide si aucun).'; + return 'Entrez le code PIN pour $deviceName (laissez vide si nécessaire).'; } + + @override + String get translation_messageTranslation => 'Traduction du message'; + + @override + String get translation_translateBeforeSending => 'Traduire avant d\'envoyer'; + + @override + String get translation_composerEnabledHint => + 'Les messages seront traduits avant d\'être envoyés.'; + + @override + String get translation_composerDisabledHint => + 'Envoyez des messages dans la langue originale, telle que vous l\'avez tapée.'; + + @override + String translation_translateTo(String language) { + return 'Traduire en $language'; + } + + @override + String get translation_translationOptions => 'Options de traduction'; + + @override + String get translation_systemLanguage => 'Langue du système'; } diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index dc6374a..5e36e94 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -3588,16 +3588,112 @@ class AppLocalizationsHu extends AppLocalizations { 'Háttérzaj, RSSI, zaj-sűrűség, és a használat időtartama'; @override - String get scanner_linuxPairingShowPin => 'PIN megjelenítése'; + String get translation_title => 'Fordítás'; @override - String get scanner_linuxPairingHidePin => 'PIN elrejtése'; + String get translation_enableTitle => 'Engedje meg a fordítást'; + + @override + String get translation_enableSubtitle => + 'Fordítsa az érkező üzeneteket, és lehetővé tegye a küldés előtti fordítást.'; + + @override + String get translation_composerTitle => 'Fordítsa el, mielőtt elküldi'; + + @override + String get translation_composerSubtitle => + 'Ellenőrzi a zeneszerző fordítási ikon alapértékét.'; + + @override + String get translation_targetLanguage => 'Célnyelv'; + + @override + String get translation_useAppLanguage => + 'Használja az alkalmazás nyelvének beállítását.'; + + @override + String get translation_downloadedModelLabel => 'Letöltött modell'; + + @override + String get translation_presetModelLabel => + 'Előre definiált Hugging Face-modell'; + + @override + String get translation_manualUrlLabel => 'Manuális modell URL'; + + @override + String get translation_downloadModel => 'Letöltés'; + + @override + String get translation_downloading => 'Letöltés...'; + + @override + String get translation_working => 'Munkában vagyok...'; + + @override + String get translation_stop => 'Halt'; + + @override + String get translation_mergingChunks => + 'A letöltött részek összeállítása a végleges fájlba...'; + + @override + String get translation_downloadedModels => 'Letöltött modelok'; + + @override + String get translation_deleteModel => 'Törölje a modellt'; + + @override + String get translation_modelDownloaded => 'Fordítási modell letöltve.'; + + @override + String get translation_downloadStopped => 'A letöltés leállt.'; + + @override + String translation_downloadFailed(String error) { + return 'Letöltés sikertelen: $error'; + } + + @override + String get translation_enterUrlFirst => 'Addon először egy modell URL-t.'; + + @override + String get scanner_linuxPairingShowPin => 'Megjelenítse a PIN-kódot'; + + @override + String get scanner_linuxPairingHidePin => 'Rejtse el a PIN-kódot'; @override String get scanner_linuxPairingPinTitle => 'Bluetooth párosítási PIN'; @override String scanner_linuxPairingPinPrompt(String deviceName) { - return 'Adja meg a(z) $deviceName PIN-kódját (hagyja üresen, ha nincs).'; + return 'Adja meg a PIN kódot a $deviceName számára (hagyja üresen, ha nincs).'; } + + @override + String get translation_messageTranslation => 'Üzenet fordítása'; + + @override + String get translation_translateBeforeSending => + 'Fordítsa el, mielőtt elküldi'; + + @override + String get translation_composerEnabledHint => + 'A üzenetek fordítását a küldés előtt elvégezzük.'; + + @override + String get translation_composerDisabledHint => + 'Küldj üzeneteket az eredeti, nyomtatott nyelven.'; + + @override + String translation_translateTo(String language) { + return 'Fordítás $language-ra'; + } + + @override + String get translation_translationOptions => 'Fordítási lehetőségek'; + + @override + String get translation_systemLanguage => 'Rendszer nyelvé'; } diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 3fc5e56..bb9e0d2 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -3574,17 +3574,113 @@ class AppLocalizationsIt extends AppLocalizations { String get radioStats_settingsSubtitle => 'Livello di rumore, RSSI, rapporto segnale/rumore (SNR) e tempo di trasmissione'; + @override + String get translation_title => 'Traduzione'; + + @override + String get translation_enableTitle => 'Abilitare la traduzione'; + + @override + String get translation_enableSubtitle => + 'Tradurre i messaggi in arrivo e consentire la traduzione preventiva prima dell\'invio.'; + + @override + String get translation_composerTitle => 'Tradurre prima di inviare'; + + @override + String get translation_composerSubtitle => + 'Controlla lo stato predefinito dell\'icona di traduzione del compositore.'; + + @override + String get translation_targetLanguage => 'Lingua di destinazione'; + + @override + String get translation_useAppLanguage => 'Utilizza la lingua dell\'app'; + + @override + String get translation_downloadedModelLabel => 'Modello scaricato'; + + @override + String get translation_presetModelLabel => + 'Modello predefinito di Hugging Face'; + + @override + String get translation_manualUrlLabel => 'URL del modello manuale'; + + @override + String get translation_downloadModel => 'Scarica il modello'; + + @override + String get translation_downloading => 'Inizio download...'; + + @override + String get translation_working => 'Lavoro...'; + + @override + String get translation_stop => 'Smetta'; + + @override + String get translation_mergingChunks => + 'Unione dei frammenti scaricati in un unico file...'; + + @override + String get translation_downloadedModels => 'Modelli scaricati'; + + @override + String get translation_deleteModel => 'Elimina modello'; + + @override + String get translation_modelDownloaded => 'Modello di traduzione scaricato.'; + + @override + String get translation_downloadStopped => 'Il download è stato interrotto.'; + + @override + String translation_downloadFailed(String error) { + return 'Download fallito: $error'; + } + + @override + String get translation_enterUrlFirst => + 'Inserite innanzitutto l\'URL del modello.'; + @override String get scanner_linuxPairingShowPin => 'Mostra PIN'; @override - String get scanner_linuxPairingHidePin => 'Nascondi PIN'; + String get scanner_linuxPairingHidePin => 'Nascondi il PIN'; @override - String get scanner_linuxPairingPinTitle => 'PIN di associazione Bluetooth'; + String get scanner_linuxPairingPinTitle => + 'PIN per l\'accoppiamento Bluetooth'; @override String scanner_linuxPairingPinPrompt(String deviceName) { - return 'Inserisci il PIN per $deviceName (lascia vuoto se non ce n\'è).'; + return 'Inserire il codice PIN per $deviceName (lasciare vuoto se non presente).'; } + + @override + String get translation_messageTranslation => 'Traduzione del messaggio'; + + @override + String get translation_translateBeforeSending => 'Tradurre prima di inviare'; + + @override + String get translation_composerEnabledHint => + 'I messaggi verranno tradotti prima di essere inviati.'; + + @override + String get translation_composerDisabledHint => + 'Invia messaggi utilizzando la lingua originale, scritta.'; + + @override + String translation_translateTo(String language) { + return 'Tradurre in $language'; + } + + @override + String get translation_translationOptions => 'Opzioni di traduzione'; + + @override + String get translation_systemLanguage => 'Lingua del sistema'; } diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 03d70d4..5151ab8 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -3403,16 +3403,104 @@ class AppLocalizationsJa extends AppLocalizations { String get radioStats_settingsSubtitle => 'ノイズレベル、RSSI、SNR、および通信時間'; @override - String get scanner_linuxPairingShowPin => 'PINを表示'; + String get translation_title => '翻訳'; @override - String get scanner_linuxPairingHidePin => 'PINを非表示'; + String get translation_enableTitle => '翻訳機能を有効にする'; @override - String get scanner_linuxPairingPinTitle => 'Bluetooth ペアリング PIN'; + String get translation_enableSubtitle => '受信メッセージを翻訳し、送信前に翻訳を適用できるようにする。'; + + @override + String get translation_composerTitle => '送信する前に翻訳する'; + + @override + String get translation_composerSubtitle => '作曲家翻訳アイコンのデフォルト状態を制御する。'; + + @override + String get translation_targetLanguage => '翻訳対象言語'; + + @override + String get translation_useAppLanguage => 'アプリの言語設定'; + + @override + String get translation_downloadedModelLabel => 'ダウンロードしたモデル'; + + @override + String get translation_presetModelLabel => 'あらかじめ設定されたHugging Faceモデル'; + + @override + String get translation_manualUrlLabel => 'マニュアルモデルのURL'; + + @override + String get translation_downloadModel => 'モデルのダウンロード'; + + @override + String get translation_downloading => 'ダウンロード中...'; + + @override + String get translation_working => '業務中…'; + + @override + String get translation_stop => '停止'; + + @override + String get translation_mergingChunks => 'ダウンロードしたファイルを最終ファイルに結合中...'; + + @override + String get translation_downloadedModels => 'ダウンロードされたモデル'; + + @override + String get translation_deleteModel => 'モデルを削除'; + + @override + String get translation_modelDownloaded => '翻訳モデルのダウンロードが完了しました。'; + + @override + String get translation_downloadStopped => 'ダウンロードが中断されました。'; + + @override + String translation_downloadFailed(String error) { + return 'ダウンロードに失敗しました:$error'; + } + + @override + String get translation_enterUrlFirst => 'まず、モデルのURLを入力してください。'; + + @override + String get scanner_linuxPairingShowPin => 'PINを表示する'; + + @override + String get scanner_linuxPairingHidePin => 'PINを非表示にする'; + + @override + String get scanner_linuxPairingPinTitle => 'Bluetooth 接続のためのPIN'; @override String scanner_linuxPairingPinPrompt(String deviceName) { - return '$deviceNameのPINを入力してください(なしの場合は空欄のまま)。'; + return '$deviceName の PIN を入力してください(該当しない場合は空白で入力)。'; } + + @override + String get translation_messageTranslation => 'メッセージの翻訳'; + + @override + String get translation_translateBeforeSending => '送信する前に翻訳する'; + + @override + String get translation_composerEnabledHint => 'メッセージは送信前に翻訳されます。'; + + @override + String get translation_composerDisabledHint => '元のタイプされた言語でメッセージを送信してください。'; + + @override + String translation_translateTo(String language) { + return '$language への翻訳'; + } + + @override + String get translation_translationOptions => '翻訳の選択肢'; + + @override + String get translation_systemLanguage => 'システム言語'; } diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 5e5925f..be64545 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -3402,7 +3402,72 @@ class AppLocalizationsKo extends AppLocalizations { String get radioStats_settingsSubtitle => '잡음 수준, RSSI, 신호 대 잡음비, 통신 시간'; @override - String get scanner_linuxPairingShowPin => 'PIN 표시'; + String get translation_title => '번역'; + + @override + String get translation_enableTitle => '번역 기능 활성화'; + + @override + String get translation_enableSubtitle => '입력 메시지를 번역하고, 미리 번역 기능을 제공합니다.'; + + @override + String get translation_composerTitle => '보내기 전에 번역'; + + @override + String get translation_composerSubtitle => '컴포저 번역 아이콘의 기본 상태를 제어합니다.'; + + @override + String get translation_targetLanguage => '목표 언어'; + + @override + String get translation_useAppLanguage => '앱 언어 사용'; + + @override + String get translation_downloadedModelLabel => '다운로드한 모델'; + + @override + String get translation_presetModelLabel => '사전에 설정된 Hugging Face 모델'; + + @override + String get translation_manualUrlLabel => '수동 모델 URL'; + + @override + String get translation_downloadModel => '모델 다운로드'; + + @override + String get translation_downloading => '다운로드 중...'; + + @override + String get translation_working => '업무 중...'; + + @override + String get translation_stop => '멈춰'; + + @override + String get translation_mergingChunks => '다운로드한 파일 조각들을 최종 파일로 병합 중...'; + + @override + String get translation_downloadedModels => '다운로드한 모델'; + + @override + String get translation_deleteModel => '모델 삭제'; + + @override + String get translation_modelDownloaded => '번역 모델이 다운로드되었습니다.'; + + @override + String get translation_downloadStopped => '다운로드 중단됨.'; + + @override + String translation_downloadFailed(String error) { + return '다운로드 실패: $error'; + } + + @override + String get translation_enterUrlFirst => '먼저 모델 URL을 입력하세요.'; + + @override + String get scanner_linuxPairingShowPin => 'PIN 보기'; @override String get scanner_linuxPairingHidePin => 'PIN 숨기기'; @@ -3412,6 +3477,29 @@ class AppLocalizationsKo extends AppLocalizations { @override String scanner_linuxPairingPinPrompt(String deviceName) { - return '$deviceName에 대한 PIN을 입력하세요 (없으면 비워두세요).'; + return '$deviceName의 PIN을 입력하세요 (해당하는 경우에만 입력).'; } + + @override + String get translation_messageTranslation => '메시지 번역'; + + @override + String get translation_translateBeforeSending => '보내기 전에 번역'; + + @override + String get translation_composerEnabledHint => '메시지는 전송하기 전에 번역될 것입니다.'; + + @override + String get translation_composerDisabledHint => '원래 작성된 언어로 메시지를 보내세요.'; + + @override + String translation_translateTo(String language) { + return '$language 번역'; + } + + @override + String get translation_translationOptions => '번역 옵션'; + + @override + String get translation_systemLanguage => '시스템 언어'; } diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 2317dd2..86809df 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -3552,17 +3552,112 @@ class AppLocalizationsNl extends AppLocalizations { String get radioStats_settingsSubtitle => 'Ruimtelijke ruis, RSSI, SNR en beschikbare tijd'; + @override + String get translation_title => 'Vertaling'; + + @override + String get translation_enableTitle => 'Activeer vertaling'; + + @override + String get translation_enableSubtitle => + 'Vertaal inkomende berichten en maak het mogelijk om berichten vooraf te vertalen.'; + + @override + String get translation_composerTitle => 'Vertaal voor verzending'; + + @override + String get translation_composerSubtitle => + 'Stelt de standaardstatus van het pictogram voor de vertaling van de componist in.'; + + @override + String get translation_targetLanguage => 'Doeltaal'; + + @override + String get translation_useAppLanguage => 'Gebruik de taal van de app'; + + @override + String get translation_downloadedModelLabel => 'Gedownloade model'; + + @override + String get translation_presetModelLabel => + 'Voorgeprogrammeerd Hugging Face-model'; + + @override + String get translation_manualUrlLabel => 'URL van de handleiding'; + + @override + String get translation_downloadModel => 'Download het model'; + + @override + String get translation_downloading => 'Downloaden...'; + + @override + String get translation_working => 'Werken...'; + + @override + String get translation_stop => 'Stoppen'; + + @override + String get translation_mergingChunks => + 'Het samenvoegen van de gedownloade stukken tot één eindbestand...'; + + @override + String get translation_downloadedModels => 'Gedownloade modellen'; + + @override + String get translation_deleteModel => 'Model verwijderen'; + + @override + String get translation_modelDownloaded => 'Vertalingmodel gedownload.'; + + @override + String get translation_downloadStopped => 'Download is afgebroken.'; + + @override + String translation_downloadFailed(String error) { + return 'Download mislukt: $error'; + } + + @override + String get translation_enterUrlFirst => + 'Voer eerst een URL van een model in.'; + @override String get scanner_linuxPairingShowPin => 'Toon PIN'; @override - String get scanner_linuxPairingHidePin => 'PIN verbergen'; + String get scanner_linuxPairingHidePin => 'Verberg PIN'; @override - String get scanner_linuxPairingPinTitle => 'Bluetooth‑koppelings‑PIN'; + String get scanner_linuxPairingPinTitle => 'PIN voor Bluetooth-koppeling'; @override String scanner_linuxPairingPinPrompt(String deviceName) { - return 'Voer PIN in voor $deviceName (laat leeg als er geen is).'; + return 'Voer het pincode-in voor $deviceName in (laat dit leeg als er geen is).'; } + + @override + String get translation_messageTranslation => 'Berichtvertaling'; + + @override + String get translation_translateBeforeSending => 'Vertaal voor verzending'; + + @override + String get translation_composerEnabledHint => + 'De berichten worden vertaald voordat ze verzonden worden.'; + + @override + String get translation_composerDisabledHint => + 'Stuur berichten in de oorspronkelijke, getypte taal.'; + + @override + String translation_translateTo(String language) { + return 'Vertalen naar $language'; + } + + @override + String get translation_translationOptions => 'Opties voor vertaling'; + + @override + String get translation_systemLanguage => 'Taal van het systeem'; } diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index cc5e2a2..8952815 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -3586,16 +3586,110 @@ class AppLocalizationsPl extends AppLocalizations { 'Szum tła, RSSI, SNR oraz czas dostępny'; @override - String get scanner_linuxPairingShowPin => 'Pokaż PIN'; + String get translation_title => 'Tłumaczenie'; @override - String get scanner_linuxPairingHidePin => 'Ukryj PIN'; + String get translation_enableTitle => 'Włącz tłumaczenie'; @override - String get scanner_linuxPairingPinTitle => 'Kod PIN parowania Bluetooth'; + String get translation_enableSubtitle => + 'Tłumaczenie otrzymywanych wiadomości oraz umożliwienie tłumaczenia przed wysłaniem.'; + + @override + String get translation_composerTitle => 'Przekład przed wysłaniem'; + + @override + String get translation_composerSubtitle => + 'Kontroluje domyślny stan ikony tłumaczenia w edytorze.'; + + @override + String get translation_targetLanguage => 'Język docelowy'; + + @override + String get translation_useAppLanguage => 'Użyj języka aplikacji'; + + @override + String get translation_downloadedModelLabel => 'Pobudowany model'; + + @override + String get translation_presetModelLabel => 'Wspólny model Hugging Face'; + + @override + String get translation_manualUrlLabel => 'Adres URL do wersji manualnej'; + + @override + String get translation_downloadModel => 'Pobierz model'; + + @override + String get translation_downloading => 'Pobieranie...'; + + @override + String get translation_working => 'Praca...'; + + @override + String get translation_stop => 'Zatrzymaj się'; + + @override + String get translation_mergingChunks => + 'Scalanie pobranych fragmentów w jeden plik końcowy...'; + + @override + String get translation_downloadedModels => 'Pobrane modele'; + + @override + String get translation_deleteModel => 'Usuń model'; + + @override + String get translation_modelDownloaded => 'Model tłumaczenia został pobrany.'; + + @override + String get translation_downloadStopped => 'Pobieranie zakończone.'; + + @override + String translation_downloadFailed(String error) { + return 'Nie udało się pobrać: $error'; + } + + @override + String get translation_enterUrlFirst => 'Najpierw wprowadź adres URL modelu.'; + + @override + String get scanner_linuxPairingShowPin => 'Wyświetl kod PIN'; + + @override + String get scanner_linuxPairingHidePin => 'Ukryj kod PIN'; + + @override + String get scanner_linuxPairingPinTitle => + 'PIN do sparowania przez Bluetooth'; @override String scanner_linuxPairingPinPrompt(String deviceName) { - return 'Wprowadź kod PIN dla $deviceName (pozostaw puste, jeśli brak).'; + return 'Wprowadź kod PIN dla $deviceName (pust, jeśli nie jest wymagany).'; } + + @override + String get translation_messageTranslation => 'Tłumaczenie wiadomości'; + + @override + String get translation_translateBeforeSending => 'Przekład przed wysłaniem'; + + @override + String get translation_composerEnabledHint => + 'Komunikaty zostaną przetłumaczone przed wysłaniem.'; + + @override + String get translation_composerDisabledHint => + 'Wysyłaj wiadomości w oryginalnym, wpisanym formacie.'; + + @override + String translation_translateTo(String language) { + return 'Tłumacz na $language'; + } + + @override + String get translation_translationOptions => 'Opcje tłumaczenia'; + + @override + String get translation_systemLanguage => 'Język systemu'; } diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 98c72f5..43dc27a 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -3567,17 +3567,111 @@ class AppLocalizationsPt extends AppLocalizations { String get radioStats_settingsSubtitle => 'Nível de ruído, RSSI, SNR e tempo de transmissão'; + @override + String get translation_title => 'Tradução'; + + @override + String get translation_enableTitle => 'Ativar a tradução'; + + @override + String get translation_enableSubtitle => + 'Traduzir mensagens recebidas e permitir a tradução antes do envio.'; + + @override + String get translation_composerTitle => 'Traduza antes de enviar'; + + @override + String get translation_composerSubtitle => + 'Controla o estado padrão do ícone de tradução do compositor.'; + + @override + String get translation_targetLanguage => 'Língua-alvo'; + + @override + String get translation_useAppLanguage => 'Utilize o idioma da aplicação'; + + @override + String get translation_downloadedModelLabel => 'Modelo baixado'; + + @override + String get translation_presetModelLabel => + 'Modelo pré-definido da Hugging Face'; + + @override + String get translation_manualUrlLabel => 'URL do modelo manual'; + + @override + String get translation_downloadModel => 'Baixar modelo'; + + @override + String get translation_downloading => 'Baixando...'; + + @override + String get translation_working => 'Trabalhando...'; + + @override + String get translation_stop => 'Pare'; + + @override + String get translation_mergingChunks => + 'Combinando os fragmentos baixados em um único arquivo...'; + + @override + String get translation_downloadedModels => 'Modelos baixados'; + + @override + String get translation_deleteModel => 'Excluir modelo'; + + @override + String get translation_modelDownloaded => 'Modelo de tradução baixado.'; + + @override + String get translation_downloadStopped => 'Download interrompido.'; + + @override + String translation_downloadFailed(String error) { + return 'Falha na descarga: $error'; + } + + @override + String get translation_enterUrlFirst => 'Insira primeiro a URL do modelo.'; + @override String get scanner_linuxPairingShowPin => 'Mostrar PIN'; @override - String get scanner_linuxPairingHidePin => 'Ocultar PIN'; + String get scanner_linuxPairingHidePin => 'Esconder o PIN'; @override - String get scanner_linuxPairingPinTitle => 'PIN de emparelhamento Bluetooth'; + String get scanner_linuxPairingPinTitle => 'PIN de pareamento Bluetooth'; @override String scanner_linuxPairingPinPrompt(String deviceName) { - return 'Insira o PIN para $deviceName (deixe em branco se não houver).'; + return 'Insira o código PIN para $deviceName (deixe em branco se não houver).'; } + + @override + String get translation_messageTranslation => 'Tradução da mensagem'; + + @override + String get translation_translateBeforeSending => 'Traduzir antes de enviar'; + + @override + String get translation_composerEnabledHint => + 'As mensagens serão traduzidas antes de serem enviadas.'; + + @override + String get translation_composerDisabledHint => + 'Envie mensagens no idioma original, conforme digitado.'; + + @override + String translation_translateTo(String language) { + return 'Traduzir para $language'; + } + + @override + String get translation_translationOptions => 'Opções de tradução'; + + @override + String get translation_systemLanguage => 'Idioma do sistema'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 4184641..703d80d 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -3582,16 +3582,111 @@ class AppLocalizationsRu extends AppLocalizations { 'Уровень шума, RSSI, SNR и время передачи'; @override - String get scanner_linuxPairingShowPin => 'Показать PIN'; + String get translation_title => 'Перевод'; @override - String get scanner_linuxPairingHidePin => 'Скрыть PIN'; + String get translation_enableTitle => 'Включить перевод'; @override - String get scanner_linuxPairingPinTitle => 'PIN‑код сопряжения Bluetooth'; + String get translation_enableSubtitle => + 'Переводить входящие сообщения и позволять предварительный перевод перед отправкой.'; + + @override + String get translation_composerTitle => 'Переводить перед отправкой'; + + @override + String get translation_composerSubtitle => + 'Управляет исходным состоянием значка перевода, предоставляемого редактором.'; + + @override + String get translation_targetLanguage => 'Целевой язык'; + + @override + String get translation_useAppLanguage => 'Используйте язык приложения'; + + @override + String get translation_downloadedModelLabel => 'Загруженная модель'; + + @override + String get translation_presetModelLabel => + 'Предопределенная модель от Hugging Face'; + + @override + String get translation_manualUrlLabel => 'Ссылка на руководство'; + + @override + String get translation_downloadModel => 'Скачать модель'; + + @override + String get translation_downloading => 'Загрузка...'; + + @override + String get translation_working => 'Работа...'; + + @override + String get translation_stop => 'Прекратите'; + + @override + String get translation_mergingChunks => + 'Объединение скачанных фрагментов в один финальный файл...'; + + @override + String get translation_downloadedModels => 'Загруженные модели'; + + @override + String get translation_deleteModel => 'Удалить модель'; + + @override + String get translation_modelDownloaded => 'Модель перевода загружена.'; + + @override + String get translation_downloadStopped => 'Процесс загрузки был прерван.'; + + @override + String translation_downloadFailed(String error) { + return 'Не удалось скачать: $error'; + } + + @override + String get translation_enterUrlFirst => 'Сначала введите URL модели.'; + + @override + String get scanner_linuxPairingShowPin => 'Показать PIN-код'; + + @override + String get scanner_linuxPairingHidePin => 'Скрыть PIN-код'; + + @override + String get scanner_linuxPairingPinTitle => + 'PIN для сопряжения устройств по Bluetooth'; @override String scanner_linuxPairingPinPrompt(String deviceName) { - return 'Введите PIN‑код для $deviceName (оставьте пустым, если нет).'; + return 'Введите PIN-код для $deviceName (оставьте поле пустым, если PIN-код отсутствует).'; } + + @override + String get translation_messageTranslation => 'Перевод сообщения'; + + @override + String get translation_translateBeforeSending => 'Перевести перед отправкой'; + + @override + String get translation_composerEnabledHint => + 'Сообщения будут переведены перед отправкой.'; + + @override + String get translation_composerDisabledHint => + 'Отправляйте сообщения на языке, в котором они были изначально набраны.'; + + @override + String translation_translateTo(String language) { + return 'Перевести на $language'; + } + + @override + String get translation_translationOptions => 'Варианты перевода'; + + @override + String get translation_systemLanguage => 'Язык системы'; } diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index 59f46bd..980657d 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -3545,6 +3545,77 @@ class AppLocalizationsSk extends AppLocalizations { String get radioStats_settingsSubtitle => 'Úroveň hluku, RSSI, SNR a časové rozloženie'; + @override + String get translation_title => 'Preklad'; + + @override + String get translation_enableTitle => 'Aktivovať preklad'; + + @override + String get translation_enableSubtitle => + 'Prekladajte prichádzajúce správy a umožnite ich preklad pred odoslaním.'; + + @override + String get translation_composerTitle => 'Preložte pred odeslaním'; + + @override + String get translation_composerSubtitle => + 'Riadi výchoce stav ikony pre preklad, ktorú používa program.'; + + @override + String get translation_targetLanguage => 'Cieľový jazyk'; + + @override + String get translation_useAppLanguage => 'Použite jazyk aplikácie'; + + @override + String get translation_downloadedModelLabel => 'Stiahnutý model'; + + @override + String get translation_presetModelLabel => + 'Prednastavený model od Hugging Face'; + + @override + String get translation_manualUrlLabel => + 'Odkaz na manuál (v elektronickej forme)'; + + @override + String get translation_downloadModel => 'Stiahnuť model'; + + @override + String get translation_downloading => 'Stiahnutie...'; + + @override + String get translation_working => 'Práca...'; + + @override + String get translation_stop => 'Zastavte'; + + @override + String get translation_mergingChunks => + 'Sliečenie stiahnutých častí do konečného súboru...'; + + @override + String get translation_downloadedModels => 'Stiahnuté modely'; + + @override + String get translation_deleteModel => 'Odstrániť model'; + + @override + String get translation_modelDownloaded => 'Model pre preklad bol stiahnutý.'; + + @override + String get translation_downloadStopped => 'Stiahnutie bolo prerušené.'; + + @override + String translation_downloadFailed(String error) { + return 'Neúspešné stiahnutie: $error'; + } + + @override + String get translation_enterUrlFirst => + 'Najprv zadajte URL pre konkrétny model.'; + @override String get scanner_linuxPairingShowPin => 'Zobraziť PIN'; @@ -3552,10 +3623,35 @@ class AppLocalizationsSk extends AppLocalizations { String get scanner_linuxPairingHidePin => 'Skryť PIN'; @override - String get scanner_linuxPairingPinTitle => 'Bluetooth párovací PIN'; + String get scanner_linuxPairingPinTitle => 'PIN pre párovanie cez Bluetooth'; @override String scanner_linuxPairingPinPrompt(String deviceName) { - return 'Zadajte PIN pre $deviceName (ak nie je, nechajte prázdne).'; + return 'Zadajte PIN pre $deviceName (ak neexistuje, nechajte prázdne).'; } + + @override + String get translation_messageTranslation => 'Preklad textu'; + + @override + String get translation_translateBeforeSending => 'Preložte pred odeslaním'; + + @override + String get translation_composerEnabledHint => + 'Správy budú preložené, než budú odoslané.'; + + @override + String get translation_composerDisabledHint => + 'Posielajte správy v pôvodnej písanom jazyku.'; + + @override + String translation_translateTo(String language) { + return 'Preložte do $language'; + } + + @override + String get translation_translationOptions => 'Možnosti prekladania'; + + @override + String get translation_systemLanguage => 'Jazyk systému'; } diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index 171353c..ad2a278 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -3550,17 +3550,114 @@ class AppLocalizationsSl extends AppLocalizations { String get radioStats_settingsSubtitle => 'Število šumov, RSSI, SNR in čas, ki ga je napolnila oprema'; + @override + String get translation_title => 'Prevod'; + + @override + String get translation_enableTitle => 'Omogočite prevod'; + + @override + String get translation_enableSubtitle => + 'Prevedite vstopne sporočila in omogočite predhodno prevajanje.'; + + @override + String get translation_composerTitle => 'Preprištejte, preden pošljete'; + + @override + String get translation_composerSubtitle => + 'Ureja privzeto stanje ikone za prevod, ki jo uporablja avtor.'; + + @override + String get translation_targetLanguage => 'Ciljna jezika'; + + @override + String get translation_useAppLanguage => 'Uporabite jezik aplikacije'; + + @override + String get translation_downloadedModelLabel => 'Naložen model'; + + @override + String get translation_presetModelLabel => + 'Prednastavljeni model Hugging Face'; + + @override + String get translation_manualUrlLabel => 'URL za ročni model'; + + @override + String get translation_downloadModel => 'Prenesite model'; + + @override + String get translation_downloading => 'Izvajanje...'; + + @override + String get translation_working => 'Delo...'; + + @override + String get translation_stop => 'Prekliji'; + + @override + String get translation_mergingChunks => + 'Sklapljanje prenesenih delov v končni datoteko...'; + + @override + String get translation_downloadedModels => 'Naloženi modeli'; + + @override + String get translation_deleteModel => 'Izbrisati model'; + + @override + String get translation_modelDownloaded => + 'Model za prevajanje je bil naložen.'; + + @override + String get translation_downloadStopped => 'Prenos je bil prekinjen.'; + + @override + String translation_downloadFailed(String error) { + return 'Izgovoritev ni bila uspešna: $error'; + } + + @override + String get translation_enterUrlFirst => 'Najprej vnesite URL model.'; + @override String get scanner_linuxPairingShowPin => 'Prikaži PIN'; @override - String get scanner_linuxPairingHidePin => 'Skrij PIN'; + String get scanner_linuxPairingHidePin => 'Skrijte PIN'; @override - String get scanner_linuxPairingPinTitle => 'Bluetooth PIN za seznanjanje'; + String get scanner_linuxPairingPinTitle => + 'PIN za združevanje preko Bluetootha'; @override String scanner_linuxPairingPinPrompt(String deviceName) { - return 'Vnesite PIN za $deviceName (pustite prazno, če ga ni).'; + return 'Vnesite PIN kodo za $deviceName (ostavite prazno, če nimate kode).'; } + + @override + String get translation_messageTranslation => 'Prevod sporočila'; + + @override + String get translation_translateBeforeSending => + 'Preprištejte, preden pošljete'; + + @override + String get translation_composerEnabledHint => + 'Vsebina sporočil bo prevedena, preden jih pošljemo.'; + + @override + String get translation_composerDisabledHint => + 'Pošljite sporočila v originalnem tipkanem jeziku.'; + + @override + String translation_translateTo(String language) { + return 'Prevesti v $language'; + } + + @override + String get translation_translationOptions => 'Možnosti prevoda'; + + @override + String get translation_systemLanguage => 'Jezik sistema'; } diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index 6a776d7..cc590c2 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -3528,16 +3528,112 @@ class AppLocalizationsSv extends AppLocalizations { 'Bakgrundsnivå, RSSI, SNR och tillgänglig tid'; @override - String get scanner_linuxPairingShowPin => 'Visa PIN'; + String get translation_title => 'Översättning'; @override - String get scanner_linuxPairingHidePin => 'Dölj PIN'; + String get translation_enableTitle => 'Aktivera översättning'; @override - String get scanner_linuxPairingPinTitle => 'Bluetooth‑parnings‑PIN'; + String get translation_enableSubtitle => + 'Översätt inkommande meddelanden och möjliggör översättning före avsändning.'; + + @override + String get translation_composerTitle => 'Översätt innan du skickar'; + + @override + String get translation_composerSubtitle => + 'Styr standardtillståndet för kompositorns översättningsikon.'; + + @override + String get translation_targetLanguage => 'Målmedvetet språk'; + + @override + String get translation_useAppLanguage => 'Använd appens språk'; + + @override + String get translation_downloadedModelLabel => 'Nedladdad modell'; + + @override + String get translation_presetModelLabel => + 'Fördefinierat Hugging Face-modell'; + + @override + String get translation_manualUrlLabel => 'Manualens URL'; + + @override + String get translation_downloadModel => 'Ladda ner modellen'; + + @override + String get translation_downloading => 'Nedladdning...'; + + @override + String get translation_working => 'Arbeta...'; + + @override + String get translation_stop => 'Stopp'; + + @override + String get translation_mergingChunks => + 'Slå samman de nedladdade delarna till en slutlig fil...'; + + @override + String get translation_downloadedModels => 'Nedladdade modeller'; + + @override + String get translation_deleteModel => 'Ta bort modell'; + + @override + String get translation_modelDownloaded => + 'Översättningsmodellen har laddats ner.'; + + @override + String get translation_downloadStopped => 'Nedladdningen avbruten.'; + + @override + String translation_downloadFailed(String error) { + return 'Nedladdning misslyckades: $error'; + } + + @override + String get translation_enterUrlFirst => + 'Ange först en URL för en specifik modell.'; + + @override + String get scanner_linuxPairingShowPin => 'Visa PIN-kod'; + + @override + String get scanner_linuxPairingHidePin => 'Dölj PIN-kod'; + + @override + String get scanner_linuxPairingPinTitle => 'PIN-kod för Bluetooth-anslutning'; @override String scanner_linuxPairingPinPrompt(String deviceName) { - return 'Ange PIN för $deviceName (lämna tomt om ingen).'; + return 'Ange PIN-kod för $deviceName (lämna tomt om ingen finns).'; } + + @override + String get translation_messageTranslation => 'Meddelandets översättning'; + + @override + String get translation_translateBeforeSending => 'Översätt innan du skickar'; + + @override + String get translation_composerEnabledHint => + 'Meddelandena kommer att översättas innan de skickas.'; + + @override + String get translation_composerDisabledHint => + 'Skicka meddelanden på det ursprungliga, stavade språket.'; + + @override + String translation_translateTo(String language) { + return 'Översätt till $language'; + } + + @override + String get translation_translationOptions => 'Översättningsalternativ'; + + @override + String get translation_systemLanguage => 'Språk för systemet'; } diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 9ebead2..dd7bf63 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -3585,16 +3585,113 @@ class AppLocalizationsUk extends AppLocalizations { 'Рівень шуму, RSSI, SNR та час, протягом якого пристрій використовує радіоканал.'; @override - String get scanner_linuxPairingShowPin => 'Показати PIN'; + String get translation_title => 'Переклад'; @override - String get scanner_linuxPairingHidePin => 'Приховати PIN'; + String get translation_enableTitle => 'Увімкнути переклад'; @override - String get scanner_linuxPairingPinTitle => 'PIN‑код спарювання Bluetooth'; + String get translation_enableSubtitle => + 'Перекладати отримані повідомлення та дозволяти попередній переклад перед відправкою.'; + + @override + String get translation_composerTitle => 'Перекладіть перед відправкою'; + + @override + String get translation_composerSubtitle => + 'Контролює стан ікон перекладу, який використовується за замовчуванням.'; + + @override + String get translation_targetLanguage => 'Цільова мова'; + + @override + String get translation_useAppLanguage => 'Використовуйте мову додатку'; + + @override + String get translation_downloadedModelLabel => 'Завантажений шаблон'; + + @override + String get translation_presetModelLabel => + 'Заздалегідь налаштований модель від Hugging Face'; + + @override + String get translation_manualUrlLabel => + 'Посилання на веб-сторінку з інструкцією'; + + @override + String get translation_downloadModel => 'Завантажити модель'; + + @override + String get translation_downloading => 'Завантаження...'; + + @override + String get translation_working => 'Працюю...'; + + @override + String get translation_stop => 'Припинити'; + + @override + String get translation_mergingChunks => + 'Об\'єднання завантажених фрагментів у кінцевий файл...'; + + @override + String get translation_downloadedModels => 'Завантажені моделі'; + + @override + String get translation_deleteModel => 'Видалити модель'; + + @override + String get translation_modelDownloaded => 'Модель перекладу завантажена.'; + + @override + String get translation_downloadStopped => 'Завантаження призупинено.'; + + @override + String translation_downloadFailed(String error) { + return 'Не вдалося завантажити: $error'; + } + + @override + String get translation_enterUrlFirst => 'Спочатку введіть URL моделі.'; + + @override + String get scanner_linuxPairingShowPin => 'Показати PIN-код'; + + @override + String get scanner_linuxPairingHidePin => 'Приховати PIN-код'; + + @override + String get scanner_linuxPairingPinTitle => + 'PIN для з\'єднання через Bluetooth'; @override String scanner_linuxPairingPinPrompt(String deviceName) { - return 'Введіть PIN для $deviceName (залиште порожнім, якщо його немає).'; + return 'Введіть PIN-код для $deviceName (залиште поле порожнім, якщо немає).'; } + + @override + String get translation_messageTranslation => 'Переклад повідомлення'; + + @override + String get translation_translateBeforeSending => + 'Перекладіть перед відправкою'; + + @override + String get translation_composerEnabledHint => + 'Повідомлення будуть перекладені перед відправленням.'; + + @override + String get translation_composerDisabledHint => + 'Надсилайте повідомлення, використовуючи оригінальний текстовий формат.'; + + @override + String translation_translateTo(String language) { + return 'Перекласти на $language'; + } + + @override + String get translation_translationOptions => 'Варіанти перекладу'; + + @override + String get translation_systemLanguage => 'Мова системи'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 6d3a856..8910dcd 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -3300,7 +3300,72 @@ class AppLocalizationsZh extends AppLocalizations { String get radioStats_settingsSubtitle => '噪声水平、RSSI、信噪比和空中时间'; @override - String get scanner_linuxPairingShowPin => '显示 PIN码'; + String get translation_title => '翻译'; + + @override + String get translation_enableTitle => '启用翻译功能'; + + @override + String get translation_enableSubtitle => '翻译收到的消息,并允许在发送前进行翻译。'; + + @override + String get translation_composerTitle => '在发送之前进行翻译'; + + @override + String get translation_composerSubtitle => '控制作曲家翻译图标的默认状态。'; + + @override + String get translation_targetLanguage => '目标语言'; + + @override + String get translation_useAppLanguage => '使用应用程序语言'; + + @override + String get translation_downloadedModelLabel => '下载的模型'; + + @override + String get translation_presetModelLabel => '预设的 Hugging Face 模型'; + + @override + String get translation_manualUrlLabel => '手动模型网址'; + + @override + String get translation_downloadModel => '下载模型'; + + @override + String get translation_downloading => '正在下载...'; + + @override + String get translation_working => '工作中...'; + + @override + String get translation_stop => '停止'; + + @override + String get translation_mergingChunks => '将下载的片段合并成最终文件...'; + + @override + String get translation_downloadedModels => '下载的模型'; + + @override + String get translation_deleteModel => '删除模型'; + + @override + String get translation_modelDownloaded => '翻译模型已下载。'; + + @override + String get translation_downloadStopped => '下载已停止。'; + + @override + String translation_downloadFailed(String error) { + return '下载失败:$error'; + } + + @override + String get translation_enterUrlFirst => '首先,请输入模型的 URL。'; + + @override + String get scanner_linuxPairingShowPin => '显示PIN码'; @override String get scanner_linuxPairingHidePin => '隐藏 PIN'; @@ -3310,6 +3375,29 @@ class AppLocalizationsZh extends AppLocalizations { @override String scanner_linuxPairingPinPrompt(String deviceName) { - return '输入 $deviceName 的 PIN(如果没有,请留空)。'; + return '输入 $deviceName 的 PIN 码(如果为空,则留空)。'; } + + @override + String get translation_messageTranslation => '消息翻译'; + + @override + String get translation_translateBeforeSending => '在发送前进行翻译'; + + @override + String get translation_composerEnabledHint => '消息将在发送前进行翻译。'; + + @override + String get translation_composerDisabledHint => '使用原始的打字方式发送消息。'; + + @override + String translation_translateTo(String language) { + return '翻译成 $language'; + } + + @override + String get translation_translationOptions => '翻译选项'; + + @override + String get translation_systemLanguage => '系统语言'; } diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index bc6a06c..cb1a11c 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -2007,6 +2007,34 @@ "radioStats_stripWaiting": "Radio-statistieken ophalen…", "radioStats_settingsTile": "Statistieken over radio", "radioStats_settingsSubtitle": "Ruimtelijke ruis, RSSI, SNR en beschikbare tijd", + "@translation_downloadFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "translation_enableSubtitle": "Vertaal inkomende berichten en maak het mogelijk om berichten vooraf te vertalen.", + "translation_enableTitle": "Activeer vertaling", + "translation_title": "Vertaling", + "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_targetLanguage": "Doeltaal", + "translation_downloadedModelLabel": "Gedownloade model", + "translation_presetModelLabel": "Voorgeprogrammeerd Hugging Face-model", + "translation_manualUrlLabel": "URL van de handleiding", + "translation_downloadModel": "Download het model", + "translation_downloading": "Downloaden...", + "translation_working": "Werken...", + "translation_mergingChunks": "Het samenvoegen van de gedownloade stukken tot één eindbestand...", + "translation_stop": "Stoppen", + "translation_downloadedModels": "Gedownloade modellen", + "translation_deleteModel": "Model verwijderen", + "translation_modelDownloaded": "Vertalingmodel gedownload.", + "translation_downloadStopped": "Download is afgebroken.", + "translation_downloadFailed": "Download mislukt: {error}", + "translation_enterUrlFirst": "Voer eerst een URL van een model in.", "@scanner_linuxPairingPinPrompt": { "placeholders": { "deviceName": { @@ -2014,8 +2042,22 @@ } } }, + "scanner_linuxPairingPinTitle": "PIN voor Bluetooth-koppeling", + "scanner_linuxPairingHidePin": "Verberg PIN", + "scanner_linuxPairingPinPrompt": "Voer het pincode-in voor {deviceName} in (laat dit leeg als er geen is).", "scanner_linuxPairingShowPin": "Toon PIN", - "scanner_linuxPairingHidePin": "PIN verbergen", - "scanner_linuxPairingPinPrompt": "Voer PIN in voor {deviceName} (laat leeg als er geen is).", - "scanner_linuxPairingPinTitle": "Bluetooth‑koppelings‑PIN" + "@translation_translateTo": { + "placeholders": { + "language": { + "type": "String" + } + } + }, + "translation_composerDisabledHint": "Stuur berichten in de oorspronkelijke, getypte taal.", + "translation_translateBeforeSending": "Vertaal voor verzending", + "translation_composerEnabledHint": "De berichten worden vertaald voordat ze verzonden worden.", + "translation_messageTranslation": "Berichtvertaling", + "translation_translationOptions": "Opties voor vertaling", + "translation_systemLanguage": "Taal van het systeem", + "translation_translateTo": "Vertalen naar {language}" } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 9eb33e5..aa3049f 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -2045,6 +2045,34 @@ "radioStats_stripWaiting": "Pobieranie danych dotyczących radia…", "radioStats_settingsTile": "Statystyki radiowe", "radioStats_settingsSubtitle": "Szum tła, RSSI, SNR oraz czas dostępny", + "@translation_downloadFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "translation_composerTitle": "Przekład przed wysłaniem", + "translation_title": "Tłumaczenie", + "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_targetLanguage": "Język docelowy", + "translation_useAppLanguage": "Użyj języka aplikacji", + "translation_downloadedModelLabel": "Pobudowany model", + "translation_presetModelLabel": "Wspólny model Hugging Face", + "translation_manualUrlLabel": "Adres URL do wersji manualnej", + "translation_downloadModel": "Pobierz model", + "translation_downloading": "Pobieranie...", + "translation_working": "Praca...", + "translation_stop": "Zatrzymaj się", + "translation_mergingChunks": "Scalanie pobranych fragmentów w jeden plik końcowy...", + "translation_downloadedModels": "Pobrane modele", + "translation_deleteModel": "Usuń model", + "translation_modelDownloaded": "Model tłumaczenia został pobrany.", + "translation_downloadStopped": "Pobieranie zakończone.", + "translation_downloadFailed": "Nie udało się pobrać: {error}", + "translation_enterUrlFirst": "Najpierw wprowadź adres URL modelu.", "@scanner_linuxPairingPinPrompt": { "placeholders": { "deviceName": { @@ -2052,8 +2080,22 @@ } } }, - "scanner_linuxPairingShowPin": "Pokaż PIN", - "scanner_linuxPairingHidePin": "Ukryj PIN", - "scanner_linuxPairingPinPrompt": "Wprowadź kod PIN dla {deviceName} (pozostaw puste, jeśli brak).", - "scanner_linuxPairingPinTitle": "Kod PIN parowania Bluetooth" + "scanner_linuxPairingShowPin": "Wyświetl kod PIN", + "scanner_linuxPairingPinPrompt": "Wprowadź kod PIN dla {deviceName} (pust, jeśli nie jest wymagany).", + "scanner_linuxPairingHidePin": "Ukryj kod PIN", + "scanner_linuxPairingPinTitle": "PIN do sparowania przez Bluetooth", + "@translation_translateTo": { + "placeholders": { + "language": { + "type": "String" + } + } + }, + "translation_composerEnabledHint": "Komunikaty zostaną przetłumaczone przed wysłaniem.", + "translation_translateBeforeSending": "Przekład przed wysłaniem", + "translation_composerDisabledHint": "Wysyłaj wiadomości w oryginalnym, wpisanym formacie.", + "translation_messageTranslation": "Tłumaczenie wiadomości", + "translation_translationOptions": "Opcje tłumaczenia", + "translation_systemLanguage": "Język systemu", + "translation_translateTo": "Tłumacz na {language}" } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index eb87a15..c667cb0 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -2007,6 +2007,34 @@ "radioStats_stripWaiting": "Obtendo estatísticas de rádio…", "radioStats_settingsTile": "Estatísticas de rádio", "radioStats_settingsSubtitle": "Nível de ruído, RSSI, SNR e tempo de transmissão", + "@translation_downloadFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "translation_composerTitle": "Traduza antes de enviar", + "translation_enableSubtitle": "Traduzir mensagens recebidas e permitir a tradução antes do envio.", + "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_targetLanguage": "Língua-alvo", + "translation_useAppLanguage": "Utilize o idioma da aplicação", + "translation_downloadedModelLabel": "Modelo baixado", + "translation_presetModelLabel": "Modelo pré-definido da Hugging Face", + "translation_manualUrlLabel": "URL do modelo manual", + "translation_downloading": "Baixando...", + "translation_downloadModel": "Baixar modelo", + "translation_working": "Trabalhando...", + "translation_stop": "Pare", + "translation_mergingChunks": "Combinando os fragmentos baixados em um único arquivo...", + "translation_downloadedModels": "Modelos baixados", + "translation_deleteModel": "Excluir modelo", + "translation_modelDownloaded": "Modelo de tradução baixado.", + "translation_downloadStopped": "Download interrompido.", + "translation_downloadFailed": "Falha na descarga: {error}", + "translation_enterUrlFirst": "Insira primeiro a URL do modelo.", "@scanner_linuxPairingPinPrompt": { "placeholders": { "deviceName": { @@ -2014,8 +2042,22 @@ } } }, + "scanner_linuxPairingHidePin": "Esconder o PIN", "scanner_linuxPairingShowPin": "Mostrar PIN", - "scanner_linuxPairingHidePin": "Ocultar PIN", - "scanner_linuxPairingPinPrompt": "Insira o PIN para {deviceName} (deixe em branco se não houver).", - "scanner_linuxPairingPinTitle": "PIN de emparelhamento Bluetooth" + "scanner_linuxPairingPinTitle": "PIN de pareamento Bluetooth", + "scanner_linuxPairingPinPrompt": "Insira o código PIN para {deviceName} (deixe em branco se não houver).", + "@translation_translateTo": { + "placeholders": { + "language": { + "type": "String" + } + } + }, + "translation_messageTranslation": "Tradução da mensagem", + "translation_translateBeforeSending": "Traduzir antes de enviar", + "translation_composerEnabledHint": "As mensagens serão traduzidas antes de serem enviadas.", + "translation_composerDisabledHint": "Envie mensagens no idioma original, conforme digitado.", + "translation_translateTo": "Traduzir para {language}", + "translation_translationOptions": "Opções de tradução", + "translation_systemLanguage": "Idioma do sistema" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index c9493a0..730cfc9 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1247,6 +1247,34 @@ "radioStats_stripWaiting": "Получение данных о радио…", "radioStats_settingsTile": "Статистика радиовещания", "radioStats_settingsSubtitle": "Уровень шума, RSSI, SNR и время передачи", + "@translation_downloadFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "translation_enableSubtitle": "Переводить входящие сообщения и позволять предварительный перевод перед отправкой.", + "translation_composerTitle": "Переводить перед отправкой", + "translation_title": "Перевод", + "translation_enableTitle": "Включить перевод", + "translation_composerSubtitle": "Управляет исходным состоянием значка перевода, предоставляемого редактором.", + "translation_targetLanguage": "Целевой язык", + "translation_useAppLanguage": "Используйте язык приложения", + "translation_downloadedModelLabel": "Загруженная модель", + "translation_presetModelLabel": "Предопределенная модель от Hugging Face", + "translation_manualUrlLabel": "Ссылка на руководство", + "translation_downloadModel": "Скачать модель", + "translation_downloading": "Загрузка...", + "translation_stop": "Прекратите", + "translation_working": "Работа...", + "translation_mergingChunks": "Объединение скачанных фрагментов в один финальный файл...", + "translation_downloadedModels": "Загруженные модели", + "translation_deleteModel": "Удалить модель", + "translation_modelDownloaded": "Модель перевода загружена.", + "translation_downloadStopped": "Процесс загрузки был прерван.", + "translation_downloadFailed": "Не удалось скачать: {error}", + "translation_enterUrlFirst": "Сначала введите URL модели.", "@scanner_linuxPairingPinPrompt": { "placeholders": { "deviceName": { @@ -1254,8 +1282,22 @@ } } }, - "scanner_linuxPairingShowPin": "Показать PIN", - "scanner_linuxPairingPinPrompt": "Введите PIN‑код для {deviceName} (оставьте пустым, если нет).", - "scanner_linuxPairingHidePin": "Скрыть PIN", - "scanner_linuxPairingPinTitle": "PIN‑код сопряжения Bluetooth" + "scanner_linuxPairingPinPrompt": "Введите PIN-код для {deviceName} (оставьте поле пустым, если PIN-код отсутствует).", + "scanner_linuxPairingHidePin": "Скрыть PIN-код", + "scanner_linuxPairingPinTitle": "PIN для сопряжения устройств по Bluetooth", + "scanner_linuxPairingShowPin": "Показать PIN-код", + "@translation_translateTo": { + "placeholders": { + "language": { + "type": "String" + } + } + }, + "translation_translateBeforeSending": "Перевести перед отправкой", + "translation_composerEnabledHint": "Сообщения будут переведены перед отправкой.", + "translation_messageTranslation": "Перевод сообщения", + "translation_composerDisabledHint": "Отправляйте сообщения на языке, в котором они были изначально набраны.", + "translation_translateTo": "Перевести на {language}", + "translation_translationOptions": "Варианты перевода", + "translation_systemLanguage": "Язык системы" } diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb index 5a7aa6d..cf99ca8 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -2007,6 +2007,34 @@ "radioStats_stripWaiting": "Získavanie údajov o rádiu…", "radioStats_settingsTile": "Štatistiky rádiových vysielaní", "radioStats_settingsSubtitle": "Úroveň hluku, RSSI, SNR a časové rozloženie", + "@translation_downloadFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "translation_enableSubtitle": "Prekladajte prichádzajúce správy a umožnite ich preklad pred odoslaním.", + "translation_enableTitle": "Aktivovať preklad", + "translation_composerTitle": "Preložte pred odeslaním", + "translation_title": "Preklad", + "translation_composerSubtitle": "Riadi výchoce stav ikony pre preklad, ktorú používa program.", + "translation_targetLanguage": "Cieľový jazyk", + "translation_useAppLanguage": "Použite jazyk aplikácie", + "translation_downloadedModelLabel": "Stiahnutý model", + "translation_presetModelLabel": "Prednastavený model od Hugging Face", + "translation_manualUrlLabel": "Odkaz na manuál (v elektronickej forme)", + "translation_downloadModel": "Stiahnuť model", + "translation_downloading": "Stiahnutie...", + "translation_working": "Práca...", + "translation_stop": "Zastavte", + "translation_mergingChunks": "Sliečenie stiahnutých častí do konečného súboru...", + "translation_downloadedModels": "Stiahnuté modely", + "translation_deleteModel": "Odstrániť model", + "translation_modelDownloaded": "Model pre preklad bol stiahnutý.", + "translation_downloadStopped": "Stiahnutie bolo prerušené.", + "translation_downloadFailed": "Neúspešné stiahnutie: {error}", + "translation_enterUrlFirst": "Najprv zadajte URL pre konkrétny model.", "@scanner_linuxPairingPinPrompt": { "placeholders": { "deviceName": { @@ -2014,8 +2042,22 @@ } } }, - "scanner_linuxPairingPinPrompt": "Zadajte PIN pre {deviceName} (ak nie je, nechajte prázdne).", - "scanner_linuxPairingShowPin": "Zobraziť PIN", "scanner_linuxPairingHidePin": "Skryť PIN", - "scanner_linuxPairingPinTitle": "Bluetooth párovací PIN" + "scanner_linuxPairingShowPin": "Zobraziť PIN", + "scanner_linuxPairingPinTitle": "PIN pre párovanie cez Bluetooth", + "scanner_linuxPairingPinPrompt": "Zadajte PIN pre {deviceName} (ak neexistuje, nechajte prázdne).", + "@translation_translateTo": { + "placeholders": { + "language": { + "type": "String" + } + } + }, + "translation_composerDisabledHint": "Posielajte správy v pôvodnej písanom jazyku.", + "translation_composerEnabledHint": "Správy budú preložené, než budú odoslané.", + "translation_translateBeforeSending": "Preložte pred odeslaním", + "translation_messageTranslation": "Preklad textu", + "translation_translateTo": "Preložte do {language}", + "translation_translationOptions": "Možnosti prekladania", + "translation_systemLanguage": "Jazyk systému" } diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index 9adb387..0c29a86 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -2007,6 +2007,34 @@ "radioStats_stripWaiting": "Prejemanje statistike o radiju…", "radioStats_settingsTile": "Radijske statistike", "radioStats_settingsSubtitle": "Število šumov, RSSI, SNR in čas, ki ga je napolnila oprema", + "@translation_downloadFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "translation_composerTitle": "Preprištejte, preden pošljete", + "translation_title": "Prevod", + "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_targetLanguage": "Ciljna jezika", + "translation_useAppLanguage": "Uporabite jezik aplikacije", + "translation_downloadedModelLabel": "Naložen model", + "translation_presetModelLabel": "Prednastavljeni model Hugging Face", + "translation_manualUrlLabel": "URL za ročni model", + "translation_downloadModel": "Prenesite model", + "translation_downloading": "Izvajanje...", + "translation_working": "Delo...", + "translation_stop": "Prekliji", + "translation_mergingChunks": "Sklapljanje prenesenih delov v končni datoteko...", + "translation_downloadedModels": "Naloženi modeli", + "translation_deleteModel": "Izbrisati model", + "translation_modelDownloaded": "Model za prevajanje je bil naložen.", + "translation_downloadStopped": "Prenos je bil prekinjen.", + "translation_downloadFailed": "Izgovoritev ni bila uspešna: {error}", + "translation_enterUrlFirst": "Najprej vnesite URL model.", "@scanner_linuxPairingPinPrompt": { "placeholders": { "deviceName": { @@ -2014,8 +2042,22 @@ } } }, + "scanner_linuxPairingHidePin": "Skrijte PIN", "scanner_linuxPairingShowPin": "Prikaži PIN", - "scanner_linuxPairingHidePin": "Skrij PIN", - "scanner_linuxPairingPinPrompt": "Vnesite PIN za {deviceName} (pustite prazno, če ga ni).", - "scanner_linuxPairingPinTitle": "Bluetooth PIN za seznanjanje" + "scanner_linuxPairingPinPrompt": "Vnesite PIN kodo za {deviceName} (ostavite prazno, če nimate kode).", + "scanner_linuxPairingPinTitle": "PIN za združevanje preko Bluetootha", + "@translation_translateTo": { + "placeholders": { + "language": { + "type": "String" + } + } + }, + "translation_translateBeforeSending": "Preprištejte, preden pošljete", + "translation_composerDisabledHint": "Pošljite sporočila v originalnem tipkanem jeziku.", + "translation_composerEnabledHint": "Vsebina sporočil bo prevedena, preden jih pošljemo.", + "translation_messageTranslation": "Prevod sporočila", + "translation_translateTo": "Prevesti v {language}", + "translation_translationOptions": "Možnosti prevoda", + "translation_systemLanguage": "Jezik sistema" } diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index e4ace3e..3232888 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -2007,6 +2007,34 @@ "radioStats_stripWaiting": "Hämtar radiostatistik…", "radioStats_settingsTile": "Radiostation", "radioStats_settingsSubtitle": "Bakgrundsnivå, RSSI, SNR och tillgänglig tid", + "@translation_downloadFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "translation_enableSubtitle": "Översätt inkommande meddelanden och möjliggör översättning före avsändning.", + "translation_enableTitle": "Aktivera översättning", + "translation_title": "Översättning", + "translation_composerTitle": "Översätt innan du skickar", + "translation_composerSubtitle": "Styr standardtillståndet för kompositorns översättningsikon.", + "translation_targetLanguage": "Målmedvetet språk", + "translation_useAppLanguage": "Använd appens språk", + "translation_downloadedModelLabel": "Nedladdad modell", + "translation_presetModelLabel": "Fördefinierat Hugging Face-modell", + "translation_manualUrlLabel": "Manualens URL", + "translation_downloadModel": "Ladda ner modellen", + "translation_downloading": "Nedladdning...", + "translation_working": "Arbeta...", + "translation_stop": "Stopp", + "translation_mergingChunks": "Slå samman de nedladdade delarna till en slutlig fil...", + "translation_downloadedModels": "Nedladdade modeller", + "translation_deleteModel": "Ta bort modell", + "translation_modelDownloaded": "Översättningsmodellen har laddats ner.", + "translation_downloadStopped": "Nedladdningen avbruten.", + "translation_downloadFailed": "Nedladdning misslyckades: {error}", + "translation_enterUrlFirst": "Ange först en URL för en specifik modell.", "@scanner_linuxPairingPinPrompt": { "placeholders": { "deviceName": { @@ -2014,8 +2042,22 @@ } } }, - "scanner_linuxPairingShowPin": "Visa PIN", - "scanner_linuxPairingPinTitle": "Bluetooth‑parnings‑PIN", - "scanner_linuxPairingPinPrompt": "Ange PIN för {deviceName} (lämna tomt om ingen).", - "scanner_linuxPairingHidePin": "Dölj PIN" + "scanner_linuxPairingPinPrompt": "Ange PIN-kod för {deviceName} (lämna tomt om ingen finns).", + "scanner_linuxPairingPinTitle": "PIN-kod för Bluetooth-anslutning", + "scanner_linuxPairingShowPin": "Visa PIN-kod", + "scanner_linuxPairingHidePin": "Dölj PIN-kod", + "@translation_translateTo": { + "placeholders": { + "language": { + "type": "String" + } + } + }, + "translation_composerDisabledHint": "Skicka meddelanden på det ursprungliga, stavade språket.", + "translation_translateBeforeSending": "Översätt innan du skickar", + "translation_composerEnabledHint": "Meddelandena kommer att översättas innan de skickas.", + "translation_messageTranslation": "Meddelandets översättning", + "translation_translateTo": "Översätt till {language}", + "translation_translationOptions": "Översättningsalternativ", + "translation_systemLanguage": "Språk för systemet" } diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 8e27da1..ddab576 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -2007,6 +2007,34 @@ "radioStats_stripWaiting": "Отримано статистику радіо…", "radioStats_settingsTile": "Дані про радіостанції", "radioStats_settingsSubtitle": "Рівень шуму, RSSI, SNR та час, протягом якого пристрій використовує радіоканал.", + "@translation_downloadFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "translation_composerTitle": "Перекладіть перед відправкою", + "translation_title": "Переклад", + "translation_enableTitle": "Увімкнути переклад", + "translation_enableSubtitle": "Перекладати отримані повідомлення та дозволяти попередній переклад перед відправкою.", + "translation_composerSubtitle": "Контролює стан ікон перекладу, який використовується за замовчуванням.", + "translation_targetLanguage": "Цільова мова", + "translation_useAppLanguage": "Використовуйте мову додатку", + "translation_downloadedModelLabel": "Завантажений шаблон", + "translation_presetModelLabel": "Заздалегідь налаштований модель від Hugging Face", + "translation_manualUrlLabel": "Посилання на веб-сторінку з інструкцією", + "translation_downloadModel": "Завантажити модель", + "translation_downloading": "Завантаження...", + "translation_working": "Працюю...", + "translation_stop": "Припинити", + "translation_mergingChunks": "Об'єднання завантажених фрагментів у кінцевий файл...", + "translation_downloadedModels": "Завантажені моделі", + "translation_deleteModel": "Видалити модель", + "translation_modelDownloaded": "Модель перекладу завантажена.", + "translation_downloadStopped": "Завантаження призупинено.", + "translation_downloadFailed": "Не вдалося завантажити: {error}", + "translation_enterUrlFirst": "Спочатку введіть URL моделі.", "@scanner_linuxPairingPinPrompt": { "placeholders": { "deviceName": { @@ -2014,8 +2042,22 @@ } } }, - "scanner_linuxPairingPinTitle": "PIN‑код спарювання Bluetooth", - "scanner_linuxPairingShowPin": "Показати PIN", - "scanner_linuxPairingPinPrompt": "Введіть PIN для {deviceName} (залиште порожнім, якщо його немає).", - "scanner_linuxPairingHidePin": "Приховати PIN" + "scanner_linuxPairingPinTitle": "PIN для з'єднання через Bluetooth", + "scanner_linuxPairingShowPin": "Показати PIN-код", + "scanner_linuxPairingPinPrompt": "Введіть PIN-код для {deviceName} (залиште поле порожнім, якщо немає).", + "scanner_linuxPairingHidePin": "Приховати PIN-код", + "@translation_translateTo": { + "placeholders": { + "language": { + "type": "String" + } + } + }, + "translation_composerEnabledHint": "Повідомлення будуть перекладені перед відправленням.", + "translation_messageTranslation": "Переклад повідомлення", + "translation_composerDisabledHint": "Надсилайте повідомлення, використовуючи оригінальний текстовий формат.", + "translation_translateBeforeSending": "Перекладіть перед відправкою", + "translation_translateTo": "Перекласти на {language}", + "translation_translationOptions": "Варіанти перекладу", + "translation_systemLanguage": "Мова системи" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index cd7b44d..766be44 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -2012,6 +2012,34 @@ "radioStats_stripWaiting": "正在获取收音机数据…", "radioStats_settingsTile": "广播统计数据", "radioStats_settingsSubtitle": "噪声水平、RSSI、信噪比和空中时间", + "@translation_downloadFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "translation_title": "翻译", + "translation_enableSubtitle": "翻译收到的消息,并允许在发送前进行翻译。", + "translation_composerTitle": "在发送之前进行翻译", + "translation_enableTitle": "启用翻译功能", + "translation_composerSubtitle": "控制作曲家翻译图标的默认状态。", + "translation_targetLanguage": "目标语言", + "translation_useAppLanguage": "使用应用程序语言", + "translation_downloadedModelLabel": "下载的模型", + "translation_presetModelLabel": "预设的 Hugging Face 模型", + "translation_downloadModel": "下载模型", + "translation_manualUrlLabel": "手动模型网址", + "translation_downloading": "正在下载...", + "translation_working": "工作中...", + "translation_stop": "停止", + "translation_mergingChunks": "将下载的片段合并成最终文件...", + "translation_downloadedModels": "下载的模型", + "translation_deleteModel": "删除模型", + "translation_modelDownloaded": "翻译模型已下载。", + "translation_downloadStopped": "下载已停止。", + "translation_downloadFailed": "下载失败:{error}", + "translation_enterUrlFirst": "首先,请输入模型的 URL。", "@scanner_linuxPairingPinPrompt": { "placeholders": { "deviceName": { @@ -2019,8 +2047,22 @@ } } }, - "scanner_linuxPairingShowPin": "显示 PIN码", - "scanner_linuxPairingPinPrompt": "输入 {deviceName} 的 PIN(如果没有,请留空)。", "scanner_linuxPairingPinTitle": "蓝牙配对 PIN", - "scanner_linuxPairingHidePin": "隐藏 PIN" + "scanner_linuxPairingPinPrompt": "输入 {deviceName} 的 PIN 码(如果为空,则留空)。", + "scanner_linuxPairingHidePin": "隐藏 PIN", + "scanner_linuxPairingShowPin": "显示PIN码", + "@translation_translateTo": { + "placeholders": { + "language": { + "type": "String" + } + } + }, + "translation_composerDisabledHint": "使用原始的打字方式发送消息。", + "translation_messageTranslation": "消息翻译", + "translation_composerEnabledHint": "消息将在发送前进行翻译。", + "translation_translateBeforeSending": "在发送前进行翻译", + "translation_translateTo": "翻译成 {language}", + "translation_translationOptions": "翻译选项", + "translation_systemLanguage": "系统语言" } diff --git a/lib/main.dart b/lib/main.dart index c1bf022..3e57eb1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -19,6 +19,7 @@ import 'services/app_debug_log_service.dart'; import 'services/background_service.dart'; import 'services/map_tile_cache_service.dart'; import 'services/chat_text_scale_service.dart'; +import 'services/translation_service.dart'; import 'services/ui_view_state_service.dart'; import 'services/timeout_prediction_service.dart'; import 'storage/prefs_manager.dart'; @@ -41,6 +42,7 @@ void main() async { final backgroundService = BackgroundService(); final mapTileCacheService = MapTileCacheService(); final chatTextScaleService = ChatTextScaleService(); + final translationService = TranslationService(appSettingsService); final uiViewStateService = UiViewStateService(); final timeoutPredictionService = TimeoutPredictionService(storage); @@ -60,6 +62,7 @@ void main() async { _registerThirdPartyLicenses(); await chatTextScaleService.initialize(); + await translationService.refreshDownloadedModels(); await uiViewStateService.initialize(); await timeoutPredictionService.initialize(); @@ -68,6 +71,7 @@ void main() async { retryService: retryService, pathHistoryService: pathHistoryService, appSettingsService: appSettingsService, + translationService: translationService, bleDebugLogService: bleDebugLogService, appDebugLogService: appDebugLogService, backgroundService: backgroundService, @@ -93,6 +97,7 @@ void main() async { appDebugLogService: appDebugLogService, mapTileCacheService: mapTileCacheService, chatTextScaleService: chatTextScaleService, + translationService: translationService, uiViewStateService: uiViewStateService, timeoutPredictionService: timeoutPredictionService, ), @@ -130,6 +135,7 @@ class MeshCoreApp extends StatelessWidget { final AppDebugLogService appDebugLogService; final MapTileCacheService mapTileCacheService; final ChatTextScaleService chatTextScaleService; + final TranslationService translationService; final UiViewStateService uiViewStateService; final TimeoutPredictionService timeoutPredictionService; @@ -144,6 +150,7 @@ class MeshCoreApp extends StatelessWidget { required this.appDebugLogService, required this.mapTileCacheService, required this.chatTextScaleService, + required this.translationService, required this.uiViewStateService, required this.timeoutPredictionService, }); @@ -159,6 +166,7 @@ class MeshCoreApp extends StatelessWidget { ChangeNotifierProvider.value(value: bleDebugLogService), ChangeNotifierProvider.value(value: appDebugLogService), ChangeNotifierProvider.value(value: chatTextScaleService), + ChangeNotifierProvider.value(value: translationService), ChangeNotifierProvider.value(value: uiViewStateService), Provider.value(value: storage), Provider.value(value: mapTileCacheService), diff --git a/lib/models/app_settings.dart b/lib/models/app_settings.dart index 36d36a6..d3d3421 100644 --- a/lib/models/app_settings.dart +++ b/lib/models/app_settings.dart @@ -1,3 +1,5 @@ +import 'translation_support.dart'; + enum UnitSystem { metric, imperial } extension UnitSystemValue on UnitSystem { @@ -49,6 +51,12 @@ class AppSettings { final String tcpServerAddress; final int tcpServerPort; final bool jumpToOldestUnread; + final bool translationEnabled; + final String? translationTargetLanguageCode; + final bool composerTranslationEnabled; + final String? translationModelSourceUrl; + final String? translationSelectedModelId; + final List translationDownloadedModels; AppSettings({ this.clearPathOnMaxRetry = false, @@ -86,9 +94,16 @@ class AppSettings { this.tcpServerAddress = '', this.tcpServerPort = 0, this.jumpToOldestUnread = false, + this.translationEnabled = false, + this.translationTargetLanguageCode, + this.composerTranslationEnabled = false, + this.translationModelSourceUrl, + this.translationSelectedModelId, + List? translationDownloadedModels, }) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {}, batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {}, - mutedChannels = mutedChannels ?? {}; + mutedChannels = mutedChannels ?? {}, + translationDownloadedModels = translationDownloadedModels ?? const []; Map toJson() { return { @@ -127,6 +142,14 @@ class AppSettings { 'tcp_server_address': tcpServerAddress, 'tcp_server_port': tcpServerPort, 'jump_to_oldest_unread': jumpToOldestUnread, + 'translation_enabled': translationEnabled, + 'translation_target_language_code': translationTargetLanguageCode, + 'composer_translation_enabled': composerTranslationEnabled, + 'translation_model_source_url': translationModelSourceUrl, + 'translation_selected_model_id': translationSelectedModelId, + 'translation_downloaded_models': translationDownloadedModels + .map((model) => model.toJson()) + .toList(), }; } @@ -196,6 +219,24 @@ class AppSettings { tcpServerAddress: json['tcp_server_address'] as String? ?? '', tcpServerPort: json['tcp_server_port'] as int? ?? 0, jumpToOldestUnread: json['jump_to_oldest_unread'] as bool? ?? false, + translationEnabled: json['translation_enabled'] as bool? ?? false, + translationTargetLanguageCode: + json['translation_target_language_code'] as String?, + composerTranslationEnabled: + json['composer_translation_enabled'] as bool? ?? false, + translationModelSourceUrl: + json['translation_model_source_url'] as String?, + translationSelectedModelId: + json['translation_selected_model_id'] as String?, + translationDownloadedModels: + (json['translation_downloaded_models'] as List?) + ?.map( + (entry) => TranslationModelRecord.fromJson( + Map.from(entry as Map), + ), + ) + .toList() ?? + const [], ); } @@ -235,6 +276,12 @@ class AppSettings { String? tcpServerAddress, int? tcpServerPort, bool? jumpToOldestUnread, + bool? translationEnabled, + Object? translationTargetLanguageCode = _unset, + bool? composerTranslationEnabled, + Object? translationModelSourceUrl = _unset, + Object? translationSelectedModelId = _unset, + List? translationDownloadedModels, }) { return AppSettings( clearPathOnMaxRetry: clearPathOnMaxRetry ?? this.clearPathOnMaxRetry, @@ -284,6 +331,20 @@ class AppSettings { tcpServerAddress: tcpServerAddress ?? this.tcpServerAddress, tcpServerPort: tcpServerPort ?? this.tcpServerPort, jumpToOldestUnread: jumpToOldestUnread ?? this.jumpToOldestUnread, + translationEnabled: translationEnabled ?? this.translationEnabled, + translationTargetLanguageCode: translationTargetLanguageCode == _unset + ? this.translationTargetLanguageCode + : translationTargetLanguageCode as String?, + composerTranslationEnabled: + composerTranslationEnabled ?? this.composerTranslationEnabled, + translationModelSourceUrl: translationModelSourceUrl == _unset + ? this.translationModelSourceUrl + : translationModelSourceUrl as String?, + translationSelectedModelId: translationSelectedModelId == _unset + ? this.translationSelectedModelId + : translationSelectedModelId as String?, + translationDownloadedModels: + translationDownloadedModels ?? this.translationDownloadedModels, ); } } diff --git a/lib/models/channel_message.dart b/lib/models/channel_message.dart index 7c09089..80c9705 100644 --- a/lib/models/channel_message.dart +++ b/lib/models/channel_message.dart @@ -2,6 +2,7 @@ import 'dart:typed_data'; import '../connector/meshcore_protocol.dart'; import '../helpers/reaction_helper.dart'; import '../helpers/smaz.dart'; +import 'translation_support.dart'; import '../utils/app_logger.dart'; enum ChannelMessageStatus { pending, sent, failed } @@ -24,9 +25,16 @@ class Repeat { } class ChannelMessage { + static const Object _unset = Object(); + final Uint8List? senderKey; final String senderName; final String text; + final String? originalText; + final String? translatedText; + final String? translatedLanguageCode; + final MessageTranslationStatus translationStatus; + final String? translationModelId; final DateTime timestamp; final bool isOutgoing; final ChannelMessageStatus status; @@ -47,6 +55,11 @@ class ChannelMessage { this.senderKey, required this.senderName, required this.text, + this.originalText, + this.translatedText, + this.translatedLanguageCode, + this.translationStatus = MessageTranslationStatus.none, + this.translationModelId, required this.timestamp, required this.isOutgoing, this.status = ChannelMessageStatus.pending, @@ -86,12 +99,30 @@ class ChannelMessage { String? replyToMessageId, String? replyToSenderName, String? replyToText, + Object? originalText = _unset, + Object? translatedText = _unset, + Object? translatedLanguageCode = _unset, + MessageTranslationStatus? translationStatus, + Object? translationModelId = _unset, Map? reactions, }) { return ChannelMessage( senderKey: senderKey, senderName: senderName, text: text, + originalText: originalText == _unset + ? this.originalText + : originalText as String?, + translatedText: translatedText == _unset + ? this.translatedText + : translatedText as String?, + translatedLanguageCode: translatedLanguageCode == _unset + ? this.translatedLanguageCode + : translatedLanguageCode as String?, + translationStatus: translationStatus ?? this.translationStatus, + translationModelId: translationModelId == _unset + ? this.translationModelId + : translationModelId as String?, timestamp: timestamp, isOutgoing: isOutgoing, status: status ?? this.status, @@ -191,12 +222,18 @@ class ChannelMessage { static ChannelMessage outgoing( String text, String senderName, - int channelIndex, - ) { + int channelIndex, { + String? originalText, + String? translatedLanguageCode, + String? translationModelId, + }) { return ChannelMessage( senderKey: null, senderName: senderName, text: text, + originalText: originalText, + translatedLanguageCode: translatedLanguageCode, + translationModelId: translationModelId, timestamp: DateTime.now(), isOutgoing: true, status: ChannelMessageStatus.pending, diff --git a/lib/models/message.dart b/lib/models/message.dart index 6b930c0..e139561 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -1,19 +1,27 @@ import 'dart:typed_data'; import '../connector/meshcore_protocol.dart'; import '../helpers/reaction_helper.dart'; +import 'translation_support.dart'; enum MessageStatus { pending, sent, delivered, failed } class Message { + static const Object _unset = Object(); + final Uint8List senderKey; final String text; final DateTime timestamp; final bool isOutgoing; final bool isCli; final MessageStatus status; + final String? originalText; + final String? translatedText; + final String? translatedLanguageCode; + final MessageTranslationStatus translationStatus; + final String? translationModelId; // NEW: Retry logic fields - final String? messageId; + final String messageId; final int retryCount; final int? estimatedTimeoutMs; final int? expectedAckHash; @@ -33,7 +41,12 @@ class Message { required this.isOutgoing, this.isCli = false, this.status = MessageStatus.pending, - this.messageId, + String? messageId, + this.originalText, + this.translatedText, + this.translatedLanguageCode, + this.translationStatus = MessageTranslationStatus.none, + this.translationModelId, this.retryCount = 0, this.estimatedTimeoutMs, this.expectedAckHash, @@ -45,7 +58,10 @@ class Message { Uint8List? fourByteRoomContactKey, Map? reactions, Map? reactionStatuses, - }) : pathBytes = pathBytes ?? Uint8List(0), + }) : messageId = + messageId ?? + '${timestamp.millisecondsSinceEpoch}_${pubKeyToHex(senderKey)}_${text.hashCode}', + pathBytes = pathBytes ?? Uint8List(0), fourByteRoomContactKey = fourByteRoomContactKey ?? Uint8List(0), reactions = reactions ?? {}, reactionStatuses = reactionStatuses ?? {}; @@ -63,6 +79,11 @@ class Message { int? pathLength, Uint8List? pathBytes, bool? isCli, + Object? originalText = _unset, + Object? translatedText = _unset, + Object? translatedLanguageCode = _unset, + MessageTranslationStatus? translationStatus, + Object? translationModelId = _unset, Map? reactions, Map? reactionStatuses, Uint8List? fourByteRoomContactKey, @@ -75,6 +96,19 @@ class Message { isCli: isCli ?? this.isCli, status: status ?? this.status, messageId: messageId, + originalText: originalText == _unset + ? this.originalText + : originalText as String?, + translatedText: translatedText == _unset + ? this.translatedText + : translatedText as String?, + translatedLanguageCode: translatedLanguageCode == _unset + ? this.translatedLanguageCode + : translatedLanguageCode as String?, + translationStatus: translationStatus ?? this.translationStatus, + translationModelId: translationModelId == _unset + ? this.translationModelId + : translationModelId as String?, retryCount: retryCount ?? this.retryCount, estimatedTimeoutMs: estimatedTimeoutMs ?? this.estimatedTimeoutMs, expectedAckHash: expectedAckHash ?? this.expectedAckHash, @@ -124,12 +158,18 @@ class Message { static Message outgoing( Uint8List recipientKey, String text, { + String? originalText, + String? translatedLanguageCode, + String? translationModelId, int? pathLength, Uint8List? pathBytes, }) { return Message( senderKey: recipientKey, text: text, + originalText: originalText, + translatedLanguageCode: translatedLanguageCode, + translationModelId: translationModelId, timestamp: DateTime.now(), isOutgoing: true, isCli: false, diff --git a/lib/models/translation_support.dart b/lib/models/translation_support.dart new file mode 100644 index 0000000..7e58c76 --- /dev/null +++ b/lib/models/translation_support.dart @@ -0,0 +1,136 @@ +enum MessageTranslationStatus { none, pending, completed, failed, skipped } + +extension MessageTranslationStatusValue on MessageTranslationStatus { + String get value { + switch (this) { + case MessageTranslationStatus.pending: + return 'pending'; + case MessageTranslationStatus.completed: + return 'completed'; + case MessageTranslationStatus.failed: + return 'failed'; + case MessageTranslationStatus.skipped: + return 'skipped'; + case MessageTranslationStatus.none: + return 'none'; + } + } +} + +MessageTranslationStatus parseMessageTranslationStatus(dynamic value) { + if (value is! String) { + return MessageTranslationStatus.none; + } + for (final status in MessageTranslationStatus.values) { + if (status.value == value) { + return status; + } + } + return MessageTranslationStatus.none; +} + +class TranslationModelRecord { + final String id; + final String name; + final String sourceUrl; + final String localPath; + final DateTime downloadedAt; + final int fileSizeBytes; + + const TranslationModelRecord({ + required this.id, + required this.name, + required this.sourceUrl, + required this.localPath, + required this.downloadedAt, + required this.fileSizeBytes, + }); + + Map toJson() { + return { + 'id': id, + 'name': name, + 'source_url': sourceUrl, + 'local_path': localPath, + 'downloaded_at': downloadedAt.millisecondsSinceEpoch, + 'file_size_bytes': fileSizeBytes, + }; + } + + factory TranslationModelRecord.fromJson(Map json) { + return TranslationModelRecord( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + sourceUrl: json['source_url'] as String? ?? '', + localPath: json['local_path'] as String? ?? '', + downloadedAt: DateTime.fromMillisecondsSinceEpoch( + json['downloaded_at'] as int? ?? 0, + ), + fileSizeBytes: json['file_size_bytes'] as int? ?? 0, + ); + } +} + +String translationModelFriendlyName(TranslationModelRecord model) { + switch (model.id) { + case 'hy-mt1.5-1.8b-q4_k_m': + return 'Tencent HY-MT 1.5 1.8B Q4_K_M'; + case 'hy-mt1.5-1.8b-q6_k': + return 'Tencent HY-MT 1.5 1.8B Q6_K'; + default: + final trimmed = model.name.trim(); + if (trimmed.endsWith('.gguf')) { + return trimmed.substring(0, trimmed.length - 5); + } + return trimmed.isEmpty ? model.id : trimmed; + } +} + +class TranslationLanguageOption { + final String code; + final String label; + + const TranslationLanguageOption({required this.code, required this.label}); +} + +const List supportedTranslationLanguages = [ + TranslationLanguageOption(code: 'bg', label: 'Bulgarian'), + TranslationLanguageOption(code: 'de', label: 'German'), + TranslationLanguageOption(code: 'en', label: 'English'), + TranslationLanguageOption(code: 'es', label: 'Spanish'), + TranslationLanguageOption(code: 'fr', label: 'French'), + TranslationLanguageOption(code: 'hu', label: 'Hungarian'), + TranslationLanguageOption(code: 'it', label: 'Italian'), + TranslationLanguageOption(code: 'ja', label: 'Japanese'), + TranslationLanguageOption(code: 'ko', label: 'Korean'), + TranslationLanguageOption(code: 'nl', label: 'Dutch'), + TranslationLanguageOption(code: 'pl', label: 'Polish'), + TranslationLanguageOption(code: 'pt', label: 'Portuguese'), + TranslationLanguageOption(code: 'ru', label: 'Russian'), + TranslationLanguageOption(code: 'sk', label: 'Slovak'), + TranslationLanguageOption(code: 'sl', label: 'Slovenian'), + TranslationLanguageOption(code: 'sv', label: 'Swedish'), + TranslationLanguageOption(code: 'uk', label: 'Ukrainian'), + TranslationLanguageOption(code: 'zh', label: 'Chinese'), +]; + +final List translationPresetModels = [ + TranslationModelRecord( + id: 'hy-mt1.5-1.8b-q4_k_m', + name: 'HY-MT1.5-1.8B-Q4_K_M.gguf', + sourceUrl: + 'https://huggingface.co/tencent/HY-MT1.5-1.8B-GGUF/resolve/main/HY-MT1.5-1.8B-Q4_K_M.gguf?download=true', + localPath: '', + downloadedAt: DateTime.fromMillisecondsSinceEpoch(0), + fileSizeBytes: 0, + ), + TranslationModelRecord( + id: 'hy-mt1.5-1.8b-q6_k', + name: 'HY-MT1.5-1.8B-Q6_K.gguf', + sourceUrl: + 'https://huggingface.co/tencent/HY-MT1.5-1.8B-GGUF/resolve/main/HY-MT1.5-1.8B-Q6_K.gguf?download=true', + localPath: '', + downloadedAt: DateTime.fromMillisecondsSinceEpoch(0), + fileSizeBytes: 0, + ), +]; diff --git a/lib/screens/app_settings_screen.dart b/lib/screens/app_settings_screen.dart index 82b0f1f..ac6f4cb 100644 --- a/lib/screens/app_settings_screen.dart +++ b/lib/screens/app_settings_screen.dart @@ -1,11 +1,14 @@ +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; import '../l10n/l10n.dart'; import '../models/app_settings.dart'; +import '../models/translation_support.dart'; import '../services/app_settings_service.dart'; import '../services/notification_service.dart'; +import '../services/translation_service.dart'; import '../widgets/adaptive_app_bar_title.dart'; import 'map_cache_screen.dart'; @@ -21,26 +24,46 @@ class AppSettingsScreen extends StatelessWidget { ), body: SafeArea( top: false, - child: Consumer2( - builder: (context, settingsService, connector, child) { - return ListView( - padding: const EdgeInsets.all(16), - children: [ - _buildAppearanceCard(context, settingsService), - const SizedBox(height: 16), - _buildNotificationsCard(context, settingsService), - const SizedBox(height: 16), - _buildMessagingCard(context, settingsService), - const SizedBox(height: 16), - _buildBatteryCard(context, settingsService, connector), - const SizedBox(height: 16), - _buildMapSettingsCard(context, settingsService), - const SizedBox(height: 16), - _buildDebugCard(context, settingsService), - ], - ); - }, - ), + child: + Consumer3< + AppSettingsService, + MeshCoreConnector, + TranslationService + >( + builder: + ( + context, + settingsService, + connector, + translationService, + child, + ) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + _buildAppearanceCard(context, settingsService), + const SizedBox(height: 16), + _buildNotificationsCard(context, settingsService), + const SizedBox(height: 16), + _buildMessagingCard(context, settingsService), + const SizedBox(height: 16), + if (!kIsWeb) ...[ + _buildTranslationCard( + context, + settingsService, + translationService, + ), + const SizedBox(height: 16), + ], + _buildBatteryCard(context, settingsService, connector), + const SizedBox(height: 16), + _buildMapSettingsCard(context, settingsService), + const SizedBox(height: 16), + _buildDebugCard(context, settingsService), + ], + ); + }, + ), ), ); } @@ -530,6 +553,211 @@ class AppSettingsScreen extends StatelessWidget { ); } + Widget _buildTranslationCard( + BuildContext context, + AppSettingsService settingsService, + TranslationService translationService, + ) { + final settings = settingsService.settings; + return Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + context.l10n.translation_title, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + ), + SwitchListTile( + secondary: const Icon(Icons.translate), + title: Text(context.l10n.translation_enableTitle), + subtitle: Text(context.l10n.translation_enableSubtitle), + value: settings.translationEnabled, + onChanged: settingsService.setTranslationEnabled, + ), + const Divider(height: 1), + SwitchListTile( + secondary: const Icon(Icons.outgoing_mail), + title: Text(context.l10n.translation_composerTitle), + subtitle: Text(context.l10n.translation_composerSubtitle), + value: settings.composerTranslationEnabled, + onChanged: settingsService.setComposerTranslationEnabled, + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.language), + title: Text(context.l10n.translation_targetLanguage), + subtitle: Text( + _translationLanguageLabel( + context, + settings.translationTargetLanguageCode, + ), + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => + _showTranslationLanguageDialog(context, settingsService), + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: DropdownButtonFormField( + initialValue: settings.translationSelectedModelId, + isExpanded: true, + decoration: InputDecoration( + labelText: context.l10n.translation_downloadedModelLabel, + border: const OutlineInputBorder(), + ), + items: [ + for (final model in settings.translationDownloadedModels) + DropdownMenuItem( + value: model.id, + child: Text(translationModelFriendlyName(model)), + ), + ], + onChanged: settings.translationDownloadedModels.isEmpty + ? null + : (value) { + settingsService.setTranslationSelectedModelId(value); + }, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: DropdownButtonFormField( + initialValue: null, + isExpanded: true, + decoration: InputDecoration( + labelText: context.l10n.translation_presetModelLabel, + border: const OutlineInputBorder(), + ), + items: [ + for (final preset in translationPresetModels) + DropdownMenuItem( + value: preset.sourceUrl, + child: Text(translationModelFriendlyName(preset)), + ), + ], + onChanged: translationService.isBusy + ? null + : (value) async { + if (value == null) return; + final preset = translationPresetModels.firstWhere( + (entry) => entry.sourceUrl == value, + ); + await _downloadTranslationModel( + context, + translationService, + settingsService, + sourceUrl: preset.sourceUrl, + fileName: preset.name, + id: preset.id, + ); + }, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + child: Column( + children: [ + _TranslationUrlField( + initialValue: settings.translationModelSourceUrl ?? '', + onChanged: settingsService.setTranslationModelSourceUrl, + onDownload: translationService.isBusy + ? null + : (url) => _downloadTranslationModel( + context, + translationService, + settingsService, + sourceUrl: url, + ), + downloadLabel: translationService.isDownloading + ? context.l10n.translation_downloading + : translationService.isBusy + ? context.l10n.translation_working + : context.l10n.translation_downloadModel, + isDownloading: translationService.isDownloading, + onCancel: translationService.cancelDownload, + labelText: context.l10n.translation_manualUrlLabel, + stopLabel: context.l10n.translation_stop, + ), + if (translationService.isDownloading) ...[ + const SizedBox(height: 12), + LinearProgressIndicator( + value: + translationService.downloadFileName == + 'Merging chunks...' + ? null + : translationService.downloadProgress, + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: Text( + _downloadProgressLabel(context, translationService), + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + if (settings.translationDownloadedModels.isNotEmpty) ...[ + const SizedBox(height: 16), + Align( + alignment: Alignment.centerLeft, + child: Text( + context.l10n.translation_downloadedModels, + style: Theme.of(context).textTheme.titleSmall, + ), + ), + const SizedBox(height: 8), + for (final model in settings.translationDownloadedModels) + Card.outlined( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + leading: Icon( + model.id == settings.translationSelectedModelId + ? Icons.check_circle + : Icons.memory_outlined, + ), + title: Text(translationModelFriendlyName(model)), + subtitle: Text(_downloadedModelLabel(model)), + trailing: IconButton( + tooltip: context.l10n.translation_deleteModel, + onPressed: translationService.isBusy + ? null + : () => _deleteTranslationModel( + context, + translationService, + model, + ), + icon: const Icon(Icons.delete_outline), + ), + onTap: () => settingsService + .setTranslationSelectedModelId(model.id), + ), + ), + ], + if (translationService.lastError != null) ...[ + const SizedBox(height: 8), + Text( + translationService.lastError!, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + ], + ], + ), + ), + ], + ), + ); + } + // Fixed rendering issues Widget _buildBatteryCard( BuildContext context, @@ -910,6 +1138,124 @@ class AppSettingsScreen extends StatelessWidget { ); } + void _showTranslationLanguageDialog( + BuildContext context, + AppSettingsService settingsService, + ) { + showDialog( + context: context, + builder: (context) => _TranslationLanguageDialogContent( + currentLanguageCode: + settingsService.settings.translationTargetLanguageCode, + onLanguageSelected: (value) { + settingsService.setTranslationTargetLanguageCode(value); + Navigator.pop(context); + }, + ), + ); + } + + Future _downloadTranslationModel( + BuildContext context, + TranslationService translationService, + AppSettingsService settingsService, { + required String sourceUrl, + String? fileName, + String? id, + }) async { + if (sourceUrl.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.translation_enterUrlFirst)), + ); + return; + } + try { + await translationService.downloadModel( + sourceUrl: sourceUrl, + fileName: fileName, + id: id, + ); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.translation_modelDownloaded)), + ); + await settingsService.setTranslationEnabled(true); + } on TranslationDownloadCancelled { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.translation_downloadStopped)), + ); + } catch (error) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.translation_downloadFailed(error.toString()), + ), + ), + ); + } + } + + String _translationLanguageLabel(BuildContext context, String? languageCode) { + if (languageCode == null || languageCode.isEmpty) { + return context.l10n.translation_useAppLanguage; + } + for (final option in supportedTranslationLanguages) { + if (option.code == languageCode) { + return option.label; + } + } + return languageCode.toUpperCase(); + } + + String _downloadProgressLabel( + BuildContext context, + TranslationService translationService, + ) { + final fileName = translationService.downloadFileName ?? 'Model'; + if (fileName == 'Merging chunks...') { + return context.l10n.translation_mergingChunks; + } + final currentMb = translationService.downloadedBytes / (1024 * 1024); + final totalBytes = translationService.downloadTotalBytes; + if (totalBytes == null || totalBytes <= 0) { + return '$fileName: ${currentMb.toStringAsFixed(1)} MB'; + } + final totalMb = totalBytes / (1024 * 1024); + final percent = ((translationService.downloadProgress ?? 0) * 100) + .toStringAsFixed(0); + return '$fileName: ${currentMb.toStringAsFixed(1)} / ${totalMb.toStringAsFixed(1)} MB ($percent%)'; + } + + Future _deleteTranslationModel( + BuildContext context, + TranslationService translationService, + TranslationModelRecord model, + ) async { + try { + await translationService.removeModel(model); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + // TODO: l10n + content: Text('Deleted ${translationModelFriendlyName(model)}.'), + ), + ); + } catch (error) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Delete failed: $error')), + ); // TODO: l10n + } + } + + String _downloadedModelLabel(TranslationModelRecord model) { + final sizeMb = model.fileSizeBytes / (1024 * 1024); + final source = model.sourceUrl.isEmpty ? model.name : model.sourceUrl; + return '${sizeMb.toStringAsFixed(1)} MB • $source'; + } + Widget _buildDebugCard( BuildContext context, AppSettingsService settingsService, @@ -950,3 +1296,176 @@ class AppSettingsScreen extends StatelessWidget { ); } } + +/// Owns the [TextEditingController] for the manual model URL field so it +/// survives rebuilds of the parent [Consumer3]. +class _TranslationUrlField extends StatefulWidget { + const _TranslationUrlField({ + required this.initialValue, + required this.onChanged, + required this.onDownload, + required this.downloadLabel, + required this.isDownloading, + required this.onCancel, + required this.labelText, + required this.stopLabel, + }); + + final String initialValue; + final ValueChanged onChanged; + final void Function(String url)? onDownload; + final String downloadLabel; + final bool isDownloading; + final VoidCallback onCancel; + final String labelText; + final String stopLabel; + + @override + State<_TranslationUrlField> createState() => _TranslationUrlFieldState(); +} + +class _TranslationUrlFieldState extends State<_TranslationUrlField> { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.initialValue); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + TextField( + controller: _controller, + decoration: InputDecoration( + labelText: widget.labelText, + border: const OutlineInputBorder(), + ), + onChanged: widget.onChanged, + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: FilledButton.icon( + onPressed: widget.onDownload == null + ? null + : () => widget.onDownload!(_controller.text.trim()), + icon: const Icon(Icons.download), + label: Text(widget.downloadLabel), + ), + ), + if (widget.isDownloading) ...[ + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: widget.onCancel, + icon: const Icon(Icons.stop_circle_outlined), + label: Text(widget.stopLabel), + ), + ], + ], + ), + ], + ); + } +} + +/// Dialog content for choosing the translation target language. +/// Owns the search [TextEditingController] so it is properly disposed. +class _TranslationLanguageDialogContent extends StatefulWidget { + const _TranslationLanguageDialogContent({ + required this.currentLanguageCode, + required this.onLanguageSelected, + }); + + final String? currentLanguageCode; + final ValueChanged onLanguageSelected; + + @override + State<_TranslationLanguageDialogContent> createState() => + _TranslationLanguageDialogContentState(); +} + +class _TranslationLanguageDialogContentState + extends State<_TranslationLanguageDialogContent> { + late final TextEditingController _searchController; + List _filtered = supportedTranslationLanguages; + + @override + void initState() { + super.initState(); + _searchController = TextEditingController(); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(context.l10n.translation_targetLanguage), + content: SizedBox( + width: 360, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _searchController, + decoration: const InputDecoration( + prefixIcon: Icon(Icons.search), + border: OutlineInputBorder(), + ), + onChanged: (value) { + final normalized = value.trim().toLowerCase(); + setState(() { + _filtered = supportedTranslationLanguages.where((option) { + return option.label.toLowerCase().contains(normalized) || + option.code.toLowerCase().contains(normalized); + }).toList(); + }); + }, + ), + const SizedBox(height: 12), + Flexible( + child: RadioGroup( + groupValue: widget.currentLanguageCode, + onChanged: (value) { + if (value == null) return; + widget.onLanguageSelected(value); + }, + child: ListView( + shrinkWrap: true, + children: [ + for (final option in _filtered) + RadioListTile( + value: option.code, + title: Text(option.label), + subtitle: Text(option.code.toUpperCase()), + ), + ], + ), + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(context.l10n.common_close), + ), + ], + ); + } +} diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index 12e98d6..f413694 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -11,22 +11,25 @@ import '../connector/meshcore_connector.dart'; import '../utils/platform_info.dart'; import '../helpers/chat_scroll_controller.dart'; import '../connector/meshcore_protocol.dart'; -import '../helpers/link_handler.dart'; import '../helpers/reaction_helper.dart'; import '../helpers/utf8_length_limiter.dart'; import '../l10n/l10n.dart'; import '../models/channel.dart'; import '../models/channel_message.dart'; +import '../models/translation_support.dart'; import '../services/app_settings_service.dart'; import '../services/chat_text_scale_service.dart'; +import '../services/translation_service.dart'; import '../utils/emoji_utils.dart'; import '../widgets/chat_zoom_wrapper.dart'; import '../widgets/emoji_picker.dart'; import '../widgets/gif_message.dart'; import '../widgets/jump_to_bottom_button.dart'; import '../widgets/gif_picker.dart'; +import '../widgets/message_translation_button.dart'; import '../widgets/message_status_icon.dart'; import '../widgets/radio_stats_entry.dart'; +import '../widgets/translated_message_content.dart'; import 'channel_message_path_screen.dart'; import 'map_screen.dart'; @@ -354,6 +357,14 @@ class _ChannelChatScreenState extends State { final isOutgoing = message.isOutgoing; final gifId = _parseGifId(message.text); final poi = _parsePoiMessage(message.text); + final translatedDisplayText = + message.translatedText != null && + message.translatedText!.trim().isNotEmpty + ? message.translatedText!.trim() + : message.text; + final originalDisplayText = message.isOutgoing + ? message.originalText + : (translatedDisplayText != message.text ? message.text : null); final displayPath = message.pathBytes.isNotEmpty ? message.pathBytes : (message.pathVariants.isNotEmpty @@ -504,12 +515,18 @@ class _ChannelChatScreenState extends State { crossAxisAlignment: CrossAxisAlignment.end, children: [ Flexible( - child: LinkHandler.buildLinkifyText( - context: context, - text: message.text, + child: TranslatedMessageContent( + displayText: translatedDisplayText, + originalText: originalDisplayText, style: TextStyle( fontSize: bodyFontSize * textScale, ), + originalStyle: TextStyle( + fontSize: bodyFontSize * textScale, + fontStyle: FontStyle.italic, + color: Theme.of(context).colorScheme.onSurface + .withValues(alpha: 0.72), + ), ), ), if (!enableTracing && isOutgoing) ...[ @@ -994,6 +1011,7 @@ class _ChannelChatScreenState extends State { Widget _buildMessageComposer() { final connector = context.watch(); final maxBytes = maxChannelMessageBytes(connector.selfName); + final settings = context.watch().settings; return Column( mainAxisSize: MainAxisSize.min, children: [ @@ -1025,6 +1043,12 @@ class _ChannelChatScreenState extends State { onPressed: () => _showGifPicker(context), tooltip: context.l10n.chat_sendGif, ), + if (settings.translationEnabled) + MessageTranslationButton( + enabled: settings.composerTranslationEnabled, + languageCode: settings.translationTargetLanguageCode, + onPressed: _showTranslationOptions, + ), Expanded( child: ValueListenableBuilder( valueListenable: _textController, @@ -1112,7 +1136,19 @@ class _ChannelChatScreenState extends State { ); } - void _sendMessage() { + Future _showTranslationOptions() async { + final settingsService = context.read(); + final settings = settingsService.settings; + await showMessageTranslationSheet( + context: context, + enabled: settings.composerTranslationEnabled, + selectedLanguageCode: settings.translationTargetLanguageCode, + onEnabledChanged: settingsService.setComposerTranslationEnabled, + onLanguageSelected: settingsService.setTranslationTargetLanguageCode, + ); + } + + Future _sendMessage() async { final text = _textController.text.trim(); if (text.isEmpty) return; @@ -1126,11 +1162,46 @@ class _ChannelChatScreenState extends State { } _lastChannelSendAt = now; + // Capture reply state before clearing, then clear input synchronously + // to prevent double-send during async translation. + final replyingTo = _replyingToMessage; + _textController.clear(); + _cancelReply(); + _textFieldFocusNode.requestFocus(); + final connector = context.read(); + final settings = context.read().settings; + final translationService = context.read(); String messageText = text; - if (_replyingToMessage != null) { - messageText = '@[${_replyingToMessage!.senderName}] $text'; + String? originalText; + String? translatedLanguageCode; + String? translationModelId; + if (settings.translationEnabled) { + final targetLanguageCode = translationService.resolvedTargetLanguageCode( + settings.languageOverride, + ); + if (translationService.shouldTranslateOutgoing( + text: text, + targetLanguageCode: targetLanguageCode, + )) { + final result = await translationService.translateOutgoingText( + text: text, + targetLanguageCode: targetLanguageCode, + ); + if (!mounted) return; + if (result != null && + result.status == MessageTranslationStatus.completed && + result.translatedText.isNotEmpty) { + messageText = result.translatedText; + originalText = text; + translatedLanguageCode = result.targetLanguageCode; + translationModelId = result.modelId; + } + } + } + if (replyingTo != null) { + messageText = '@[${replyingTo.senderName}] $messageText'; } final maxBytes = maxChannelMessageBytes(connector.selfName); @@ -1141,10 +1212,13 @@ class _ChannelChatScreenState extends State { return; } - connector.sendChannelMessage(widget.channel, messageText); - _textController.clear(); - _cancelReply(); - _textFieldFocusNode.requestFocus(); + connector.sendChannelMessage( + widget.channel, + messageText, + originalText: originalText, + translatedLanguageCode: translatedLanguageCode, + translationModelId: translationModelId, + ); } String _formatTime(DateTime time) { diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index aecdc81..5f9a2ee 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -16,16 +16,17 @@ import '../connector/meshcore_protocol.dart'; import '../helpers/reaction_helper.dart'; import '../widgets/message_status_icon.dart'; import '../helpers/chat_scroll_controller.dart'; -import '../helpers/link_handler.dart'; import '../helpers/path_helper.dart'; import '../helpers/utf8_length_limiter.dart'; import '../models/channel_message.dart'; import '../models/contact.dart'; import '../models/message.dart'; import '../models/path_history.dart'; +import '../models/translation_support.dart'; import '../services/app_settings_service.dart'; import '../services/chat_text_scale_service.dart'; import '../services/path_history_service.dart'; +import '../services/translation_service.dart'; import '../widgets/chat_zoom_wrapper.dart'; import '../widgets/elements_ui.dart'; import 'channel_message_path_screen.dart'; @@ -35,8 +36,10 @@ import '../widgets/emoji_picker.dart'; import '../widgets/gif_message.dart'; import '../widgets/jump_to_bottom_button.dart'; import '../widgets/gif_picker.dart'; +import '../widgets/message_translation_button.dart'; import '../widgets/path_selection_dialog.dart'; import '../widgets/radio_stats_entry.dart'; +import '../widgets/translated_message_content.dart'; import '../utils/app_logger.dart'; import '../l10n/l10n.dart'; import 'telemetry_screen.dart'; @@ -495,6 +498,7 @@ class _ChatScreenState extends State { Widget _buildInputBar(MeshCoreConnector connector) { final maxBytes = maxContactMessageBytes(); final colorScheme = Theme.of(context).colorScheme; + final settings = context.watch().settings; return Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( @@ -509,6 +513,12 @@ class _ChatScreenState extends State { onPressed: () => _showGifPicker(context), tooltip: context.l10n.chat_sendGif, ), + if (settings.translationEnabled) + MessageTranslationButton( + enabled: settings.composerTranslationEnabled, + languageCode: settings.translationTargetLanguageCode, + onPressed: _showTranslationOptions, + ), Expanded( child: ValueListenableBuilder( valueListenable: _textController, @@ -606,7 +616,19 @@ class _ChatScreenState extends State { ); } - void _sendMessage(MeshCoreConnector connector) { + Future _showTranslationOptions() async { + final settingsService = context.read(); + final settings = settingsService.settings; + await showMessageTranslationSheet( + context: context, + enabled: settings.composerTranslationEnabled, + selectedLanguageCode: settings.translationTargetLanguageCode, + onEnabledChanged: settingsService.setComposerTranslationEnabled, + onLanguageSelected: settingsService.setTranslationTargetLanguageCode, + ); + } + + Future _sendMessage(MeshCoreConnector connector) async { final text = _textController.text.trim(); if (text.isEmpty) return; @@ -620,17 +642,54 @@ class _ChatScreenState extends State { } _lastTextSendAt = now; + // Clear input synchronously to prevent double-send + _textController.clear(); + _textFieldFocusNode.requestFocus(); + + final settings = context.read().settings; + final translationService = context.read(); + var outgoingText = text; + String? originalText; + String? translatedLanguageCode; + String? translationModelId; + if (settings.translationEnabled) { + final targetLanguageCode = translationService.resolvedTargetLanguageCode( + settings.languageOverride, + ); + if (translationService.shouldTranslateOutgoing( + text: text, + targetLanguageCode: targetLanguageCode, + )) { + final result = await translationService.translateOutgoingText( + text: text, + targetLanguageCode: targetLanguageCode, + ); + if (!mounted) return; + if (result != null && + result.status == MessageTranslationStatus.completed && + result.translatedText.isNotEmpty) { + outgoingText = result.translatedText; + originalText = text; + translatedLanguageCode = result.targetLanguageCode; + translationModelId = result.modelId; + } + } + } final maxBytes = maxContactMessageBytes(); - if (utf8.encode(text).length > maxBytes) { + if (utf8.encode(outgoingText).length > maxBytes) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.chat_messageTooLong(maxBytes))), ); return; } - connector.sendMessage(_resolveContact(connector), text); - _textController.clear(); - _textFieldFocusNode.requestFocus(); + connector.sendMessage( + _resolveContact(connector), + outgoingText, + originalText: originalText, + translatedLanguageCode: translatedLanguageCode, + translationModelId: translationModelId, + ); } void _showPathHistory(BuildContext context) { @@ -1533,6 +1592,14 @@ class _MessageBubble extends StatelessWidget { if (isRoomServer && !isOutgoing) { messageText = message.text.substring(4.clamp(0, message.text.length)); } + final translatedDisplayText = + message.translatedText != null && + message.translatedText!.trim().isNotEmpty + ? message.translatedText!.trim() + : messageText; + final originalDisplayText = isOutgoing + ? message.originalText + : (translatedDisplayText != messageText ? messageText : null); return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Column( @@ -1662,13 +1729,17 @@ class _MessageBubble extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.end, children: [ Flexible( - child: LinkHandler.buildLinkifyText( - context: context, - text: messageText, + child: TranslatedMessageContent( + displayText: translatedDisplayText, + originalText: originalDisplayText, style: TextStyle( color: textColor, fontSize: bodyFontSize * textScale, ), + originalStyle: TextStyle( + color: textColor.withValues(alpha: 0.78), + fontSize: bodyFontSize * textScale, + ), ), ), if (!enableTracing && isOutgoing) ...[ diff --git a/lib/services/app_settings_service.dart b/lib/services/app_settings_service.dart index cb69469..283ce72 100644 --- a/lib/services/app_settings_service.dart +++ b/lib/services/app_settings_service.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import '../models/app_settings.dart'; +import '../models/translation_support.dart'; import '../storage/prefs_manager.dart'; import '../utils/app_logger.dart'; @@ -222,4 +223,34 @@ class AppSettingsService extends ChangeNotifier { Future setJumpToOldestUnread(bool value) async { await updateSettings(_settings.copyWith(jumpToOldestUnread: value)); } + + Future setTranslationEnabled(bool value) async { + await updateSettings(_settings.copyWith(translationEnabled: value)); + } + + Future setTranslationTargetLanguageCode(String? value) async { + await updateSettings( + _settings.copyWith(translationTargetLanguageCode: value), + ); + } + + Future setComposerTranslationEnabled(bool value) async { + await updateSettings(_settings.copyWith(composerTranslationEnabled: value)); + } + + Future setTranslationModelSourceUrl(String? value) async { + await updateSettings(_settings.copyWith(translationModelSourceUrl: value)); + } + + Future setTranslationSelectedModelId(String? value) async { + await updateSettings(_settings.copyWith(translationSelectedModelId: value)); + } + + Future setTranslationDownloadedModels( + List value, + ) async { + await updateSettings( + _settings.copyWith(translationDownloadedModels: value), + ); + } } diff --git a/lib/services/message_retry_service.dart b/lib/services/message_retry_service.dart index 5bc8812..733dfc5 100644 --- a/lib/services/message_retry_service.dart +++ b/lib/services/message_retry_service.dart @@ -138,6 +138,9 @@ class MessageRetryService extends ChangeNotifier { Future sendMessageWithRetry({ required Contact contact, required String text, + String? originalText, + String? translatedLanguageCode, + String? translationModelId, Uint8List? pathBytes, int? pathLength, }) async { @@ -150,6 +153,9 @@ class MessageRetryService extends ChangeNotifier { final message = Message( senderKey: contact.publicKey, text: text, + originalText: originalText, + translatedLanguageCode: translatedLanguageCode, + translationModelId: translationModelId, timestamp: DateTime.now(), isOutgoing: true, status: MessageStatus.pending, diff --git a/lib/services/translation_file_store.dart b/lib/services/translation_file_store.dart new file mode 100644 index 0000000..451e1c8 --- /dev/null +++ b/lib/services/translation_file_store.dart @@ -0,0 +1,2 @@ +export 'translation_file_store_stub.dart' + if (dart.library.io) 'translation_file_store_io.dart'; diff --git a/lib/services/translation_file_store_io.dart b/lib/services/translation_file_store_io.dart new file mode 100644 index 0000000..ad549ef --- /dev/null +++ b/lib/services/translation_file_store_io.dart @@ -0,0 +1,131 @@ +import 'dart:io'; + +import 'package:path_provider/path_provider.dart'; + +import '../models/translation_support.dart'; + +class TranslationFileStore { + Future modelDirectoryPath() async { + final baseDir = await getApplicationDocumentsDirectory(); + final dir = Directory('${baseDir.path}/translation_models'); + if (!dir.existsSync()) { + await dir.create(recursive: true); + } + return dir.path; + } + + Future> scanDownloadedModels() async { + final dir = Directory(await modelDirectoryPath()); + if (!dir.existsSync()) { + return const []; + } + final models = []; + for (final entity in dir.listSync().whereType()) { + final name = entity.uri.pathSegments.last; + // Skip hidden chunk files from interrupted parallel downloads. + if (name.startsWith('.')) { + await entity.delete(); + continue; + } + final stat = entity.statSync(); + models.add( + TranslationModelRecord( + id: name, + name: name, + sourceUrl: '', + localPath: entity.path, + downloadedAt: stat.modified, + fileSizeBytes: stat.size, + ), + ); + } + return models; + } + + Future deleteModel(TranslationModelRecord model) async { + await deleteFile(model.localPath); + } + + Future deleteFile(String path) async { + final file = File(path); + if (file.existsSync()) { + await file.delete(); + } + } + + Future writeModelBytes({ + required String fileName, + required Stream> chunks, + }) async { + final directoryPath = await modelDirectoryPath(); + final file = File('$directoryPath/$fileName'); + final sink = file.openWrite(); + var fileSizeBytes = 0; + var completed = false; + try { + await for (final chunk in chunks) { + sink.add(chunk); + fileSizeBytes += chunk.length; + } + completed = true; + } finally { + await sink.close(); + if (!completed && file.existsSync()) { + await file.delete(); + } + } + return DownloadedModelFile( + localPath: file.path, + fileSizeBytes: fileSizeBytes, + ); + } + + Future chunkFilePath(String fileName, int index) async { + final dir = await modelDirectoryPath(); + return '$dir/.${fileName}_chunk_$index'; + } + + Future combineChunks({ + required String fileName, + required List chunkPaths, + }) async { + final dir = await modelDirectoryPath(); + final finalPath = '$dir/$fileName'; + final sink = File(finalPath).openWrite(); + var totalSize = 0; + var completed = false; + try { + for (final chunkPath in chunkPaths) { + final chunkFile = File(chunkPath); + await sink.addStream(chunkFile.openRead()); + totalSize += await chunkFile.length(); + } + completed = true; + } finally { + await sink.close(); + for (final chunkPath in chunkPaths) { + final file = File(chunkPath); + if (file.existsSync()) { + await file.delete(); + } + } + if (!completed) { + final finalFile = File(finalPath); + if (finalFile.existsSync()) { + await finalFile.delete(); + } + } + } + return DownloadedModelFile(localPath: finalPath, fileSizeBytes: totalSize); + } +} + +class DownloadedModelFile { + final String localPath; + final int fileSizeBytes; + + const DownloadedModelFile({ + required this.localPath, + required this.fileSizeBytes, + }); +} diff --git a/lib/services/translation_file_store_stub.dart b/lib/services/translation_file_store_stub.dart new file mode 100644 index 0000000..4a38c37 --- /dev/null +++ b/lib/services/translation_file_store_stub.dart @@ -0,0 +1,43 @@ +import '../models/translation_support.dart'; + +class TranslationFileStore { + Future modelDirectoryPath() async { + throw UnsupportedError('Local model storage is not supported on web.'); + } + + Future> scanDownloadedModels() async { + return const []; + } + + Future deleteModel(TranslationModelRecord model) async {} + + Future deleteFile(String path) async {} + + Future writeModelBytes({ + required String fileName, + required Stream> chunks, + }) async { + throw UnsupportedError('Local model downloads are not supported on web.'); + } + + Future chunkFilePath(String fileName, int index) async { + throw UnsupportedError('Local model downloads are not supported on web.'); + } + + Future combineChunks({ + required String fileName, + required List chunkPaths, + }) async { + throw UnsupportedError('Local model downloads are not supported on web.'); + } +} + +class DownloadedModelFile { + final String localPath; + final int fileSizeBytes; + + const DownloadedModelFile({ + required this.localPath, + required this.fileSizeBytes, + }); +} diff --git a/lib/services/translation_service.dart b/lib/services/translation_service.dart new file mode 100644 index 0000000..3dd65d5 --- /dev/null +++ b/lib/services/translation_service.dart @@ -0,0 +1,653 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:llamadart/llamadart.dart'; + +import '../models/app_settings.dart'; +import '../models/translation_support.dart'; +import '../utils/app_logger.dart'; +import 'app_settings_service.dart'; +import 'translation_file_store.dart'; + +class TranslationResult { + final String translatedText; + final String targetLanguageCode; + final String? detectedLanguageCode; + final String? modelId; + final MessageTranslationStatus status; + + const TranslationResult({ + required this.translatedText, + required this.targetLanguageCode, + required this.status, + this.detectedLanguageCode, + this.modelId, + }); +} + +class TranslationDownloadCancelled implements Exception { + const TranslationDownloadCancelled(); + + @override + String toString() => 'Download canceled.'; +} + +class TranslationService extends ChangeNotifier { + final AppSettingsService _appSettingsService; + final TranslationFileStore _fileStore; + + TranslationService( + this._appSettingsService, { + TranslationFileStore? fileStore, + }) : _fileStore = fileStore ?? TranslationFileStore(); + + bool _isBusy = false; + bool _isDownloading = false; + bool _cancelDownloadRequested = false; + String? _lastError; + Future _queue = Future.value(); + LlamaEngine? _engine; + String? _loadedModelPath; + String? _failedModelPath; + int _downloadedBytes = 0; + int? _downloadTotalBytes; + String? _downloadFileName; + + bool get isBusy => _isBusy; + bool get isDownloading => _isDownloading; + String? get lastError => _lastError; + int get downloadedBytes => _downloadedBytes; + int? get downloadTotalBytes => _downloadTotalBytes; + String? get downloadFileName => _downloadFileName; + double? get downloadProgress { + final total = _downloadTotalBytes; + if (!_isDownloading || total == null || total <= 0) { + return null; + } + return (_downloadedBytes / total).clamp(0.0, 1.0); + } + + AppSettings get _settings => _appSettingsService.settings; + + String? resolvedTargetLanguageCode(String? fallbackLanguageCode) { + return _settings.translationTargetLanguageCode ?? + _settings.languageOverride ?? + fallbackLanguageCode; + } + + String? resolvedIncomingLanguageCode(String? fallbackLanguageCode) { + return _settings.languageOverride ?? fallbackLanguageCode ?? 'en'; + } + + bool shouldTranslateIncoming({ + required String text, + required bool isCli, + required bool isOutgoing, + }) { + if (!_settings.translationEnabled || isCli || isOutgoing) { + return false; + } + return _isPlainTextEligible(text); + } + + bool shouldTranslateOutgoing({ + required String text, + required String? targetLanguageCode, + }) { + return _settings.composerTranslationEnabled && + targetLanguageCode != null && + targetLanguageCode.isNotEmpty && + _isPlainTextEligible(text); + } + + List get availableModels => + _settings.translationDownloadedModels; + + TranslationModelRecord? get selectedModel { + final selectedId = _settings.translationSelectedModelId; + if (selectedId == null) { + return availableModels.isNotEmpty ? availableModels.first : null; + } + for (final model in availableModels) { + if (model.id == selectedId) { + return model; + } + } + return availableModels.isNotEmpty ? availableModels.first : null; + } + + Future refreshDownloadedModels() async { + if (_isDownloading) return; + final scanned = await _fileStore.scanDownloadedModels(); + if (scanned.isEmpty) { + return; + } + final existingByPath = { + for (final model in _settings.translationDownloadedModels) + model.localPath: model, + }; + final merged = scanned.map((model) { + final existing = existingByPath[model.localPath]; + if (existing == null) { + return model; + } + return TranslationModelRecord( + id: existing.id, + name: existing.name, + sourceUrl: existing.sourceUrl, + localPath: existing.localPath, + downloadedAt: existing.downloadedAt, + fileSizeBytes: model.fileSizeBytes, + ); + }).toList(); + await _appSettingsService.setTranslationDownloadedModels(merged); + _failedModelPath = null; + if (_settings.translationSelectedModelId == null && merged.isNotEmpty) { + await _appSettingsService.setTranslationSelectedModelId(merged.first.id); + } + } + + static const int _parallelChunks = 8; + static const int _parallelMinBytes = 10 * 1024 * 1024; // 10 MB + + Future downloadModel({ + required String sourceUrl, + String? fileName, + String? id, + }) async { + final uri = Uri.tryParse(sourceUrl); + if (uri == null || !uri.hasScheme) { + throw ArgumentError('Invalid model URL.'); + } + return _runExclusive(() async { + _setBusy(true); + _setDownloading(true); + _lastError = null; + try { + final resolvedFileName = + fileName ?? + _sanitizeFileName( + uri.pathSegments.isNotEmpty + ? uri.pathSegments.last + : 'translation-model.gguf', + ); + _downloadFileName = resolvedFileName; + _downloadedBytes = 0; + _cancelDownloadRequested = false; + + // HEAD request to check size and range support. + final headClient = http.Client(); + int? totalSize; + bool supportsRange = false; + try { + final headResponse = await headClient.send(http.Request('HEAD', uri)); + totalSize = headResponse.contentLength; + supportsRange = + headResponse.headers['accept-ranges']?.contains('bytes') == true; + await headResponse.stream.drain(); + } finally { + headClient.close(); + } + + _downloadTotalBytes = totalSize; + notifyListeners(); + + DownloadedModelFile downloaded; + if (supportsRange && + totalSize != null && + totalSize > _parallelMinBytes) { + downloaded = await _downloadParallel( + uri: uri, + fileName: resolvedFileName, + totalSize: totalSize, + ); + } else { + downloaded = await _downloadSingle( + uri: uri, + fileName: resolvedFileName, + ); + } + + final record = TranslationModelRecord( + id: id ?? resolvedFileName, + name: resolvedFileName, + sourceUrl: sourceUrl, + localPath: downloaded.localPath, + downloadedAt: DateTime.now(), + fileSizeBytes: downloaded.fileSizeBytes, + ); + final updated = [ + for (final existing in _settings.translationDownloadedModels) + if (existing.id != record.id) existing, + record, + ]; + await _appSettingsService.setTranslationDownloadedModels(updated); + await _appSettingsService.setTranslationSelectedModelId(record.id); + await _appSettingsService.setTranslationModelSourceUrl(sourceUrl); + _failedModelPath = null; + return record; + } finally { + _setDownloading(false); + } + }); + } + + Future _downloadSingle({ + required Uri uri, + required String fileName, + }) async { + final client = http.Client(); + try { + final response = await client.send(http.Request('GET', uri)); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw StateError('Model download failed: HTTP ${response.statusCode}'); + } + _downloadTotalBytes ??= response.contentLength; + notifyListeners(); + final trackedStream = _trackDownloadProgress(response.stream); + return await _fileStore.writeModelBytes( + fileName: fileName, + chunks: trackedStream, + ); + } finally { + client.close(); + } + } + + Future _downloadParallel({ + required Uri uri, + required String fileName, + required int totalSize, + }) async { + final chunkSize = (totalSize / _parallelChunks).ceil(); + final chunkPaths = []; + final clients = []; + var combineReached = false; + try { + final futures = >[]; + for (var i = 0; i < _parallelChunks; i++) { + final start = i * chunkSize; + final end = (start + chunkSize - 1).clamp(0, totalSize - 1); + if (start >= totalSize) break; + final chunkPath = await _fileStore.chunkFilePath(fileName, i); + chunkPaths.add(chunkPath); + final client = http.Client(); + clients.add(client); + futures.add( + _downloadRange( + client: client, + uri: uri, + chunkPath: chunkPath, + start: start, + end: end, + ), + ); + } + await Future.wait(futures); + if (_cancelDownloadRequested) { + throw const TranslationDownloadCancelled(); + } + _downloadFileName = 'Merging chunks...'; + notifyListeners(); + combineReached = true; + return await _fileStore.combineChunks( + fileName: fileName, + chunkPaths: chunkPaths, + ); + } finally { + for (final client in clients) { + client.close(); + } + if (!combineReached) { + for (final chunkPath in chunkPaths) { + await _fileStore.deleteFile(chunkPath); + } + } + } + } + + Future _downloadRange({ + required http.Client client, + required Uri uri, + required String chunkPath, + required int start, + required int end, + }) async { + final request = http.Request('GET', uri); + request.headers['Range'] = 'bytes=$start-$end'; + final response = await client.send(request); + if (response.statusCode != 206 && response.statusCode != 200) { + throw StateError('Range download failed: HTTP ${response.statusCode}'); + } + final trackedStream = _trackDownloadProgress(response.stream); + await _fileStore.writeModelBytes( + fileName: chunkPath.split('/').last, + chunks: trackedStream, + ); + } + + void cancelDownload() { + if (!_isDownloading) { + return; + } + _cancelDownloadRequested = true; + _lastError = 'Download stopped.'; + notifyListeners(); + } + + Future removeModel(TranslationModelRecord model) async { + await _runExclusive(() async { + _setBusy(true); + _lastError = null; + await _fileStore.deleteModel(model); + final updated = _settings.translationDownloadedModels + .where((entry) => entry.id != model.id) + .toList(); + await _appSettingsService.setTranslationDownloadedModels(updated); + if (_settings.translationSelectedModelId == model.id) { + await _appSettingsService.setTranslationSelectedModelId( + updated.isNotEmpty ? updated.first.id : null, + ); + } + }); + } + + Future translateIncomingText({ + required String text, + required String? targetLanguageCode, + }) async { + if (targetLanguageCode == null || !_isPlainTextEligible(text)) { + return null; + } + final detectedLanguageCode = await detectLanguage(text); + if (detectedLanguageCode != null && + detectedLanguageCode == targetLanguageCode) { + return const TranslationResult( + translatedText: '', + targetLanguageCode: '', + status: MessageTranslationStatus.skipped, + ); + } + final translatedText = await _translateText( + text: text, + targetLanguageCode: targetLanguageCode, + sourceLanguageCode: detectedLanguageCode, + ); + if (translatedText == null || translatedText.trim().isEmpty) { + return null; + } + // If translation is nearly identical, text was already in target language. + if (translatedText.trim().toLowerCase() == text.trim().toLowerCase()) { + return const TranslationResult( + translatedText: '', + targetLanguageCode: '', + status: MessageTranslationStatus.skipped, + ); + } + return TranslationResult( + translatedText: translatedText.trim(), + targetLanguageCode: targetLanguageCode, + detectedLanguageCode: detectedLanguageCode, + modelId: selectedModel?.id, + status: MessageTranslationStatus.completed, + ); + } + + Future translateOutgoingText({ + required String text, + required String? targetLanguageCode, + }) async { + if (targetLanguageCode == null || !_isPlainTextEligible(text)) { + return null; + } + final detectedLanguageCode = await detectLanguage(text); + if (detectedLanguageCode != null && + detectedLanguageCode == targetLanguageCode) { + return const TranslationResult( + translatedText: '', + targetLanguageCode: '', + status: MessageTranslationStatus.skipped, + ); + } + final translatedText = await _translateText( + text: text, + targetLanguageCode: targetLanguageCode, + sourceLanguageCode: detectedLanguageCode, + ); + if (translatedText == null || translatedText.trim().isEmpty) { + return null; + } + return TranslationResult( + translatedText: translatedText.trim(), + targetLanguageCode: targetLanguageCode, + detectedLanguageCode: detectedLanguageCode, + modelId: selectedModel?.id, + status: MessageTranslationStatus.completed, + ); + } + + Future detectLanguage(String text) async { + return _heuristicLanguageCode(text); + } + + Future _translateText({ + required String text, + required String targetLanguageCode, + String? sourceLanguageCode, + }) async { + if (!_hasUsableModel) { + return null; + } + final model = selectedModel; + if (model == null || model.localPath.isEmpty) { + return null; + } + final targetLabel = _languageLabel(targetLanguageCode); + final instruction = targetLanguageCode == 'zh' + ? '将以下文本翻译为中文,注意只需要输出翻译后的结果,不要额外解释:\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); + if (engine == null) { + return null; + } + final messages = [ + LlamaChatMessage.fromText( + role: LlamaChatRole.user, + text: instruction, + ), + ]; + final output = StringBuffer(); + await for (final chunk in engine.create( + messages, + params: const GenerationParams( + maxTokens: 256, + temp: 0.7, + topK: 20, + topP: 0.6, + penalty: 1.05, + reusePromptPrefix: false, + ), + enableThinking: false, + sourceLangCode: sourceLanguageCode, + targetLangCode: targetLanguageCode, + )) { + final content = chunk.choices.firstOrNull?.delta.content; + if (content != null) { + output.write(content); + } + if (output.length >= text.length * 4 + 100) { + break; + } + } + return _sanitizeOutput(output.toString()); + }); + } catch (error) { + _lastError = error.toString(); + appLogger.warn('Translation request failed: $error'); + notifyListeners(); + return null; + } + } + + bool get _hasUsableModel { + final model = selectedModel; + return !kIsWeb && model != null && model.localPath.isNotEmpty; + } + + bool _isPlainTextEligible(String text) { + final trimmed = text.trim(); + if (trimmed.isEmpty) { + return false; + } + return !(trimmed.startsWith('g:') || + trimmed.startsWith('m:') || + trimmed.startsWith('V1|') || + trimmed.startsWith('r:')); + } + + String? _heuristicLanguageCode(String text) { + if (RegExp(r'[іїєґІЇЄҐ]').hasMatch(text)) { + return 'uk'; + } + if (RegExp(r'[а-яёА-ЯЁ]').hasMatch(text)) { + return 'ru'; + } + if (RegExp(r'[ぁ-んァ-ン]').hasMatch(text)) { + return 'ja'; + } + if (RegExp(r'[가-힣]').hasMatch(text)) { + return 'ko'; + } + if (RegExp(r'[\u4e00-\u9fff]').hasMatch(text)) { + return 'zh'; + } + // Latin-script languages can't be reliably distinguished by characters + // alone — return null so the translator always attempts translation. + return null; + } + + String _languageLabel(String code) { + for (final option in supportedTranslationLanguages) { + if (option.code == code) { + return option.label; + } + } + return code.toUpperCase(); + } + + String _sanitizeOutput(String raw) { + var result = raw.trim(); + result = result.replaceAll(RegExp(r'\*\*'), ''); + result = result.replaceAll(RegExp(r'<[^>]+>'), ''); + return result.trim(); + } + + String _sanitizeFileName(String fileName) { + final cleaned = fileName.replaceAll(RegExp(r'[^A-Za-z0-9._-]'), '_'); + return cleaned.isEmpty ? 'translation-model.gguf' : cleaned; + } + + Future _ensureContext(String modelPath) async { + if (_engine != null && _loadedModelPath == modelPath) { + return _engine; + } + if (modelPath == _failedModelPath) { + return null; + } + if (_engine != null) { + await _engine!.dispose(); + _engine = null; + _loadedModelPath = null; + } + final engine = LlamaEngine(LlamaBackend()); + try { + await engine.loadModel( + modelPath, + modelParams: const ModelParams( + gpuLayers: 0, + preferredBackend: GpuBackend.cpu, + ), + ); + _engine = engine; + _loadedModelPath = modelPath; + _failedModelPath = null; + return _engine; + } catch (_) { + await engine.dispose(); + _failedModelPath = modelPath; + rethrow; + } + } + + Future releaseModel() async { + await _runExclusive(() async { + final engine = _engine; + if (engine == null) { + _loadedModelPath = null; + return; + } + _engine = null; + _loadedModelPath = null; + await engine.dispose(); + }); + } + + Future _runExclusive(Future Function() action) { + final completer = Completer(); + _setBusy(true); + _queue = _queue.then((_) async { + try { + completer.complete(await action()); + } catch (error, stackTrace) { + completer.completeError(error, stackTrace); + } finally { + _setBusy(false); + } + }); + return completer.future; + } + + Stream> _trackDownloadProgress(Stream> source) async* { + await for (final chunk in source) { + if (_cancelDownloadRequested) { + throw const TranslationDownloadCancelled(); + } + _downloadedBytes += chunk.length; + notifyListeners(); + yield chunk; + } + } + + void _setBusy(bool value) { + if (_isBusy == value) { + return; + } + _isBusy = value; + notifyListeners(); + } + + void _setDownloading(bool value) { + _isDownloading = value; + if (!value) { + _cancelDownloadRequested = false; + _downloadedBytes = 0; + _downloadTotalBytes = null; + _downloadFileName = null; + } + notifyListeners(); + } + + @override + void dispose() { + final engine = _engine; + _engine = null; + _loadedModelPath = null; + if (engine != null) { + unawaited(engine.dispose()); + } + super.dispose(); + } +} diff --git a/lib/storage/channel_message_store.dart b/lib/storage/channel_message_store.dart index ddb42f6..5a0fbd8 100644 --- a/lib/storage/channel_message_store.dart +++ b/lib/storage/channel_message_store.dart @@ -3,6 +3,7 @@ import 'dart:typed_data'; import 'package:meshcore_open/utils/app_logger.dart'; import '../models/channel_message.dart'; +import '../models/translation_support.dart'; import '../helpers/smaz.dart'; import 'prefs_manager.dart'; @@ -98,6 +99,11 @@ class ChannelMessageStore { 'senderKey': msg.senderKey != null ? base64Encode(msg.senderKey!) : null, 'senderName': msg.senderName, 'text': msg.text, + 'originalText': msg.originalText, + 'translatedText': msg.translatedText, + 'translatedLanguageCode': msg.translatedLanguageCode, + 'translationStatus': msg.translationStatus.value, + 'translationModelId': msg.translationModelId, 'timestamp': msg.timestamp.millisecondsSinceEpoch, 'isOutgoing': msg.isOutgoing, 'status': msg.status.index, @@ -126,6 +132,13 @@ class ChannelMessageStore { : null, senderName: json['senderName'] as String, text: decodedText, + originalText: json['originalText'] as String?, + translatedText: json['translatedText'] as String?, + translatedLanguageCode: json['translatedLanguageCode'] as String?, + translationStatus: parseMessageTranslationStatus( + json['translationStatus'], + ), + translationModelId: json['translationModelId'] as String?, timestamp: DateTime.fromMillisecondsSinceEpoch(json['timestamp'] as int), isOutgoing: json['isOutgoing'] as bool, status: ChannelMessageStatus.values[json['status'] as int], diff --git a/lib/storage/message_store.dart b/lib/storage/message_store.dart index 5550911..3d23768 100644 --- a/lib/storage/message_store.dart +++ b/lib/storage/message_store.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:typed_data'; import '../models/message.dart'; +import '../models/translation_support.dart'; import '../helpers/smaz.dart'; import '../utils/app_logger.dart'; import 'prefs_manager.dart'; @@ -83,6 +84,11 @@ class MessageStore { 'isCli': msg.isCli, 'status': msg.status.index, 'messageId': msg.messageId, + 'originalText': msg.originalText, + 'translatedText': msg.translatedText, + 'translatedLanguageCode': msg.translatedLanguageCode, + 'translationStatus': msg.translationStatus.value, + 'translationModelId': msg.translationModelId, 'retryCount': msg.retryCount, 'estimatedTimeoutMs': msg.estimatedTimeoutMs, 'expectedAckHash': msg.expectedAckHash, @@ -115,6 +121,13 @@ class MessageStore { isCli: isCli, status: MessageStatus.values[json['status'] as int], messageId: json['messageId'] as String?, + originalText: json['originalText'] as String?, + translatedText: json['translatedText'] as String?, + translatedLanguageCode: json['translatedLanguageCode'] as String?, + translationStatus: parseMessageTranslationStatus( + json['translationStatus'], + ), + translationModelId: json['translationModelId'] as String?, retryCount: json['retryCount'] as int? ?? 0, estimatedTimeoutMs: json['estimatedTimeoutMs'] as int?, expectedAckHash: json['expectedAckHash'] as int? ?? 0, diff --git a/lib/widgets/message_translation_button.dart b/lib/widgets/message_translation_button.dart new file mode 100644 index 0000000..18946dd --- /dev/null +++ b/lib/widgets/message_translation_button.dart @@ -0,0 +1,187 @@ +import 'package:flutter/material.dart'; + +import '../l10n/l10n.dart'; +import '../models/translation_support.dart'; + +class MessageTranslationButton extends StatelessWidget { + final bool enabled; + final String? languageCode; + final VoidCallback onPressed; + + const MessageTranslationButton({ + super.key, + required this.enabled, + required this.languageCode, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + final label = _languageLabel( + languageCode, + context.l10n.translation_systemLanguage, + ); + return IconButton( + icon: Icon(enabled ? Icons.translate : Icons.translate_outlined), + onPressed: onPressed, + tooltip: enabled + ? context.l10n.translation_translateTo(label) + : context.l10n.translation_translationOptions, + ); + } +} + +Future showMessageTranslationSheet({ + required BuildContext context, + required bool enabled, + required String? selectedLanguageCode, + required ValueChanged onEnabledChanged, + required ValueChanged onLanguageSelected, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => _MessageTranslationSheet( + enabled: enabled, + selectedLanguageCode: selectedLanguageCode, + onEnabledChanged: onEnabledChanged, + onLanguageSelected: onLanguageSelected, + ), + ); +} + +class _MessageTranslationSheet extends StatefulWidget { + final bool enabled; + final String? selectedLanguageCode; + final ValueChanged onEnabledChanged; + final ValueChanged onLanguageSelected; + + const _MessageTranslationSheet({ + required this.enabled, + required this.selectedLanguageCode, + required this.onEnabledChanged, + required this.onLanguageSelected, + }); + + @override + State<_MessageTranslationSheet> createState() => + _MessageTranslationSheetState(); +} + +class _MessageTranslationSheetState extends State<_MessageTranslationSheet> { + late final TextEditingController _searchController; + late bool _localEnabled; + late String? _localSelectedLanguageCode; + List _filtered = supportedTranslationLanguages; + + @override + void initState() { + super.initState(); + _searchController = TextEditingController(); + _localEnabled = widget.enabled; + _localSelectedLanguageCode = widget.selectedLanguageCode; + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + void _updateFilter(String query) { + final normalized = query.trim().toLowerCase(); + setState(() { + _filtered = supportedTranslationLanguages.where((option) { + return option.label.toLowerCase().contains(normalized) || + option.code.toLowerCase().contains(normalized); + }).toList(); + }); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: 16 + MediaQuery.of(context).viewInsets.bottom, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.translation_messageTranslation, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(context.l10n.translation_translateBeforeSending), + subtitle: Text( + _localEnabled + ? context.l10n.translation_composerEnabledHint + : context.l10n.translation_composerDisabledHint, + ), + value: _localEnabled, + onChanged: (value) { + setState(() => _localEnabled = value); + widget.onEnabledChanged(value); + }, + ), + const SizedBox(height: 8), + TextField( + controller: _searchController, + onChanged: _updateFilter, + decoration: InputDecoration( + labelText: context.l10n.translation_targetLanguage, + prefixIcon: const Icon(Icons.search), + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + Flexible( + child: ListView.builder( + shrinkWrap: true, + itemCount: _filtered.length, + itemBuilder: (context, index) { + final option = _filtered[index]; + final selected = option.code == _localSelectedLanguageCode; + return ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon( + selected + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + ), + title: Text(option.label), + subtitle: Text(option.code.toUpperCase()), + onTap: () { + setState(() => _localSelectedLanguageCode = option.code); + widget.onLanguageSelected(option.code); + Navigator.pop(context); + }, + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +String _languageLabel(String? languageCode, String systemLanguageFallback) { + if (languageCode == null) { + return systemLanguageFallback; + } + for (final option in supportedTranslationLanguages) { + if (option.code == languageCode) { + return option.label; + } + } + return languageCode.toUpperCase(); +} diff --git a/lib/widgets/translated_message_content.dart b/lib/widgets/translated_message_content.dart new file mode 100644 index 0000000..3495897 --- /dev/null +++ b/lib/widgets/translated_message_content.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; + +import '../helpers/link_handler.dart'; + +class TranslatedMessageContent extends StatelessWidget { + final String displayText; + final String? originalText; + final TextStyle style; + final TextStyle? originalStyle; + final bool showOriginalFirst; + + const TranslatedMessageContent({ + super.key, + required this.displayText, + required this.style, + this.originalText, + this.originalStyle, + this.showOriginalFirst = true, + }); + + @override + Widget build(BuildContext context) { + final trimmedDisplay = displayText.trim(); + final trimmedOriginal = originalText?.trim(); + final shouldShowOriginal = + trimmedOriginal != null && + trimmedOriginal.isNotEmpty && + trimmedOriginal != trimmedDisplay; + final originalWidget = shouldShowOriginal + ? LinkHandler.buildLinkifyText( + context: context, + text: trimmedOriginal, + style: + originalStyle ?? + style.copyWith( + fontStyle: FontStyle.italic, + fontSize: style.fontSize, + ), + ) + : null; + final translatedWidget = LinkHandler.buildLinkifyText( + context: context, + text: trimmedDisplay, + style: style, + ); + + if (!shouldShowOriginal) { + return translatedWidget; + } + + final children = showOriginalFirst + ? [originalWidget!, const SizedBox(height: 6), translatedWidget] + : [translatedWidget, const SizedBox(height: 6), originalWidget!]; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: children, + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 39e9f96..af07196 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -71,6 +71,17 @@ dependencies: flutter_blue_plus_platform_interface: ^8.2.1 ml_algo: ^16.0.0 ml_dataframe: ^1.0.0 + llamadart: '>=0.6.8 <0.7.0' + +hooks: + user_defines: + llamadart: + llamadart_native_backends: + platforms: + android-arm64: + backends: [cpu] + android-x64: + backends: [cpu] dev_dependencies: flutter_test: