From 14ff8250c09c53b39f616a7e4ba2addf7769ce11 Mon Sep 17 00:00:00 2001 From: zjs81 Date: Fri, 16 Jan 2026 19:06:39 -0700 Subject: [PATCH] Add support for private and hashtag channels in localization and channel management - Updated Polish, Portuguese, Slovak, Slovenian, Swedish, and Chinese localization files to include new strings for creating and joining private channels, as well as joining hashtag channels. - Enhanced the channel management UI to allow users to create and join private channels, join public channels, and join channels via hashtags. - Implemented PSK derivation from hashtags using SHA256 in the Channel model. - Improved the translation script to handle missing keys and translate all locales efficiently. --- lib/l10n/app_bg.arb | 14 +- lib/l10n/app_de.arb | 14 +- lib/l10n/app_en.arb | 12 + lib/l10n/app_es.arb | 14 +- lib/l10n/app_fr.arb | 14 +- lib/l10n/app_it.arb | 14 +- lib/l10n/app_localizations.dart | 72 +++++ lib/l10n/app_localizations_bg.dart | 39 +++ lib/l10n/app_localizations_de.dart | 42 +++ lib/l10n/app_localizations_en.dart | 37 +++ lib/l10n/app_localizations_es.dart | 40 +++ lib/l10n/app_localizations_fr.dart | 40 +++ lib/l10n/app_localizations_it.dart | 40 +++ lib/l10n/app_localizations_nl.dart | 40 +++ lib/l10n/app_localizations_pl.dart | 40 +++ lib/l10n/app_localizations_pt.dart | 40 +++ lib/l10n/app_localizations_sk.dart | 39 +++ lib/l10n/app_localizations_sl.dart | 39 +++ lib/l10n/app_localizations_sv.dart | 40 +++ lib/l10n/app_localizations_zh.dart | 36 +++ lib/l10n/app_nl.arb | 14 +- lib/l10n/app_pl.arb | 14 +- lib/l10n/app_pt.arb | 14 +- lib/l10n/app_sk.arb | 14 +- lib/l10n/app_sl.arb | 14 +- lib/l10n/app_sv.arb | 14 +- lib/l10n/app_zh.arb | 14 +- lib/models/channel.dart | 12 + lib/screens/channels_screen.dart | 408 +++++++++++++++++++++-------- tools/translate.py | 207 +++++++++++++-- 30 files changed, 1250 insertions(+), 141 deletions(-) diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb index 4c747da..c17f117 100644 --- a/lib/l10n/app_bg.arb +++ b/lib/l10n/app_bg.arb @@ -1335,5 +1335,17 @@ "listFilter_repeaters": "Повторители", "listFilter_roomServers": "Сървъри на стая", "listFilter_unreadOnly": "Само непрочетените", - "listFilter_newGroup": "Нова група" + "listFilter_newGroup": "Нова група", + "channels_createPrivateChannel": "Създай Частен Канал", + "channels_joinPrivateChannel": "Присъедини се към Частен Канал", + "channels_createPrivateChannelDesc": "Защитено с таен ключ.", + "channels_joinPrivateChannelDesc": "Ръчно въведете таен ключ.", + "channels_joinPublicChannel": "Присъединете се към Публичния канал", + "channels_joinPublicChannelDesc": "Всеки може да се присъедини към този канал.", + "channels_joinHashtagChannel": "Присъедини се към Хаштаг Канал", + "channels_joinHashtagChannelDesc": "Всеки може да се присъедини към хаштаговите канали.", + "channels_scanQrCode": "Сканирайте QR код", + "channels_scanQrCodeComingSoon": "Ще излезе скоро", + "channels_enterHashtag": "Въведете хаштаг", + "channels_hashtagHint": "напр. #отбор" } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 9044962..6c79f2d 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1335,5 +1335,17 @@ "listFilter_repeaters": "Wiederholer", "listFilter_roomServers": "Raumserver", "listFilter_unreadOnly": "Nur nicht gelesen", - "listFilter_newGroup": "Neue Gruppe" + "listFilter_newGroup": "Neue Gruppe", + "channels_joinPrivateChannel": "Treten Sie einem privaten Kanal bei", + "channels_joinPrivateChannelDesc": "Manuelle Eingabe eines geheimen Schlüssels.", + "channels_createPrivateChannel": "Erstelle einen privaten Kanal", + "channels_createPrivateChannelDesc": "Verschlüsselt mit einem geheimen Schlüssel.", + "channels_joinPublicChannel": "Tritt dem öffentlichen Kanal bei", + "channels_joinPublicChannelDesc": "Jeder kann diesem Kanal beitreten.", + "channels_joinHashtagChannel": "Treten Sie einem Hashtag-Kanal bei", + "channels_joinHashtagChannelDesc": "Jeder kann sich bei Hashtag-Kanälen beteiligen.", + "channels_scanQrCode": "Scannen Sie einen QR-Code", + "channels_scanQrCodeComingSoon": "Bald verfügbar", + "channels_enterHashtag": "Gib Hashtag ein", + "channels_hashtagHint": "z.B. #team" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index dc53f73..8a647b4 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -361,6 +361,18 @@ "channels_sortAZ": "A-Z", "channels_sortLatestMessages": "Latest messages", "channels_sortUnread": "Unread", + "channels_createPrivateChannel": "Create a Private Channel", + "channels_createPrivateChannelDesc": "Secured with a secret key.", + "channels_joinPrivateChannel": "Join a Private Channel", + "channels_joinPrivateChannelDesc": "Manually enter a secret key.", + "channels_joinPublicChannel": "Join the Public Channel", + "channels_joinPublicChannelDesc": "Anyone can join this channel.", + "channels_joinHashtagChannel": "Join a Hashtag Channel", + "channels_joinHashtagChannelDesc": "Anyone can join hashtag channels.", + "channels_scanQrCode": "Scan a QR Code", + "channels_scanQrCodeComingSoon": "Coming soon", + "channels_enterHashtag": "Enter hashtag", + "channels_hashtagHint": "e.g. #team", "chat_noMessages": "No messages yet", "chat_sendMessageToStart": "Send a message to get started", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 1515eb6..5a2672e 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1335,5 +1335,17 @@ "listFilter_repeaters": "Repetidores", "listFilter_roomServers": "Servidores de la sala", "listFilter_unreadOnly": "Solo sin leer", - "listFilter_newGroup": "Nuevo grupo" + "listFilter_newGroup": "Nuevo grupo", + "channels_joinPrivateChannel": "Únete a un Canal Privado", + "channels_createPrivateChannel": "Crear un Canal Privado", + "channels_createPrivateChannelDesc": "Cifrado con una clave secreta.", + "channels_joinPrivateChannelDesc": "Introducir manualmente una clave secreta.", + "channels_joinPublicChannel": "Únete al Canal Público", + "channels_joinPublicChannelDesc": "Cualquiera puede unirse a este canal.", + "channels_joinHashtagChannel": "Únete a un Canal con Hashtag", + "channels_joinHashtagChannelDesc": "Cualquiera puede unirse a los canales de hashtag.", + "channels_scanQrCode": "Escanear un Código QR", + "channels_scanQrCodeComingSoon": "Próximamente", + "channels_enterHashtag": "Introducir hashtag", + "channels_hashtagHint": "ej. #equipo" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 6a09a33..60ac827 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1335,5 +1335,17 @@ "listFilter_repeaters": "Répéteurs", "listFilter_roomServers": "Serveurs de pièce", "listFilter_unreadOnly": "Messages non lus seulement", - "listFilter_newGroup": "Nouvelle groupe" + "listFilter_newGroup": "Nouvelle groupe", + "channels_createPrivateChannelDesc": "Sécurisé avec une clé secrète.", + "channels_joinPrivateChannel": "Rejoindre un Canal Privé", + "channels_createPrivateChannel": "Créer un Canal Privé", + "channels_joinPrivateChannelDesc": "Entrer manuellement une clé secrète.", + "channels_joinPublicChannel": "Rejoindre le canal public", + "channels_joinPublicChannelDesc": "Tout le monde peut rejoindre ce canal.", + "channels_joinHashtagChannel": "Rejoindre un Canal Hashtag", + "channels_joinHashtagChannelDesc": "N'importe qui peut rejoindre les canaux #hashtag.", + "channels_scanQrCode": "Scanner un code QR", + "channels_scanQrCodeComingSoon": "Bientôt disponible", + "channels_enterHashtag": "Entrez le hashtag", + "channels_hashtagHint": "ex. #équipe" } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 72be091..08163c6 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1335,5 +1335,17 @@ "listFilter_repeaters": "Ripetitori", "listFilter_roomServers": "Server della stanza", "listFilter_unreadOnly": "Solo non letto", - "listFilter_newGroup": "Nuovo gruppo" + "listFilter_newGroup": "Nuovo gruppo", + "channels_createPrivateChannel": "Crea un Canale Privato", + "channels_createPrivateChannelDesc": "Protetta con una chiave segreta.", + "channels_joinPrivateChannel": "Unisciti a un Canale Privato", + "channels_joinPrivateChannelDesc": "Inserire manualmente una chiave segreta.", + "channels_joinPublicChannel": "Unisciti al Canale Pubblico", + "channels_joinPublicChannelDesc": "Chiunque può unirsi a questo canale.", + "channels_joinHashtagChannel": "Unisciti a un Canale con Hashtag", + "channels_joinHashtagChannelDesc": "Chiunque può unirsi ai canali hashtag.", + "channels_scanQrCode": "Scansiona un codice QR", + "channels_scanQrCodeComingSoon": "Arriverà presto", + "channels_enterHashtag": "Inserisci hashtag", + "channels_hashtagHint": "es. #team" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index a2c7ddb..e0c1f8e 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1596,6 +1596,78 @@ abstract class AppLocalizations { /// **'Unread'** String get channels_sortUnread; + /// No description provided for @channels_createPrivateChannel. + /// + /// In en, this message translates to: + /// **'Create a Private Channel'** + String get channels_createPrivateChannel; + + /// No description provided for @channels_createPrivateChannelDesc. + /// + /// In en, this message translates to: + /// **'Secured with a secret key.'** + String get channels_createPrivateChannelDesc; + + /// No description provided for @channels_joinPrivateChannel. + /// + /// In en, this message translates to: + /// **'Join a Private Channel'** + String get channels_joinPrivateChannel; + + /// No description provided for @channels_joinPrivateChannelDesc. + /// + /// In en, this message translates to: + /// **'Manually enter a secret key.'** + String get channels_joinPrivateChannelDesc; + + /// No description provided for @channels_joinPublicChannel. + /// + /// In en, this message translates to: + /// **'Join the Public Channel'** + String get channels_joinPublicChannel; + + /// No description provided for @channels_joinPublicChannelDesc. + /// + /// In en, this message translates to: + /// **'Anyone can join this channel.'** + String get channels_joinPublicChannelDesc; + + /// No description provided for @channels_joinHashtagChannel. + /// + /// In en, this message translates to: + /// **'Join a Hashtag Channel'** + String get channels_joinHashtagChannel; + + /// No description provided for @channels_joinHashtagChannelDesc. + /// + /// In en, this message translates to: + /// **'Anyone can join hashtag channels.'** + String get channels_joinHashtagChannelDesc; + + /// No description provided for @channels_scanQrCode. + /// + /// In en, this message translates to: + /// **'Scan a QR Code'** + String get channels_scanQrCode; + + /// No description provided for @channels_scanQrCodeComingSoon. + /// + /// In en, this message translates to: + /// **'Coming soon'** + String get channels_scanQrCodeComingSoon; + + /// No description provided for @channels_enterHashtag. + /// + /// In en, this message translates to: + /// **'Enter hashtag'** + String get channels_enterHashtag; + + /// No description provided for @channels_hashtagHint. + /// + /// In en, this message translates to: + /// **'e.g. #team'** + String get channels_hashtagHint; + /// No description provided for @chat_noMessages. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index 5def822..019566a 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -830,6 +830,45 @@ class AppLocalizationsBg extends AppLocalizations { @override String get channels_sortUnread => 'Непрочетено'; + @override + String get channels_createPrivateChannel => 'Създай Частен Канал'; + + @override + String get channels_createPrivateChannelDesc => 'Защитено с таен ключ.'; + + @override + String get channels_joinPrivateChannel => 'Присъедини се към Частен Канал'; + + @override + String get channels_joinPrivateChannelDesc => 'Ръчно въведете таен ключ.'; + + @override + String get channels_joinPublicChannel => + 'Присъединете се към Публичния канал'; + + @override + String get channels_joinPublicChannelDesc => + 'Всеки може да се присъедини към този канал.'; + + @override + String get channels_joinHashtagChannel => 'Присъедини се към Хаштаг Канал'; + + @override + String get channels_joinHashtagChannelDesc => + 'Всеки може да се присъедини към хаштаговите канали.'; + + @override + String get channels_scanQrCode => 'Сканирайте QR код'; + + @override + String get channels_scanQrCodeComingSoon => 'Ще излезе скоро'; + + @override + String get channels_enterHashtag => 'Въведете хаштаг'; + + @override + String get channels_hashtagHint => 'напр. #отбор'; + @override String get chat_noMessages => 'Няма съобщения.'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 591de39..1ae2fb4 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -828,6 +828,48 @@ class AppLocalizationsDe extends AppLocalizations { @override String get channels_sortUnread => 'Unlescht'; + @override + String get channels_createPrivateChannel => 'Erstelle einen privaten Kanal'; + + @override + String get channels_createPrivateChannelDesc => + 'Verschlüsselt mit einem geheimen Schlüssel.'; + + @override + String get channels_joinPrivateChannel => + 'Treten Sie einem privaten Kanal bei'; + + @override + String get channels_joinPrivateChannelDesc => + 'Manuelle Eingabe eines geheimen Schlüssels.'; + + @override + String get channels_joinPublicChannel => 'Tritt dem öffentlichen Kanal bei'; + + @override + String get channels_joinPublicChannelDesc => + 'Jeder kann diesem Kanal beitreten.'; + + @override + String get channels_joinHashtagChannel => + 'Treten Sie einem Hashtag-Kanal bei'; + + @override + String get channels_joinHashtagChannelDesc => + 'Jeder kann sich bei Hashtag-Kanälen beteiligen.'; + + @override + String get channels_scanQrCode => 'Scannen Sie einen QR-Code'; + + @override + String get channels_scanQrCodeComingSoon => 'Bald verfügbar'; + + @override + String get channels_enterHashtag => 'Gib Hashtag ein'; + + @override + String get channels_hashtagHint => 'z.B. #team'; + @override String get chat_noMessages => 'Noch keine Nachrichten.'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 9e04923..a98546e 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -818,6 +818,43 @@ class AppLocalizationsEn extends AppLocalizations { @override String get channels_sortUnread => 'Unread'; + @override + String get channels_createPrivateChannel => 'Create a Private Channel'; + + @override + String get channels_createPrivateChannelDesc => 'Secured with a secret key.'; + + @override + String get channels_joinPrivateChannel => 'Join a Private Channel'; + + @override + String get channels_joinPrivateChannelDesc => 'Manually enter a secret key.'; + + @override + String get channels_joinPublicChannel => 'Join the Public Channel'; + + @override + String get channels_joinPublicChannelDesc => 'Anyone can join this channel.'; + + @override + String get channels_joinHashtagChannel => 'Join a Hashtag Channel'; + + @override + String get channels_joinHashtagChannelDesc => + 'Anyone can join hashtag channels.'; + + @override + String get channels_scanQrCode => 'Scan a QR Code'; + + @override + String get channels_scanQrCodeComingSoon => 'Coming soon'; + + @override + String get channels_enterHashtag => 'Enter hashtag'; + + @override + String get channels_hashtagHint => 'e.g. #team'; + @override String get chat_noMessages => 'No messages yet'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 82259cf..aa3926e 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -829,6 +829,46 @@ class AppLocalizationsEs extends AppLocalizations { @override String get channels_sortUnread => 'Sin leer'; + @override + String get channels_createPrivateChannel => 'Crear un Canal Privado'; + + @override + String get channels_createPrivateChannelDesc => + 'Cifrado con una clave secreta.'; + + @override + String get channels_joinPrivateChannel => 'Únete a un Canal Privado'; + + @override + String get channels_joinPrivateChannelDesc => + 'Introducir manualmente una clave secreta.'; + + @override + String get channels_joinPublicChannel => 'Únete al Canal Público'; + + @override + String get channels_joinPublicChannelDesc => + 'Cualquiera puede unirse a este canal.'; + + @override + String get channels_joinHashtagChannel => 'Únete a un Canal con Hashtag'; + + @override + String get channels_joinHashtagChannelDesc => + 'Cualquiera puede unirse a los canales de hashtag.'; + + @override + String get channels_scanQrCode => 'Escanear un Código QR'; + + @override + String get channels_scanQrCodeComingSoon => 'Próximamente'; + + @override + String get channels_enterHashtag => 'Introducir hashtag'; + + @override + String get channels_hashtagHint => 'ej. #equipo'; + @override String get chat_noMessages => 'Aún no hay mensajes'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index c7735bc..f8e89cc 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -830,6 +830,46 @@ class AppLocalizationsFr extends AppLocalizations { @override String get channels_sortUnread => 'Non lu'; + @override + String get channels_createPrivateChannel => 'Créer un Canal Privé'; + + @override + String get channels_createPrivateChannelDesc => + 'Sécurisé avec une clé secrète.'; + + @override + String get channels_joinPrivateChannel => 'Rejoindre un Canal Privé'; + + @override + String get channels_joinPrivateChannelDesc => + 'Entrer manuellement une clé secrète.'; + + @override + String get channels_joinPublicChannel => 'Rejoindre le canal public'; + + @override + String get channels_joinPublicChannelDesc => + 'Tout le monde peut rejoindre ce canal.'; + + @override + String get channels_joinHashtagChannel => 'Rejoindre un Canal Hashtag'; + + @override + String get channels_joinHashtagChannelDesc => + 'N\'importe qui peut rejoindre les canaux #hashtag.'; + + @override + String get channels_scanQrCode => 'Scanner un code QR'; + + @override + String get channels_scanQrCodeComingSoon => 'Bientôt disponible'; + + @override + String get channels_enterHashtag => 'Entrez le hashtag'; + + @override + String get channels_hashtagHint => 'ex. #équipe'; + @override String get chat_noMessages => 'Aucun message pour le moment.'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index cd2d022..1b15148 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -827,6 +827,46 @@ class AppLocalizationsIt extends AppLocalizations { @override String get channels_sortUnread => 'Non letto'; + @override + String get channels_createPrivateChannel => 'Crea un Canale Privato'; + + @override + String get channels_createPrivateChannelDesc => + 'Protetta con una chiave segreta.'; + + @override + String get channels_joinPrivateChannel => 'Unisciti a un Canale Privato'; + + @override + String get channels_joinPrivateChannelDesc => + 'Inserire manualmente una chiave segreta.'; + + @override + String get channels_joinPublicChannel => 'Unisciti al Canale Pubblico'; + + @override + String get channels_joinPublicChannelDesc => + 'Chiunque può unirsi a questo canale.'; + + @override + String get channels_joinHashtagChannel => 'Unisciti a un Canale con Hashtag'; + + @override + String get channels_joinHashtagChannelDesc => + 'Chiunque può unirsi ai canali hashtag.'; + + @override + String get channels_scanQrCode => 'Scansiona un codice QR'; + + @override + String get channels_scanQrCodeComingSoon => 'Arriverà presto'; + + @override + String get channels_enterHashtag => 'Inserisci hashtag'; + + @override + String get channels_hashtagHint => 'es. #team'; + @override String get chat_noMessages => 'Nessun messaggio ancora'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index b9ae792..0d9f807 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -824,6 +824,46 @@ class AppLocalizationsNl extends AppLocalizations { @override String get channels_sortUnread => 'Ongelezen'; + @override + String get channels_createPrivateChannel => 'Maak een Privé Kanaal'; + + @override + String get channels_createPrivateChannelDesc => + 'Beveiligd met een geheime sleutel.'; + + @override + String get channels_joinPrivateChannel => 'Sluit een Privé Kanaal aan'; + + @override + String get channels_joinPrivateChannelDesc => + 'Handmatig een geheime sleutel invoeren.'; + + @override + String get channels_joinPublicChannel => 'Sluit het Open Kanaal'; + + @override + String get channels_joinPublicChannelDesc => + 'Iedereen kan dit kanaal aanmelden.'; + + @override + String get channels_joinHashtagChannel => 'Sluit een Hashtag Kanaal'; + + @override + String get channels_joinHashtagChannelDesc => + 'Iedereen kan lid worden van hashtag-kanalen.'; + + @override + String get channels_scanQrCode => 'Scan een QR-code'; + + @override + String get channels_scanQrCodeComingSoon => 'Komt later'; + + @override + String get channels_enterHashtag => 'Voer hashtag in'; + + @override + String get channels_hashtagHint => 'bijv. #team'; + @override String get chat_noMessages => 'Nog geen berichten.'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 180d8e2..6626308 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -828,6 +828,46 @@ class AppLocalizationsPl extends AppLocalizations { @override String get channels_sortUnread => 'Niezgłoszone'; + @override + String get channels_createPrivateChannel => 'Utwórz Prywatny Kanał'; + + @override + String get channels_createPrivateChannelDesc => + 'Zabezpieczone kluczem szyfrowym.'; + + @override + String get channels_joinPrivateChannel => 'Dołącz do Prywatnego Kanału'; + + @override + String get channels_joinPrivateChannelDesc => 'Ręcznie wprowadź klucz tajny.'; + + @override + String get channels_joinPublicChannel => 'Dołącz do kanału publicznego.'; + + @override + String get channels_joinPublicChannelDesc => + 'Każdy może dołączyć do tego kanału.'; + + @override + String get channels_joinHashtagChannel => + 'Dołącz do kanału oznaczanego hashtagiem'; + + @override + String get channels_joinHashtagChannelDesc => + 'Każdy może dołączyć do kanałów z hashtagami.'; + + @override + String get channels_scanQrCode => 'Skanuj kod QR'; + + @override + String get channels_scanQrCodeComingSoon => 'Wkrótce'; + + @override + String get channels_enterHashtag => 'Wprowadź hashtag'; + + @override + String get channels_hashtagHint => 'np. #zespół'; + @override String get chat_noMessages => 'Brak jeszcze wiadomości'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 403c4d6..614ff0d 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -829,6 +829,46 @@ class AppLocalizationsPt extends AppLocalizations { @override String get channels_sortUnread => 'Não lido'; + @override + String get channels_createPrivateChannel => 'Criar um Canal Privado'; + + @override + String get channels_createPrivateChannelDesc => + 'Protegido com uma chave secreta.'; + + @override + String get channels_joinPrivateChannel => 'Junte-se a um Canal Privado'; + + @override + String get channels_joinPrivateChannelDesc => + 'Inserir uma chave secreta manualmente.'; + + @override + String get channels_joinPublicChannel => 'Junte-se ao Canal Público'; + + @override + String get channels_joinPublicChannelDesc => + 'Qualquer pessoa pode entrar neste canal.'; + + @override + String get channels_joinHashtagChannel => 'Junte-se a um Canal com Hashtag'; + + @override + String get channels_joinHashtagChannelDesc => + 'Qualquer pessoa pode participar de canais com hashtag.'; + + @override + String get channels_scanQrCode => 'Digitalizar um Código QR'; + + @override + String get channels_scanQrCodeComingSoon => 'Em breve'; + + @override + String get channels_enterHashtag => 'Insira hashtag'; + + @override + String get channels_hashtagHint => 'ex. #equipe'; + @override String get chat_noMessages => 'Ainda não existem mensagens.'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index e9eb10c..e3747f2 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -824,6 +824,45 @@ class AppLocalizationsSk extends AppLocalizations { @override String get channels_sortUnread => 'Nezriadené'; + @override + String get channels_createPrivateChannel => 'Vytvorte súkromný kanál'; + + @override + String get channels_createPrivateChannelDesc => + 'Zabezpečené pomocou tajného kľúča.'; + + @override + String get channels_joinPrivateChannel => 'Pripojiť sa k súkromnému kanálu'; + + @override + String get channels_joinPrivateChannelDesc => 'Ručne zadajte tajný kľúč.'; + + @override + String get channels_joinPublicChannel => 'Pripojte sa k verejnému kanálu'; + + @override + String get channels_joinPublicChannelDesc => + 'Któvek sátó na tutó kanalizovát.'; + + @override + String get channels_joinHashtagChannel => 'Pripojte sa k Hashtag Kanálu'; + + @override + String get channels_joinHashtagChannelDesc => + 'Ktoekolikoľvek sa môže pridať do hashtag kanálov.'; + + @override + String get channels_scanQrCode => 'Skenujte QR kód'; + + @override + String get channels_scanQrCodeComingSoon => 'Čoskoro'; + + @override + String get channels_enterHashtag => 'Zadajte hashtag'; + + @override + String get channels_hashtagHint => 'napr. #tím'; + @override String get chat_noMessages => 'Zatiaľ žiadne správy.'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index f95797b..4d2ad7f 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -824,6 +824,45 @@ class AppLocalizationsSl extends AppLocalizations { @override String get channels_sortUnread => 'Nerešeno'; + @override + String get channels_createPrivateChannel => 'Ustvari zasebno kanal.'; + + @override + String get channels_createPrivateChannelDesc => + 'Varno zaklenjeno s skrivnim ključem.'; + + @override + String get channels_joinPrivateChannel => 'Pridružite se zasebni skupini'; + + @override + String get channels_joinPrivateChannelDesc => 'Ročno vnesite zaporni ključ.'; + + @override + String get channels_joinPublicChannel => 'Pridružite se javnemu kanalu'; + + @override + String get channels_joinPublicChannelDesc => + 'Kdor karkoli je, lahko se pridruži tej skupini.'; + + @override + String get channels_joinHashtagChannel => 'Pridružite se Kanalu z Hashtagom'; + + @override + String get channels_joinHashtagChannelDesc => + 'Kdor karkoli, lahko se pridruži hashtag kanalom.'; + + @override + String get channels_scanQrCode => 'Skeniraj QR kodo'; + + @override + String get channels_scanQrCodeComingSoon => 'Prihajajoča'; + + @override + String get channels_enterHashtag => 'Vnesite hashtag'; + + @override + String get channels_hashtagHint => 'npr. #ekipa'; + @override String get chat_noMessages => 'Še ni sporočil.'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index 6f97659..d170b4a 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -817,6 +817,46 @@ class AppLocalizationsSv extends AppLocalizations { @override String get channels_sortUnread => 'Oläst'; + @override + String get channels_createPrivateChannel => 'Skapa en privat kanal'; + + @override + String get channels_createPrivateChannelDesc => + 'Skyddat med en hemlig nyckel.'; + + @override + String get channels_joinPrivateChannel => 'Gå med i en Privat Kanal'; + + @override + String get channels_joinPrivateChannelDesc => + 'Ange en hemlig nyckel manuellt.'; + + @override + String get channels_joinPublicChannel => 'Gå med i den Offentliga Kanalen'; + + @override + String get channels_joinPublicChannelDesc => + 'Vem som helst kan gå med i denna kanal.'; + + @override + String get channels_joinHashtagChannel => 'Gå med i en Hashtagkanal'; + + @override + String get channels_joinHashtagChannelDesc => + 'Väldigt enkelt att gå med i hashtag-kanaler.'; + + @override + String get channels_scanQrCode => 'Skanna en QR-kod'; + + @override + String get channels_scanQrCodeComingSoon => 'Kommer snart'; + + @override + String get channels_enterHashtag => 'Ange hashtag'; + + @override + String get channels_hashtagHint => 't.ex. #team'; + @override String get chat_noMessages => 'Inga meddelanden ännu'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index e5b5a9f..7d02bf5 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -789,6 +789,42 @@ class AppLocalizationsZh extends AppLocalizations { @override String get channels_sortUnread => '未读'; + @override + String get channels_createPrivateChannel => '创建私聊频道'; + + @override + String get channels_createPrivateChannelDesc => '使用密钥保护。'; + + @override + String get channels_joinPrivateChannel => '加入私密频道'; + + @override + String get channels_joinPrivateChannelDesc => '手动输入密钥。'; + + @override + String get channels_joinPublicChannel => '加入公共频道'; + + @override + String get channels_joinPublicChannelDesc => '任何人都可以加入这个频道。'; + + @override + String get channels_joinHashtagChannel => '加入标签频道'; + + @override + String get channels_joinHashtagChannelDesc => '任何人都可以加入话题频道。'; + + @override + String get channels_scanQrCode => '扫描二维码'; + + @override + String get channels_scanQrCodeComingSoon => '即将到来'; + + @override + String get channels_enterHashtag => '输入标签'; + + @override + String get channels_hashtagHint => '例如 #团队'; + @override String get chat_noMessages => '目前还没有消息'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 79fc69d..f78fc68 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -1335,5 +1335,17 @@ "listFilter_repeaters": "Repeaters", "listFilter_roomServers": "Roomservers", "listFilter_unreadOnly": "Alleen ongelezen", - "listFilter_newGroup": "Nieuwe groep" + "listFilter_newGroup": "Nieuwe groep", + "channels_createPrivateChannelDesc": "Beveiligd met een geheime sleutel.", + "channels_createPrivateChannel": "Maak een Privé Kanaal", + "channels_joinPrivateChannel": "Sluit een Privé Kanaal aan", + "channels_joinPrivateChannelDesc": "Handmatig een geheime sleutel invoeren.", + "channels_joinPublicChannel": "Sluit het Open Kanaal", + "channels_joinPublicChannelDesc": "Iedereen kan dit kanaal aanmelden.", + "channels_joinHashtagChannel": "Sluit een Hashtag Kanaal", + "channels_joinHashtagChannelDesc": "Iedereen kan lid worden van hashtag-kanalen.", + "channels_scanQrCode": "Scan een QR-code", + "channels_scanQrCodeComingSoon": "Komt later", + "channels_enterHashtag": "Voer hashtag in", + "channels_hashtagHint": "bijv. #team" } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index bab67b5..24edcaf 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1335,5 +1335,17 @@ "listFilter_repeaters": "Powtarzacze", "listFilter_roomServers": "Serwery pokoju", "listFilter_unreadOnly": "Tylko nieprzeczytane", - "listFilter_newGroup": "Nowa grupa" + "listFilter_newGroup": "Nowa grupa", + "channels_joinPrivateChannelDesc": "Ręcznie wprowadź klucz tajny.", + "channels_createPrivateChannel": "Utwórz Prywatny Kanał", + "channels_createPrivateChannelDesc": "Zabezpieczone kluczem szyfrowym.", + "channels_joinPrivateChannel": "Dołącz do Prywatnego Kanału", + "channels_joinPublicChannel": "Dołącz do kanału publicznego.", + "channels_joinPublicChannelDesc": "Każdy może dołączyć do tego kanału.", + "channels_joinHashtagChannel": "Dołącz do kanału oznaczanego hashtagiem", + "channels_joinHashtagChannelDesc": "Każdy może dołączyć do kanałów z hashtagami.", + "channels_scanQrCode": "Skanuj kod QR", + "channels_scanQrCodeComingSoon": "Wkrótce", + "channels_enterHashtag": "Wprowadź hashtag", + "channels_hashtagHint": "np. #zespół" } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 59f4a47..47f9cef 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -1335,5 +1335,17 @@ "listFilter_repeaters": "Repetidores", "listFilter_roomServers": "Servidores de sala", "listFilter_unreadOnly": "Apenas não lido", - "listFilter_newGroup": "Novo grupo" + "listFilter_newGroup": "Novo grupo", + "channels_createPrivateChannelDesc": "Protegido com uma chave secreta.", + "channels_joinPrivateChannelDesc": "Inserir uma chave secreta manualmente.", + "channels_createPrivateChannel": "Criar um Canal Privado", + "channels_joinPrivateChannel": "Junte-se a um Canal Privado", + "channels_joinPublicChannel": "Junte-se ao Canal Público", + "channels_joinPublicChannelDesc": "Qualquer pessoa pode entrar neste canal.", + "channels_joinHashtagChannel": "Junte-se a um Canal com Hashtag", + "channels_joinHashtagChannelDesc": "Qualquer pessoa pode participar de canais com hashtag.", + "channels_scanQrCode": "Digitalizar um Código QR", + "channels_scanQrCodeComingSoon": "Em breve", + "channels_enterHashtag": "Insira hashtag", + "channels_hashtagHint": "ex. #equipe" } diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb index 67043a7..752c8d4 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -1335,5 +1335,17 @@ "listFilter_repeaters": "Opakovadlá", "listFilter_roomServers": "Servéry miestnosti", "listFilter_unreadOnly": "Nezaregistrované len", - "listFilter_newGroup": "Nová skupina" + "listFilter_newGroup": "Nová skupina", + "channels_createPrivateChannel": "Vytvorte súkromný kanál", + "channels_joinPrivateChannel": "Pripojiť sa k súkromnému kanálu", + "channels_joinPrivateChannelDesc": "Ručne zadajte tajný kľúč.", + "channels_createPrivateChannelDesc": "Zabezpečené pomocou tajného kľúča.", + "channels_joinPublicChannel": "Pripojte sa k verejnému kanálu", + "channels_joinPublicChannelDesc": "Któvek sátó na tutó kanalizovát.", + "channels_joinHashtagChannel": "Pripojte sa k Hashtag Kanálu", + "channels_joinHashtagChannelDesc": "Ktoekolikoľvek sa môže pridať do hashtag kanálov.", + "channels_scanQrCode": "Skenujte QR kód", + "channels_scanQrCodeComingSoon": "Čoskoro", + "channels_enterHashtag": "Zadajte hashtag", + "channels_hashtagHint": "napr. #tím" } diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index 8a8dc59..3e40f2d 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -1335,5 +1335,17 @@ "listFilter_repeaters": "Ponovitve", "listFilter_roomServers": "Smeti za prostore", "listFilter_unreadOnly": "Nezbrani samo", - "listFilter_newGroup": "Nova skupina" + "listFilter_newGroup": "Nova skupina", + "channels_joinPrivateChannel": "Pridružite se zasebni skupini", + "channels_createPrivateChannelDesc": "Varno zaklenjeno s skrivnim ključem.", + "channels_joinPrivateChannelDesc": "Ročno vnesite zaporni ključ.", + "channels_createPrivateChannel": "Ustvari zasebno kanal.", + "channels_joinPublicChannel": "Pridružite se javnemu kanalu", + "channels_joinPublicChannelDesc": "Kdor karkoli je, lahko se pridruži tej skupini.", + "channels_joinHashtagChannel": "Pridružite se Kanalu z Hashtagom", + "channels_joinHashtagChannelDesc": "Kdor karkoli, lahko se pridruži hashtag kanalom.", + "channels_scanQrCode": "Skeniraj QR kodo", + "channels_scanQrCodeComingSoon": "Prihajajoča", + "channels_enterHashtag": "Vnesite hashtag", + "channels_hashtagHint": "npr. #ekipa" } diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index 866e438..35ec92b 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -1335,5 +1335,17 @@ "listFilter_repeaters": "Upprepare", "listFilter_roomServers": "Rumservrar", "listFilter_unreadOnly": "Endast oinlästa", - "listFilter_newGroup": "Ny grupp" + "listFilter_newGroup": "Ny grupp", + "channels_createPrivateChannel": "Skapa en privat kanal", + "channels_joinPrivateChannel": "Gå med i en Privat Kanal", + "channels_joinPrivateChannelDesc": "Ange en hemlig nyckel manuellt.", + "channels_createPrivateChannelDesc": "Skyddat med en hemlig nyckel.", + "channels_joinPublicChannel": "Gå med i den Offentliga Kanalen", + "channels_joinPublicChannelDesc": "Vem som helst kan gå med i denna kanal.", + "channels_joinHashtagChannel": "Gå med i en Hashtagkanal", + "channels_joinHashtagChannelDesc": "Väldigt enkelt att gå med i hashtag-kanaler.", + "channels_scanQrCode": "Skanna en QR-kod", + "channels_scanQrCodeComingSoon": "Kommer snart", + "channels_enterHashtag": "Ange hashtag", + "channels_hashtagHint": "t.ex. #team" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index f1349e8..33cd517 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1335,5 +1335,17 @@ "listFilter_repeaters": "重复器", "listFilter_roomServers": "房间服务器", "listFilter_unreadOnly": "未读消息", - "listFilter_newGroup": "新组" + "listFilter_newGroup": "新组", + "channels_joinPrivateChannel": "加入私密频道", + "channels_createPrivateChannelDesc": "使用密钥保护。", + "channels_joinPrivateChannelDesc": "手动输入密钥。", + "channels_createPrivateChannel": "创建私聊频道", + "channels_joinPublicChannel": "加入公共频道", + "channels_joinPublicChannelDesc": "任何人都可以加入这个频道。", + "channels_joinHashtagChannel": "加入标签频道", + "channels_joinHashtagChannelDesc": "任何人都可以加入话题频道。", + "channels_scanQrCode": "扫描二维码", + "channels_scanQrCodeComingSoon": "即将到来", + "channels_enterHashtag": "输入标签", + "channels_hashtagHint": "例如 #团队" } diff --git a/lib/models/channel.dart b/lib/models/channel.dart index 79df62d..3325280 100644 --- a/lib/models/channel.dart +++ b/lib/models/channel.dart @@ -1,5 +1,8 @@ +import 'dart:convert'; import 'dart:typed_data'; +import 'package:crypto/crypto.dart' as crypto; + import '../connector/meshcore_protocol.dart'; class Channel { @@ -61,6 +64,15 @@ class Channel { return bytes; } + /// Derive PSK from hashtag name using SHA256. + /// The hashtag is normalized to include '#' prefix. + /// Returns first 16 bytes of SHA256 hash as PSK. + static Uint8List derivePskFromHashtag(String hashtag) { + final name = hashtag.startsWith('#') ? hashtag : '#$hashtag'; + final hash = crypto.sha256.convert(utf8.encode(name)).bytes; + return Uint8List.fromList(hash.sublist(0, 16)); + } + static String formatPskHex(Uint8List psk) { return _bytesToHex(psk); } diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index a652d00..bd40e1f 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -515,132 +515,318 @@ class _ChannelsScreenState extends State void _showAddChannelDialog(BuildContext context) { final connector = context.read(); + final nextIndex = _findNextAvailableIndex(connector.channels, connector.maxChannels); + final hasPublicChannel = connector.channels.any((c) => c.isPublicChannel); + int? selectedOption; final nameController = TextEditingController(); final pskController = TextEditingController(); - final maxChannels = connector.maxChannels; - int selectedIndex = _findNextAvailableIndex(connector.channels, maxChannels); - bool usePublicPsk = false; + final hashtagController = TextEditingController(); showDialog( context: context, builder: (dialogContext) => StatefulBuilder( - builder: (dialogContext, setDialogState) => AlertDialog( - title: Text(dialogContext.l10n.channels_addChannel), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - DropdownButtonFormField( - initialValue: selectedIndex, - decoration: InputDecoration( - labelText: dialogContext.l10n.channels_channelIndexLabel, - border: const OutlineInputBorder(), - ), - items: List.generate(maxChannels, (i) => i) - .map((i) => DropdownMenuItem( - value: i, - child: Text(dialogContext.l10n.channels_channelIndex(i)), - )) - .toList(), - onChanged: (value) { - if (value != null) { - setDialogState(() => selectedIndex = value); - } - }, + builder: (dialogContext, setDialogState) { + Widget buildOptionTile({ + required int optionIndex, + required IconData icon, + required String title, + required String subtitle, + bool enabled = true, + }) { + final isSelected = selectedOption == optionIndex; + return ListTile( + leading: CircleAvatar( + backgroundColor: enabled + ? (isSelected ? Theme.of(dialogContext).colorScheme.primaryContainer : null) + : Colors.grey.withValues(alpha: 0.2), + child: Icon( + icon, + color: enabled + ? (isSelected ? Theme.of(dialogContext).colorScheme.primary : null) + : Colors.grey, ), - const SizedBox(height: 16), - TextField( - controller: nameController, - decoration: InputDecoration( - labelText: dialogContext.l10n.channels_channelName, - border: const OutlineInputBorder(), - ), - maxLength: 31, - ), - const SizedBox(height: 8), - CheckboxListTile( - title: Text(dialogContext.l10n.channels_usePublicChannel), - subtitle: Text(dialogContext.l10n.channels_standardPublicPsk), - value: usePublicPsk, - onChanged: (value) { - setDialogState(() { - usePublicPsk = value ?? false; - if (usePublicPsk) { - nameController.text = 'Public'; - pskController.text = Channel.publicChannelPsk; - } else { + ), + title: Text( + title, + style: TextStyle(color: enabled ? null : Colors.grey), + ), + subtitle: Text( + subtitle, + style: TextStyle(color: enabled ? null : Colors.grey), + ), + trailing: enabled ? const Icon(Icons.chevron_right) : null, + selected: isSelected, + onTap: enabled + ? () { + setDialogState(() { + selectedOption = optionIndex; + nameController.clear(); pskController.clear(); - } - }); - }, - ), - if (!usePublicPsk) ...[ - const SizedBox(height: 8), - TextField( - controller: pskController, - decoration: InputDecoration( - labelText: dialogContext.l10n.channels_pskHex, - border: const OutlineInputBorder(), - suffixIcon: IconButton( - icon: const Icon(Icons.casino), - tooltip: dialogContext.l10n.channels_generateRandomPsk, - onPressed: () { - final random = Random.secure(); - final bytes = Uint8List(16); - for (int i = 0; i < 16; i++) { - bytes[i] = random.nextInt(256); - } - pskController.text = Channel.formatPskHex(bytes); - }, + hashtagController.clear(); + }); + } + : null, + ); + } + + Widget? buildExpandedContent() { + switch (selectedOption) { + case 0: // Create Private Channel + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: TextField( + controller: nameController, + decoration: InputDecoration( + labelText: dialogContext.l10n.channels_channelName, + border: const OutlineInputBorder(), + ), + maxLength: 31, ), ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Expanded( + child: FilledButton( + onPressed: () { + final name = nameController.text.trim(); + if (name.isEmpty) { + ScaffoldMessenger.of(dialogContext).showSnackBar( + SnackBar(content: Text(dialogContext.l10n.channels_enterChannelName)), + ); + return; + } + final random = Random.secure(); + final psk = Uint8List(16); + for (int i = 0; i < 16; i++) { + psk[i] = random.nextInt(256); + } + Navigator.pop(dialogContext); + connector.setChannel(nextIndex, name, psk); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.channels_channelAdded(name))), + ); + } + }, + child: Text(dialogContext.l10n.common_create), + ), + ), + ], + ), + ), + ], + ); + + case 1: // Join Private Channel + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: TextField( + controller: nameController, + decoration: InputDecoration( + labelText: dialogContext.l10n.channels_channelName, + border: const OutlineInputBorder(), + ), + maxLength: 31, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: TextField( + controller: pskController, + decoration: InputDecoration( + labelText: dialogContext.l10n.channels_pskHex, + border: const OutlineInputBorder(), + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Expanded( + child: FilledButton( + onPressed: () { + final name = nameController.text.trim(); + final pskHex = pskController.text.trim(); + if (name.isEmpty) { + ScaffoldMessenger.of(dialogContext).showSnackBar( + SnackBar(content: Text(dialogContext.l10n.channels_enterChannelName)), + ); + return; + } + Uint8List psk; + try { + psk = Channel.parsePskHex(pskHex); + } on FormatException { + ScaffoldMessenger.of(dialogContext).showSnackBar( + SnackBar(content: Text(dialogContext.l10n.channels_pskMustBe32Hex)), + ); + return; + } + Navigator.pop(dialogContext); + connector.setChannel(nextIndex, name, psk); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.channels_channelAdded(name))), + ); + } + }, + child: Text(dialogContext.l10n.common_add), + ), + ), + ], + ), + ), + ], + ); + + case 2: // Join Public Channel + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Expanded( + child: FilledButton( + onPressed: () { + final psk = Channel.parsePskHex(Channel.publicChannelPsk); + Navigator.pop(dialogContext); + connector.setChannel(nextIndex, 'Public', psk); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.channels_publicChannelAdded)), + ); + } + }, + child: Text(dialogContext.l10n.common_add), + ), + ), + ], ), - ], - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext), - child: Text(dialogContext.l10n.common_cancel), - ), - FilledButton( - onPressed: () { - final name = nameController.text.trim(); - final pskHex = usePublicPsk - ? Channel.publicChannelPsk - : pskController.text.trim(); + ); - if (name.isEmpty) { - ScaffoldMessenger.of(dialogContext).showSnackBar( - SnackBar(content: Text(dialogContext.l10n.channels_enterChannelName)), - ); - return; - } + case 3: // Join Hashtag Channel + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: TextField( + controller: hashtagController, + decoration: InputDecoration( + labelText: dialogContext.l10n.channels_enterHashtag, + hintText: dialogContext.l10n.channels_hashtagHint, + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.tag), + ), + maxLength: 31, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Expanded( + child: FilledButton( + onPressed: () { + var hashtag = hashtagController.text.trim(); + if (hashtag.isEmpty) { + ScaffoldMessenger.of(dialogContext).showSnackBar( + SnackBar(content: Text(dialogContext.l10n.channels_enterChannelName)), + ); + return; + } + // Normalize hashtag name + final name = hashtag.startsWith('#') ? hashtag : '#$hashtag'; + final psk = Channel.derivePskFromHashtag(hashtag); + Navigator.pop(dialogContext); + connector.setChannel(nextIndex, name, psk); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.channels_channelAdded(name))), + ); + } + }, + child: Text(dialogContext.l10n.common_add), + ), + ), + ], + ), + ), + ], + ); - Uint8List psk; - try { - psk = Channel.parsePskHex(pskHex); - } on FormatException { - ScaffoldMessenger.of(dialogContext).showSnackBar( - SnackBar(content: Text(dialogContext.l10n.channels_pskMustBe32Hex)), - ); - return; - } + default: + return null; + } + } - Navigator.pop(dialogContext); - connector.setChannel(selectedIndex, name, psk); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.channels_channelAdded(name))), - ); - } - }, - child: Text(dialogContext.l10n.common_add), + return AlertDialog( + title: Text(dialogContext.l10n.channels_addChannel), + contentPadding: const EdgeInsets.symmetric(vertical: 16), + content: SizedBox( + width: double.maxFinite, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + buildOptionTile( + optionIndex: 0, + icon: Icons.add, + title: dialogContext.l10n.channels_createPrivateChannel, + subtitle: dialogContext.l10n.channels_createPrivateChannelDesc, + ), + if (selectedOption == 0) buildExpandedContent()!, + const Divider(height: 1), + buildOptionTile( + optionIndex: 1, + icon: Icons.lock, + title: dialogContext.l10n.channels_joinPrivateChannel, + subtitle: dialogContext.l10n.channels_joinPrivateChannelDesc, + ), + if (selectedOption == 1) buildExpandedContent()!, + if (!hasPublicChannel) ...[ + const Divider(height: 1), + buildOptionTile( + optionIndex: 2, + icon: Icons.public, + title: dialogContext.l10n.channels_joinPublicChannel, + subtitle: dialogContext.l10n.channels_joinPublicChannelDesc, + ), + if (selectedOption == 2) buildExpandedContent()!, + ], + const Divider(height: 1), + buildOptionTile( + optionIndex: 3, + icon: Icons.tag, + title: dialogContext.l10n.channels_joinHashtagChannel, + subtitle: dialogContext.l10n.channels_joinHashtagChannelDesc, + ), + if (selectedOption == 3) buildExpandedContent()!, + const Divider(height: 1), + buildOptionTile( + optionIndex: 4, + icon: Icons.qr_code, + title: dialogContext.l10n.channels_scanQrCode, + subtitle: dialogContext.l10n.channels_scanQrCodeComingSoon, + enabled: false, + ), + ], + ), + ), ), - ], - ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(dialogContext.l10n.common_close), + ), + ], + ); + }, ), ); } diff --git a/tools/translate.py b/tools/translate.py index 54dd3bc..8a82100 100644 --- a/tools/translate.py +++ b/tools/translate.py @@ -10,6 +10,7 @@ Translates ARB/JSON localization values using a local Ollama model, while: - printing progress as it runs Usage: + # Translate all strings: python translate_arb_with_ollama.py \ --in /home/zjs81/Desktop/meshcore-open/lib/l10n/app_en.arb \ --out /home/zjs81/Desktop/meshcore-open/lib/l10n/app_es.arb \ @@ -17,12 +18,28 @@ Usage: --model ministral-3:latest \ --temperature 0 \ --concurrency 4 + + # Translate only missing/untranslated strings: + python translate_arb_with_ollama.py \ + --in /home/zjs81/Desktop/meshcore-open/lib/l10n/app_en.arb \ + --out /home/zjs81/Desktop/meshcore-open/lib/l10n/app_es.arb \ + --to-locale es \ + --missing-only \ + --model ministral-3:latest + + # Translate all locales (missing strings only): + python translate_arb_with_ollama.py \ + --in /home/zjs81/Desktop/meshcore-open/lib/l10n/app_en.arb \ + --l10n-dir /home/zjs81/Desktop/meshcore-open/lib/l10n \ + --missing-only \ + --model ministral-3:latest """ from __future__ import annotations import argparse import json +import os import re import sys import time @@ -448,11 +465,48 @@ def fmt_duration(seconds: float) -> str: return f"{h}h {m2}m" +def find_missing_keys(source_data: Dict[str, Any], target_data: Dict[str, Any]) -> List[str]: + """Find keys that are in source but not in target (excluding metadata keys).""" + missing = [] + for key in source_data: + if key == "@@locale": + continue + if key.startswith("@"): + continue + if key not in target_data: + missing.append(key) + return missing + + +def get_all_locale_files(l10n_dir: str, template_file: str) -> List[Tuple[str, str]]: + """Find all locale .arb files in the directory, excluding the template. + + Returns list of (locale_code, file_path) tuples. + """ + locales = [] + template_basename = os.path.basename(template_file) + + for filename in os.listdir(l10n_dir): + if not filename.endswith('.arb'): + continue + if filename == template_basename: + continue + # Extract locale from filename like app_es.arb -> es + if filename.startswith('app_') and filename.endswith('.arb'): + locale = filename[4:-4] # Remove 'app_' prefix and '.arb' suffix + filepath = os.path.join(l10n_dir, filename) + locales.append((locale, filepath)) + + return sorted(locales) + + def main() -> int: ap = argparse.ArgumentParser() - ap.add_argument("--in", dest="in_path", required=True, help="Input .arb/.json file path") - ap.add_argument("--out", dest="out_path", required=True, help="Output .arb/.json file path") - ap.add_argument("--to-locale", required=True, help="Target locale code, e.g. es, fr, de") + ap.add_argument("--in", dest="in_path", required=True, help="Input .arb/.json file path (source/template)") + ap.add_argument("--out", dest="out_path", default=None, help="Output .arb/.json file path (required unless using --l10n-dir)") + ap.add_argument("--to-locale", default=None, help="Target locale code, e.g. es, fr, de (required unless using --l10n-dir)") + ap.add_argument("--l10n-dir", default=None, help="Directory containing locale .arb files. When set, translates all locales.") + ap.add_argument("--missing-only", action="store_true", help="Only translate keys missing from target file") ap.add_argument("--target-lang", default=None, help="Target language name for the model, e.g. Spanish (defaults from locale)") ap.add_argument("--model", default="gemma3:4b", help="Ollama model name") ap.add_argument("--fallback-model", default=None, help="Larger model to use for low-confidence translations") @@ -504,19 +558,119 @@ def main() -> int: "vi": "Vietnamese", "id": "Indonesian", } - target_lang = args.target_lang or locale_map.get(args.to_locale, args.to_locale) + # Read source/template file try: with open(args.in_path, "r", encoding="utf-8") as f: - data = json.load(f) + source_data = json.load(f) except Exception as e: print(f"Failed to read input: {e}", file=sys.stderr) return 2 - if not isinstance(data, dict): + if not isinstance(source_data, dict): print("Input JSON must be an object at top-level.", file=sys.stderr) return 2 + # If --l10n-dir is provided, process all locale files + if args.l10n_dir: + locales = get_all_locale_files(args.l10n_dir, args.in_path) + if not locales: + print(f"No locale files found in {args.l10n_dir}", file=sys.stderr) + return 1 + + print(f"Found {len(locales)} locale file(s) to process") + + total_translated = 0 + for locale_code, locale_path in locales: + target_lang = locale_map.get(locale_code, locale_code) + + # Read existing target file + try: + with open(locale_path, "r", encoding="utf-8") as f: + target_data = json.load(f) + except Exception as e: + print(f" [{locale_code}] Failed to read {locale_path}: {e}") + continue + + if args.missing_only: + missing_keys = find_missing_keys(source_data, target_data) + if not missing_keys: + print(f" [{locale_code}] No missing keys") + continue + print(f" [{locale_code}] {len(missing_keys)} missing key(s): {', '.join(missing_keys[:5])}{'...' if len(missing_keys) > 5 else ''}") + else: + missing_keys = None + + # Run translation for this locale + result = translate_locale( + source_data=source_data, + target_data=target_data, + target_locale=locale_code, + target_lang=target_lang, + out_path=locale_path, + args=args, + locale_map=locale_map, + missing_keys=missing_keys, + ) + total_translated += result + + print(f"\nTotal: {total_translated} string(s) translated across {len(locales)} locale(s)") + return 0 + + # Single locale mode - validate required args + if not args.out_path: + print("--out is required when not using --l10n-dir", file=sys.stderr) + return 1 + if not args.to_locale: + print("--to-locale is required when not using --l10n-dir", file=sys.stderr) + return 1 + + target_lang = args.target_lang or locale_map.get(args.to_locale, args.to_locale) + + # Read existing target file if --missing-only and file exists + target_data: Dict[str, Any] = {} + missing_keys: Optional[List[str]] = None + if args.missing_only: + if os.path.exists(args.out_path): + try: + with open(args.out_path, "r", encoding="utf-8") as f: + target_data = json.load(f) + missing_keys = find_missing_keys(source_data, target_data) + if not missing_keys: + print(f"No missing keys in {args.out_path}") + return 0 + print(f"Found {len(missing_keys)} missing key(s) to translate") + except Exception as e: + print(f"Failed to read target file: {e}", file=sys.stderr) + return 2 + else: + print(f"Target file {args.out_path} does not exist. Will translate all strings.") + + result = translate_locale( + source_data=source_data, + target_data=target_data, + target_locale=args.to_locale, + target_lang=target_lang, + out_path=args.out_path, + args=args, + locale_map=locale_map, + missing_keys=missing_keys, + ) + return 0 if result >= 0 else 1 + + +def translate_locale( + source_data: Dict[str, Any], + target_data: Dict[str, Any], + target_locale: str, + target_lang: str, + out_path: str, + args, + locale_map: Dict[str, str], + missing_keys: Optional[List[str]] = None, +) -> int: + """Translate a single locale. Returns number of strings translated.""" + cfg = OllamaConfig( host=args.host, model=args.model, @@ -540,17 +694,34 @@ def main() -> int: top_p=args.top_p, ) - out_data: Dict[str, Any] = dict(data) - out_data["@@locale"] = args.to_locale + # Start with target data (preserves existing translations) or source data + if target_data: + out_data: Dict[str, Any] = dict(target_data) + else: + out_data: Dict[str, Any] = dict(source_data) + out_data["@@locale"] = target_locale - items: List[Tuple[str, str]] = [(k, v) for k, v in data.items() if is_translatable_entry(k, v)] + # Build list of items to translate + if missing_keys is not None: + # Only translate missing keys + items: List[Tuple[str, str]] = [ + (k, source_data[k]) for k in missing_keys + if is_translatable_entry(k, source_data.get(k)) + ] + # Also copy over any metadata keys for missing items + for key in missing_keys: + meta_key = f"@{key}" + if meta_key in source_data: + out_data[meta_key] = source_data[meta_key] + else: + items: List[Tuple[str, str]] = [(k, v) for k, v in source_data.items() if is_translatable_entry(k, v)] # Apply manual translations first manual_count = 0 items_to_translate: List[Tuple[str, str]] = [] for k, v in items: - if k in MANUAL_TRANSLATIONS and args.to_locale in MANUAL_TRANSLATIONS[k]: - out_data[k] = MANUAL_TRANSLATIONS[k][args.to_locale] + if k in MANUAL_TRANSLATIONS and target_locale in MANUAL_TRANSLATIONS[k]: + out_data[k] = MANUAL_TRANSLATIONS[k][target_locale] manual_count += 1 else: items_to_translate.append((k, v)) @@ -560,8 +731,8 @@ def main() -> int: total = len(items_to_translate) if total == 0 and manual_count == 0: - print("No translatable string entries found (excluding @@locale and @metadata).", file=sys.stderr) - return 1 + print("No translatable string entries found (excluding @@locale and @metadata).") + return 0 if total == 0: print("All strings handled by manual translations.") @@ -705,18 +876,18 @@ def main() -> int: if args.dry_run: print("Dry run: not writing output file.") - return 0 + return translated_ok try: - with open(args.out_path, "w", encoding="utf-8") as f: + with open(out_path, "w", encoding="utf-8") as f: json.dump(out_data, f, ensure_ascii=False, indent=2) f.write("\n") except Exception as e: print(f"Failed to write output: {e}", file=sys.stderr) - return 2 + return -1 - print(f"Wrote: {args.out_path}") - return 0 + print(f"Wrote: {out_path}") + return translated_ok if __name__ == "__main__":