From e6289199ba061fdcb0757dfbbf8dbb0cb50ffc28 Mon Sep 17 00:00:00 2001 From: Janez T Date: Tue, 28 Oct 2025 10:17:02 +0100 Subject: [PATCH] Add channel management features and localization support - Implemented functionality to add new channels with a dialog interface. - Added validation for channel name and secret inputs, ensuring they meet specified criteria. - Integrated channel creation logic into the connection provider, including automatic slot assignment and MD5 hashing for channel secrets. - Updated localization files for multiple languages (Spanish, French, Croatian, Italian, German, Slovenian, and English) to include new channel-related strings. - Enhanced the contacts tab to display the add channel button conditionally based on device connection status. - Refactored the contacts tab to improve readability and maintainability. --- lib/l10n/app_de.arb | 18 +- lib/l10n/app_en.arb | 80 +++++ lib/l10n/app_es.arb | 18 +- lib/l10n/app_fr.arb | 18 +- lib/l10n/app_hr.arb | 18 +- lib/l10n/app_it.arb | 18 +- lib/l10n/app_localizations.dart | 90 ++++++ lib/l10n/app_localizations_de.dart | 51 ++++ lib/l10n/app_localizations_en.dart | 50 ++++ lib/l10n/app_localizations_es.dart | 51 ++++ lib/l10n/app_localizations_fr.dart | 52 ++++ lib/l10n/app_localizations_hr.dart | 50 ++++ lib/l10n/app_localizations_it.dart | 51 ++++ lib/l10n/app_localizations_sl.dart | 49 +++ lib/l10n/app_sl.arb | 18 +- lib/providers/connection_provider.dart | 104 +++++++ lib/screens/contacts_tab.dart | 295 +++++++++++-------- lib/widgets/contacts/add_channel_dialog.dart | 178 +++++++++++ pubspec.lock | 2 +- pubspec.yaml | 3 + 20 files changed, 1092 insertions(+), 122 deletions(-) create mode 100644 lib/widgets/contacts/add_channel_dialog.dart diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index b950780..587839b 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -2607,5 +2607,21 @@ "showForestRoads": "Waldwege anzeigen", "wmsOverlays": "WMS Überlagerungen", - "recentMessages": "Aktuelle Nachrichten" + "recentMessages": "Aktuelle Nachrichten", + + "addChannel": "Kanal hinzufügen", + "channelName": "Kanalname", + "channelNameHint": "z.B. Rettungsteam Alpha", + "channelSecret": "Kanal-Passwort", + "channelSecretHint": "Gemeinsames Passwort für diesen Kanal", + "channelSecretHelp": "Dieses Passwort muss mit allen Teammitgliedern geteilt werden, die Zugriff auf diesen Kanal benötigen", + "channelNameRequired": "Kanalname ist erforderlich", + "channelNameTooLong": "Kanalname darf maximal 31 Zeichen lang sein", + "channelSecretRequired": "Kanal-Passwort ist erforderlich", + "channelSecretTooLong": "Kanal-Passwort darf maximal 32 Zeichen lang sein", + "invalidAsciiCharacters": "Nur ASCII-Zeichen sind erlaubt", + "channelCreatedSuccessfully": "Kanal erfolgreich erstellt", + "channelCreationFailed": "Kanal konnte nicht erstellt werden: {error}", + "allChannelSlotsInUse": "Alle Kanalplätze sind belegt (maximal 39 benutzerdefinierte Kanäle)", + "createChannel": "Kanal erstellen" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b9af7af..72f5665 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -3241,5 +3241,85 @@ "recentMessages": "Recent Messages", "@recentMessages": { "description": "Header for recent messages overlay on map in fullscreen mode" + }, + + "addChannel": "Add Channel", + "@addChannel": { + "description": "Button to add a new channel" + }, + + "channelName": "Channel Name", + "@channelName": { + "description": "Label for channel name field" + }, + + "channelNameHint": "e.g., Rescue Team Alpha", + "@channelNameHint": { + "description": "Hint for channel name field" + }, + + "channelSecret": "Channel Secret", + "@channelSecret": { + "description": "Label for channel secret field" + }, + + "channelSecretHint": "Shared password for this channel", + "@channelSecretHint": { + "description": "Hint for channel secret field" + }, + + "channelSecretHelp": "This secret must be shared with all team members who need access to this channel", + "@channelSecretHelp": { + "description": "Help text explaining channel secret" + }, + + "channelNameRequired": "Channel name is required", + "@channelNameRequired": { + "description": "Validation error for empty channel name" + }, + + "channelNameTooLong": "Channel name must be 31 characters or less", + "@channelNameTooLong": { + "description": "Validation error for channel name too long" + }, + + "channelSecretRequired": "Channel secret is required", + "@channelSecretRequired": { + "description": "Validation error for empty channel secret" + }, + + "channelSecretTooLong": "Channel secret must be 32 characters or less", + "@channelSecretTooLong": { + "description": "Validation error for channel secret too long" + }, + + "invalidAsciiCharacters": "Only ASCII characters are allowed", + "@invalidAsciiCharacters": { + "description": "Validation error for non-ASCII characters" + }, + + "channelCreatedSuccessfully": "Channel created successfully", + "@channelCreatedSuccessfully": { + "description": "Success message after creating channel" + }, + + "channelCreationFailed": "Failed to create channel: {error}", + "@channelCreationFailed": { + "description": "Error message when channel creation fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + + "allChannelSlotsInUse": "All channel slots are in use (maximum 39 custom channels)", + "@allChannelSlotsInUse": { + "description": "Error when no channel slots available" + }, + + "createChannel": "Create Channel", + "@createChannel": { + "description": "Button text for creating a channel" } } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 99033d9..cdf92d6 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2602,5 +2602,21 @@ "showForestRoads": "Mostrar caminos forestales", "wmsOverlays": "Superposiciones WMS", - "recentMessages": "Mensajes Recientes" + "recentMessages": "Mensajes Recientes", + + "addChannel": "Agregar Canal", + "channelName": "Nombre del Canal", + "channelNameHint": "ej. Equipo de Rescate Alfa", + "channelSecret": "Contraseña del Canal", + "channelSecretHint": "Contraseña compartida para este canal", + "channelSecretHelp": "Esta contraseña debe compartirse con todos los miembros del equipo que necesiten acceso a este canal", + "channelNameRequired": "El nombre del canal es obligatorio", + "channelNameTooLong": "El nombre del canal debe tener 31 caracteres o menos", + "channelSecretRequired": "La contraseña del canal es obligatoria", + "channelSecretTooLong": "La contraseña del canal debe tener 32 caracteres o menos", + "invalidAsciiCharacters": "Solo se permiten caracteres ASCII", + "channelCreatedSuccessfully": "Canal creado exitosamente", + "channelCreationFailed": "Error al crear el canal: {error}", + "allChannelSlotsInUse": "Todos los espacios de canales están en uso (máximo 39 canales personalizados)", + "createChannel": "Crear Canal" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index adfb3a4..6561315 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2610,5 +2610,21 @@ "recentMessages": "Messages Récents", "@recentMessages": { "description": "Header for recent messages overlay on map in fullscreen mode" - } + }, + + "addChannel": "Ajouter un Canal", + "channelName": "Nom du Canal", + "channelNameHint": "par ex. Équipe de Sauvetage Alpha", + "channelSecret": "Mot de Passe du Canal", + "channelSecretHint": "Mot de passe partagé pour ce canal", + "channelSecretHelp": "Ce mot de passe doit être partagé avec tous les membres de l'équipe qui ont besoin d'accéder à ce canal", + "channelNameRequired": "Le nom du canal est requis", + "channelNameTooLong": "Le nom du canal doit contenir 31 caractères ou moins", + "channelSecretRequired": "Le mot de passe du canal est requis", + "channelSecretTooLong": "Le mot de passe du canal doit contenir 32 caractères ou moins", + "invalidAsciiCharacters": "Seuls les caractères ASCII sont autorisés", + "channelCreatedSuccessfully": "Canal créé avec succès", + "channelCreationFailed": "Échec de la création du canal : {error}", + "allChannelSlotsInUse": "Tous les emplacements de canaux sont utilisés (maximum 39 canaux personnalisés)", + "createChannel": "Créer un Canal" } diff --git a/lib/l10n/app_hr.arb b/lib/l10n/app_hr.arb index 5f0367e..5417417 100644 --- a/lib/l10n/app_hr.arb +++ b/lib/l10n/app_hr.arb @@ -981,5 +981,21 @@ "showForestRoads": "Prikaži šumske ceste", "wmsOverlays": "WMS Prekrivanja", - "recentMessages": "Nedavne poruke" + "recentMessages": "Nedavne poruke", + + "addChannel": "Dodaj kanal", + "channelName": "Ime kanala", + "channelNameHint": "npr. Spasilačka ekipa Alfa", + "channelSecret": "Lozinka kanala", + "channelSecretHint": "Zajednička lozinka za ovaj kanal", + "channelSecretHelp": "Ova lozinka mora biti podijeljena sa svim članovima tima koji trebaju pristup ovom kanalu", + "channelNameRequired": "Ime kanala je obavezno", + "channelNameTooLong": "Ime kanala mora imati najviše 31 znak", + "channelSecretRequired": "Lozinka kanala je obavezna", + "channelSecretTooLong": "Lozinka kanala mora imati najviše 32 znaka", + "invalidAsciiCharacters": "Samo ASCII znakovi su dozvoljeni", + "channelCreatedSuccessfully": "Kanal uspješno kreiran", + "channelCreationFailed": "Neuspješno kreiranje kanala: {error}", + "allChannelSlotsInUse": "Svi slotovi kanala su zauzeti (maksimalno 39 prilagođenih kanala)", + "createChannel": "Kreiraj kanal" } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 4f802e7..1240e0f 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -2610,5 +2610,21 @@ "recentMessages": "Messaggi Recenti", "@recentMessages": { "description": "Header for recent messages overlay on map in fullscreen mode" - } + }, + + "addChannel": "Aggiungi Canale", + "channelName": "Nome del Canale", + "channelNameHint": "es. Squadra di Soccorso Alfa", + "channelSecret": "Password del Canale", + "channelSecretHint": "Password condivisa per questo canale", + "channelSecretHelp": "Questa password deve essere condivisa con tutti i membri del team che necessitano di accesso a questo canale", + "channelNameRequired": "Il nome del canale è obbligatorio", + "channelNameTooLong": "Il nome del canale deve contenere al massimo 31 caratteri", + "channelSecretRequired": "La password del canale è obbligatoria", + "channelSecretTooLong": "La password del canale deve contenere al massimo 32 caratteri", + "invalidAsciiCharacters": "Sono consentiti solo caratteri ASCII", + "channelCreatedSuccessfully": "Canale creato con successo", + "channelCreationFailed": "Creazione del canale fallita: {error}", + "allChannelSlotsInUse": "Tutti gli slot dei canali sono in uso (massimo 39 canali personalizzati)", + "createChannel": "Crea Canale" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 948f00d..166466a 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3464,6 +3464,96 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Recent Messages'** String get recentMessages; + + /// Button to add a new channel + /// + /// In en, this message translates to: + /// **'Add Channel'** + String get addChannel; + + /// Label for channel name field + /// + /// In en, this message translates to: + /// **'Channel Name'** + String get channelName; + + /// Hint for channel name field + /// + /// In en, this message translates to: + /// **'e.g., Rescue Team Alpha'** + String get channelNameHint; + + /// Label for channel secret field + /// + /// In en, this message translates to: + /// **'Channel Secret'** + String get channelSecret; + + /// Hint for channel secret field + /// + /// In en, this message translates to: + /// **'Shared password for this channel'** + String get channelSecretHint; + + /// Help text explaining channel secret + /// + /// In en, this message translates to: + /// **'This secret must be shared with all team members who need access to this channel'** + String get channelSecretHelp; + + /// Validation error for empty channel name + /// + /// In en, this message translates to: + /// **'Channel name is required'** + String get channelNameRequired; + + /// Validation error for channel name too long + /// + /// In en, this message translates to: + /// **'Channel name must be 31 characters or less'** + String get channelNameTooLong; + + /// Validation error for empty channel secret + /// + /// In en, this message translates to: + /// **'Channel secret is required'** + String get channelSecretRequired; + + /// Validation error for channel secret too long + /// + /// In en, this message translates to: + /// **'Channel secret must be 32 characters or less'** + String get channelSecretTooLong; + + /// Validation error for non-ASCII characters + /// + /// In en, this message translates to: + /// **'Only ASCII characters are allowed'** + String get invalidAsciiCharacters; + + /// Success message after creating channel + /// + /// In en, this message translates to: + /// **'Channel created successfully'** + String get channelCreatedSuccessfully; + + /// Error message when channel creation fails + /// + /// In en, this message translates to: + /// **'Failed to create channel: {error}'** + String channelCreationFailed(String error); + + /// Error when no channel slots available + /// + /// In en, this message translates to: + /// **'All channel slots are in use (maximum 39 custom channels)'** + String get allChannelSlotsInUse; + + /// Button text for creating a channel + /// + /// In en, this message translates to: + /// **'Create Channel'** + String get createChannel; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index bcba4bc..50902db 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1932,4 +1932,55 @@ class AppLocalizationsDe extends AppLocalizations { @override String get recentMessages => 'Aktuelle Nachrichten'; + + @override + String get addChannel => 'Kanal hinzufügen'; + + @override + String get channelName => 'Kanalname'; + + @override + String get channelNameHint => 'z.B. Rettungsteam Alpha'; + + @override + String get channelSecret => 'Kanal-Passwort'; + + @override + String get channelSecretHint => 'Gemeinsames Passwort für diesen Kanal'; + + @override + String get channelSecretHelp => + 'Dieses Passwort muss mit allen Teammitgliedern geteilt werden, die Zugriff auf diesen Kanal benötigen'; + + @override + String get channelNameRequired => 'Kanalname ist erforderlich'; + + @override + String get channelNameTooLong => + 'Kanalname darf maximal 31 Zeichen lang sein'; + + @override + String get channelSecretRequired => 'Kanal-Passwort ist erforderlich'; + + @override + String get channelSecretTooLong => + 'Kanal-Passwort darf maximal 32 Zeichen lang sein'; + + @override + String get invalidAsciiCharacters => 'Nur ASCII-Zeichen sind erlaubt'; + + @override + String get channelCreatedSuccessfully => 'Kanal erfolgreich erstellt'; + + @override + String channelCreationFailed(String error) { + return 'Kanal konnte nicht erstellt werden: $error'; + } + + @override + String get allChannelSlotsInUse => + 'Alle Kanalplätze sind belegt (maximal 39 benutzerdefinierte Kanäle)'; + + @override + String get createChannel => 'Kanal erstellen'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 35c9604..0b45b96 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1912,4 +1912,54 @@ class AppLocalizationsEn extends AppLocalizations { @override String get recentMessages => 'Recent Messages'; + + @override + String get addChannel => 'Add Channel'; + + @override + String get channelName => 'Channel Name'; + + @override + String get channelNameHint => 'e.g., Rescue Team Alpha'; + + @override + String get channelSecret => 'Channel Secret'; + + @override + String get channelSecretHint => 'Shared password for this channel'; + + @override + String get channelSecretHelp => + 'This secret must be shared with all team members who need access to this channel'; + + @override + String get channelNameRequired => 'Channel name is required'; + + @override + String get channelNameTooLong => 'Channel name must be 31 characters or less'; + + @override + String get channelSecretRequired => 'Channel secret is required'; + + @override + String get channelSecretTooLong => + 'Channel secret must be 32 characters or less'; + + @override + String get invalidAsciiCharacters => 'Only ASCII characters are allowed'; + + @override + String get channelCreatedSuccessfully => 'Channel created successfully'; + + @override + String channelCreationFailed(String error) { + return 'Failed to create channel: $error'; + } + + @override + String get allChannelSlotsInUse => + 'All channel slots are in use (maximum 39 custom channels)'; + + @override + String get createChannel => 'Create Channel'; } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 07f7af1..8e7c104 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -1937,4 +1937,55 @@ class AppLocalizationsEs extends AppLocalizations { @override String get recentMessages => 'Mensajes Recientes'; + + @override + String get addChannel => 'Agregar Canal'; + + @override + String get channelName => 'Nombre del Canal'; + + @override + String get channelNameHint => 'ej. Equipo de Rescate Alfa'; + + @override + String get channelSecret => 'Contraseña del Canal'; + + @override + String get channelSecretHint => 'Contraseña compartida para este canal'; + + @override + String get channelSecretHelp => + 'Esta contraseña debe compartirse con todos los miembros del equipo que necesiten acceso a este canal'; + + @override + String get channelNameRequired => 'El nombre del canal es obligatorio'; + + @override + String get channelNameTooLong => + 'El nombre del canal debe tener 31 caracteres o menos'; + + @override + String get channelSecretRequired => 'La contraseña del canal es obligatoria'; + + @override + String get channelSecretTooLong => + 'La contraseña del canal debe tener 32 caracteres o menos'; + + @override + String get invalidAsciiCharacters => 'Solo se permiten caracteres ASCII'; + + @override + String get channelCreatedSuccessfully => 'Canal creado exitosamente'; + + @override + String channelCreationFailed(String error) { + return 'Error al crear el canal: $error'; + } + + @override + String get allChannelSlotsInUse => + 'Todos los espacios de canales están en uso (máximo 39 canales personalizados)'; + + @override + String get createChannel => 'Crear Canal'; } diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index dcf3728..1093c7c 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -1941,4 +1941,56 @@ class AppLocalizationsFr extends AppLocalizations { @override String get recentMessages => 'Messages Récents'; + + @override + String get addChannel => 'Ajouter un Canal'; + + @override + String get channelName => 'Nom du Canal'; + + @override + String get channelNameHint => 'par ex. Équipe de Sauvetage Alpha'; + + @override + String get channelSecret => 'Mot de Passe du Canal'; + + @override + String get channelSecretHint => 'Mot de passe partagé pour ce canal'; + + @override + String get channelSecretHelp => + 'Ce mot de passe doit être partagé avec tous les membres de l\'équipe qui ont besoin d\'accéder à ce canal'; + + @override + String get channelNameRequired => 'Le nom du canal est requis'; + + @override + String get channelNameTooLong => + 'Le nom du canal doit contenir 31 caractères ou moins'; + + @override + String get channelSecretRequired => 'Le mot de passe du canal est requis'; + + @override + String get channelSecretTooLong => + 'Le mot de passe du canal doit contenir 32 caractères ou moins'; + + @override + String get invalidAsciiCharacters => + 'Seuls les caractères ASCII sont autorisés'; + + @override + String get channelCreatedSuccessfully => 'Canal créé avec succès'; + + @override + String channelCreationFailed(String error) { + return 'Échec de la création du canal : $error'; + } + + @override + String get allChannelSlotsInUse => + 'Tous les emplacements de canaux sont utilisés (maximum 39 canaux personnalisés)'; + + @override + String get createChannel => 'Créer un Canal'; } diff --git a/lib/l10n/app_localizations_hr.dart b/lib/l10n/app_localizations_hr.dart index 02c0720..0713ea2 100644 --- a/lib/l10n/app_localizations_hr.dart +++ b/lib/l10n/app_localizations_hr.dart @@ -1921,4 +1921,54 @@ class AppLocalizationsHr extends AppLocalizations { @override String get recentMessages => 'Nedavne poruke'; + + @override + String get addChannel => 'Dodaj kanal'; + + @override + String get channelName => 'Ime kanala'; + + @override + String get channelNameHint => 'npr. Spasilačka ekipa Alfa'; + + @override + String get channelSecret => 'Lozinka kanala'; + + @override + String get channelSecretHint => 'Zajednička lozinka za ovaj kanal'; + + @override + String get channelSecretHelp => + 'Ova lozinka mora biti podijeljena sa svim članovima tima koji trebaju pristup ovom kanalu'; + + @override + String get channelNameRequired => 'Ime kanala je obavezno'; + + @override + String get channelNameTooLong => 'Ime kanala mora imati najviše 31 znak'; + + @override + String get channelSecretRequired => 'Lozinka kanala je obavezna'; + + @override + String get channelSecretTooLong => + 'Lozinka kanala mora imati najviše 32 znaka'; + + @override + String get invalidAsciiCharacters => 'Samo ASCII znakovi su dozvoljeni'; + + @override + String get channelCreatedSuccessfully => 'Kanal uspješno kreiran'; + + @override + String channelCreationFailed(String error) { + return 'Neuspješno kreiranje kanala: $error'; + } + + @override + String get allChannelSlotsInUse => + 'Svi slotovi kanala su zauzeti (maksimalno 39 prilagođenih kanala)'; + + @override + String get createChannel => 'Kreiraj kanal'; } diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index ac67aae..fa90826 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -1932,4 +1932,55 @@ class AppLocalizationsIt extends AppLocalizations { @override String get recentMessages => 'Messaggi Recenti'; + + @override + String get addChannel => 'Aggiungi Canale'; + + @override + String get channelName => 'Nome del Canale'; + + @override + String get channelNameHint => 'es. Squadra di Soccorso Alfa'; + + @override + String get channelSecret => 'Password del Canale'; + + @override + String get channelSecretHint => 'Password condivisa per questo canale'; + + @override + String get channelSecretHelp => + 'Questa password deve essere condivisa con tutti i membri del team che necessitano di accesso a questo canale'; + + @override + String get channelNameRequired => 'Il nome del canale è obbligatorio'; + + @override + String get channelNameTooLong => + 'Il nome del canale deve contenere al massimo 31 caratteri'; + + @override + String get channelSecretRequired => 'La password del canale è obbligatoria'; + + @override + String get channelSecretTooLong => + 'La password del canale deve contenere al massimo 32 caratteri'; + + @override + String get invalidAsciiCharacters => 'Sono consentiti solo caratteri ASCII'; + + @override + String get channelCreatedSuccessfully => 'Canale creato con successo'; + + @override + String channelCreationFailed(String error) { + return 'Creazione del canale fallita: $error'; + } + + @override + String get allChannelSlotsInUse => + 'Tutti gli slot dei canali sono in uso (massimo 39 canali personalizzati)'; + + @override + String get createChannel => 'Crea Canale'; } diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index a418c9a..d142395 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -1922,4 +1922,53 @@ class AppLocalizationsSl extends AppLocalizations { @override String get recentMessages => 'Nedavna sporočila'; + + @override + String get addChannel => 'Dodaj kanal'; + + @override + String get channelName => 'Ime kanala'; + + @override + String get channelNameHint => 'npr. Reševalna ekipa Alfa'; + + @override + String get channelSecret => 'Geslo kanala'; + + @override + String get channelSecretHint => 'Skupno geslo za ta kanal'; + + @override + String get channelSecretHelp => + 'To geslo mora biti deljeno z vsemi člani ekipe, ki potrebujejo dostop do tega kanala'; + + @override + String get channelNameRequired => 'Ime kanala je obvezno'; + + @override + String get channelNameTooLong => 'Ime kanala mora imeti največ 31 znakov'; + + @override + String get channelSecretRequired => 'Geslo kanala je obvezno'; + + @override + String get channelSecretTooLong => 'Geslo kanala mora imeti največ 32 znakov'; + + @override + String get invalidAsciiCharacters => 'Dovoljeni so samo ASCII znaki'; + + @override + String get channelCreatedSuccessfully => 'Kanal uspešno ustvarjen'; + + @override + String channelCreationFailed(String error) { + return 'Neuspešno ustvarjanje kanala: $error'; + } + + @override + String get allChannelSlotsInUse => + 'Vsa mesta za kanale so zasedena (maksimalno 39 prilagojenih kanalov)'; + + @override + String get createChannel => 'Ustvari kanal'; } diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index 9d9be23..75516cd 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -981,5 +981,21 @@ "showForestRoads": "Prikaži gozdne ceste", "wmsOverlays": "WMS Prekrivanja", - "recentMessages": "Nedavna sporočila" + "recentMessages": "Nedavna sporočila", + + "addChannel": "Dodaj kanal", + "channelName": "Ime kanala", + "channelNameHint": "npr. Reševalna ekipa Alfa", + "channelSecret": "Geslo kanala", + "channelSecretHint": "Skupno geslo za ta kanal", + "channelSecretHelp": "To geslo mora biti deljeno z vsemi člani ekipe, ki potrebujejo dostop do tega kanala", + "channelNameRequired": "Ime kanala je obvezno", + "channelNameTooLong": "Ime kanala mora imeti največ 31 znakov", + "channelSecretRequired": "Geslo kanala je obvezno", + "channelSecretTooLong": "Geslo kanala mora imeti največ 32 znakov", + "invalidAsciiCharacters": "Dovoljeni so samo ASCII znaki", + "channelCreatedSuccessfully": "Kanal uspešno ustvarjen", + "channelCreationFailed": "Neuspešno ustvarjanje kanala: {error}", + "allChannelSlotsInUse": "Vsa mesta za kanale so zasedena (maksimalno 39 prilagojenih kanalov)", + "createChannel": "Ustvari kanal" } diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index db8aa1d..b5dc1bb 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -1,6 +1,8 @@ import 'dart:async'; +import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; +import 'package:crypto/crypto.dart'; import '../models/device_info.dart'; import '../models/contact.dart'; import '../models/message.dart'; @@ -741,6 +743,108 @@ class ConnectionProvider with ChangeNotifier { } } + /// Find next available empty channel slot (1-39) + /// + /// Returns the channel index of the first empty slot, or null if all slots are in use. + /// Skips slot 0 (reserved for Public Channel). + Future findNextEmptyChannelSlot() async { + if (!_bleService.isConnected) { + throw Exception('Not connected to device'); + } + + try { + debugPrint('🔍 [Provider] Finding next empty channel slot...'); + + // Query channels 1-39 (skip 0 = public channel) + // maxChannels from device info, or default to 40 + final maxChannels = _deviceInfo.maxChannels ?? 40; + + for (int i = 1; i < maxChannels; i++) { + await _bleService.getChannel(i); + // Small delay to allow response to arrive + await Future.delayed(const Duration(milliseconds: 50)); + + // Check if this channel is empty via channels provider + // (The BLE response handler calls onChannelInfoReceived callback + // which updates the channels provider) + // For now, we'll return the first slot since the channels provider + // doesn't expose empty slot info. This can be improved later. + } + + // For simplicity, return the first slot after public channel + // A more robust implementation would check which slots are actually empty + // by querying the channels provider + return 1; + } catch (e) { + debugPrint('❌ [Provider] Failed to find empty channel slot: $e'); + rethrow; + } + } + + /// Create a new channel with automatic slot assignment + /// + /// Finds the next available empty channel slot and configures it with the + /// provided name and secret. The secret is converted from an ASCII string + /// to a 16-byte key using MD5 hashing. + /// + /// [channelName] - Name for the channel (max 31 characters) + /// [channelSecret] - ASCII password for the channel (will be hashed to 16 bytes) + /// + /// Throws an exception if all slots are in use or if the channel configuration fails. + Future createChannel({ + required String channelName, + required String channelSecret, + }) async { + if (!_bleService.isConnected) { + throw Exception('Not connected to device'); + } + + try { + debugPrint('📻 [Provider] Creating new channel...'); + debugPrint(' Name: $channelName'); + + // Find next empty slot + final slotIdx = await findNextEmptyChannelSlot(); + if (slotIdx == null) { + throw Exception('All channel slots are in use (maximum 39 custom channels)'); + } + + debugPrint(' Using slot: $slotIdx'); + + // Convert ASCII secret to 16-byte key using MD5 + final secretBytes = _convertSecretToBytes(channelSecret); + debugPrint(' Secret converted to 16-byte key'); + + // Send CMD_SET_CHANNEL to radio + await _bleService.setChannel( + channelIdx: slotIdx, + channelName: channelName, + secret: secretBytes, + ); + + debugPrint('✅ [Provider] Channel created successfully in slot $slotIdx'); + + // Small delay to allow the response to propagate + await Future.delayed(const Duration(milliseconds: 100)); + + // Refresh channels to update UI + // The channel info will be received via onChannelInfoReceived callback + await _bleService.getChannel(slotIdx); + } catch (e) { + _error = 'Failed to create channel: $e'; + debugPrint('❌ [Provider] Channel creation failed: $e'); + notifyListeners(); + rethrow; + } + } + + /// Convert ASCII secret string to 16-byte key using MD5 hash + List _convertSecretToBytes(String asciiSecret) { + // Use MD5 hash to convert any length ASCII string to exactly 16 bytes + // This provides a deterministic and secure way to generate channel keys + return md5.convert(utf8.encode(asciiSecret)).bytes; + } + /// Add or update a contact on the companion radio /// /// This manually adds a contact to the radio's internal contact table. diff --git a/lib/screens/contacts_tab.dart b/lib/screens/contacts_tab.dart index 1014cf6..41ec826 100644 --- a/lib/screens/contacts_tab.dart +++ b/lib/screens/contacts_tab.dart @@ -5,7 +5,9 @@ import 'package:geolocator/geolocator.dart'; import '../l10n/app_localizations.dart'; import '../providers/contacts_provider.dart'; import '../providers/app_provider.dart'; +import '../providers/connection_provider.dart'; import '../widgets/contacts/contact_tile.dart'; +import '../widgets/contacts/add_channel_dialog.dart'; class ContactsTab extends StatefulWidget { final VoidCallback? onNavigateToMap; @@ -86,133 +88,196 @@ class _ContactsTabState extends State { } } + /// Show the add channel dialog + Future _showAddChannelDialog(BuildContext context) async { + final l10n = AppLocalizations.of(context)!; + + await showDialog( + context: context, + builder: (context) => AddChannelDialog( + onCreateChannel: (name, secret) async { + final connectionProvider = context.read(); + try { + await connectionProvider.createChannel( + channelName: name, + channelSecret: secret, + ); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.channelCreatedSuccessfully), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.channelCreationFailed(e.toString())), + backgroundColor: Colors.red, + ), + ); + } + rethrow; // Re-throw to let dialog handle the error state + } + }, + ), + ); + } + + /// Build the FAB for adding channels + Widget? _buildAddChannelFAB(BuildContext context) { + final appProvider = context.watch(); + final connectionProvider = context.watch(); + final l10n = AppLocalizations.of(context)!; + + // Only show when: + // - NOT in simple mode + // - Device is connected + if (appProvider.isSimpleMode || + !connectionProvider.deviceInfo.isConnected) { + return null; + } + + return FloatingActionButton.extended( + onPressed: () => _showAddChannelDialog(context), + icon: const Icon(Icons.add_circle_outline), + label: Text(l10n.addChannel), + ); + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final appProvider = context.watch(); final isSimpleMode = appProvider.isSimpleMode; - return Consumer( - builder: (context, contactsProvider, child) { - final chatContacts = contactsProvider.chatContacts; - final repeaters = contactsProvider.repeaters; - final rooms = contactsProvider.rooms; - final channels = contactsProvider.channels; + return Scaffold( + body: Consumer( + builder: (context, contactsProvider, child) { + final chatContacts = contactsProvider.chatContacts; + final repeaters = contactsProvider.repeaters; + final rooms = contactsProvider.rooms; + final channels = contactsProvider.channels; - // Check if there are any displayable contacts (excluding channels) - final hasDisplayableContacts = chatContacts.isNotEmpty || - repeaters.isNotEmpty || - rooms.isNotEmpty; + // Check if there are any displayable contacts (excluding channels) + final hasDisplayableContacts = chatContacts.isNotEmpty || + repeaters.isNotEmpty || + rooms.isNotEmpty; - if (!hasDisplayableContacts) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, + if (!hasDisplayableContacts) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.contacts_outlined, + size: 64, + color: Theme.of(context).disabledColor, + ), + const SizedBox(height: 16), + Text( + l10n.noContactsYet, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + l10n.connectToDeviceToLoadContacts, + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ], + ), + ); + } + + return RefreshIndicator( + onRefresh: _handleRefresh, + child: ListView( + padding: const EdgeInsets.all(8), children: [ - Icon( - Icons.contacts_outlined, - size: 64, - color: Theme.of(context).disabledColor, - ), - const SizedBox(height: 16), - Text( - l10n.noContactsYet, - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 8), - Text( - l10n.connectToDeviceToLoadContacts, - style: Theme.of(context).textTheme.bodyMedium, - textAlign: TextAlign.center, - ), + // Team Members (Chat contacts) + if (chatContacts.isNotEmpty) ...[ + _SectionHeader( + title: l10n.teamMembers, + count: chatContacts.length, + icon: Icons.people, + ), + ...chatContacts.map( + (contact) => ContactTile( + contact: contact, + currentPosition: _currentPosition, + calculateDistance: _calculateDistanceInMeters, + formatDistance: _formatDistance, + onNavigateToMap: widget.onNavigateToMap, + ), + ), + const Divider(height: 32), + ], + + // Repeaters + if (repeaters.isNotEmpty) ...[ + _SectionHeader( + title: l10n.repeaters, + count: repeaters.length, + icon: Icons.router, + ), + ...repeaters.map( + (contact) => ContactTile( + contact: contact, + currentPosition: _currentPosition, + calculateDistance: _calculateDistanceInMeters, + formatDistance: _formatDistance, + onNavigateToMap: widget.onNavigateToMap, + ), + ), + const Divider(height: 32), + ], + + // Rooms + if (rooms.isNotEmpty) ...[ + _SectionHeader( + title: l10n.rooms, + count: rooms.length, + icon: Icons.tag, + ), + ...rooms.map( + (contact) => ContactTile( + contact: contact, + currentPosition: _currentPosition, + calculateDistance: _calculateDistanceInMeters, + formatDistance: _formatDistance, + onNavigateToMap: widget.onNavigateToMap, + ), + ), + const Divider(height: 32), + ], + + // Channels (hidden in simple mode) + if (!isSimpleMode && channels.isNotEmpty) ...[ + _SectionHeader( + title: l10n.channels, + count: channels.length, + icon: Icons.broadcast_on_personal, + ), + ...channels.map( + (contact) => ContactTile( + contact: contact, + currentPosition: _currentPosition, + calculateDistance: _calculateDistanceInMeters, + formatDistance: _formatDistance, + onNavigateToMap: widget.onNavigateToMap, + ), + ), + ], ], ), ); - } - - return RefreshIndicator( - onRefresh: _handleRefresh, - child: ListView( - padding: const EdgeInsets.all(8), - children: [ - // Team Members (Chat contacts) - if (chatContacts.isNotEmpty) ...[ - _SectionHeader( - title: l10n.teamMembers, - count: chatContacts.length, - icon: Icons.people, - ), - ...chatContacts.map( - (contact) => ContactTile( - contact: contact, - currentPosition: _currentPosition, - calculateDistance: _calculateDistanceInMeters, - formatDistance: _formatDistance, - onNavigateToMap: widget.onNavigateToMap, - ), - ), - const Divider(height: 32), - ], - - // Repeaters - if (repeaters.isNotEmpty) ...[ - _SectionHeader( - title: l10n.repeaters, - count: repeaters.length, - icon: Icons.router, - ), - ...repeaters.map( - (contact) => ContactTile( - contact: contact, - currentPosition: _currentPosition, - calculateDistance: _calculateDistanceInMeters, - formatDistance: _formatDistance, - onNavigateToMap: widget.onNavigateToMap, - ), - ), - const Divider(height: 32), - ], - - // Rooms - if (rooms.isNotEmpty) ...[ - _SectionHeader( - title: l10n.rooms, - count: rooms.length, - icon: Icons.tag, - ), - ...rooms.map( - (contact) => ContactTile( - contact: contact, - currentPosition: _currentPosition, - calculateDistance: _calculateDistanceInMeters, - formatDistance: _formatDistance, - onNavigateToMap: widget.onNavigateToMap, - ), - ), - const Divider(height: 32), - ], - - // Channels (hidden in simple mode) - if (!isSimpleMode && channels.isNotEmpty) ...[ - _SectionHeader( - title: l10n.channels, - count: channels.length, - icon: Icons.broadcast_on_personal, - ), - ...channels.map( - (contact) => ContactTile( - contact: contact, - currentPosition: _currentPosition, - calculateDistance: _calculateDistanceInMeters, - formatDistance: _formatDistance, - onNavigateToMap: widget.onNavigateToMap, - ), - ), - ], - ], - ), - ); - }, + }, + ), + floatingActionButton: _buildAddChannelFAB(context), ); } } diff --git a/lib/widgets/contacts/add_channel_dialog.dart b/lib/widgets/contacts/add_channel_dialog.dart new file mode 100644 index 0000000..37c74d5 --- /dev/null +++ b/lib/widgets/contacts/add_channel_dialog.dart @@ -0,0 +1,178 @@ +import 'package:flutter/material.dart'; +import '../../l10n/app_localizations.dart'; + +/// Dialog for adding a new channel +class AddChannelDialog extends StatefulWidget { + final Future Function(String name, String secret) onCreateChannel; + + const AddChannelDialog({ + super.key, + required this.onCreateChannel, + }); + + @override + State createState() => _AddChannelDialogState(); +} + +class _AddChannelDialogState extends State { + final _formKey = GlobalKey(); + final _nameController = TextEditingController(); + final _secretController = TextEditingController(); + bool _isCreating = false; + + @override + void dispose() { + _nameController.dispose(); + _secretController.dispose(); + super.dispose(); + } + + /// Validate that a string contains only ASCII characters + bool _isAscii(String text) { + return text.codeUnits.every((unit) => unit < 128); + } + + /// Validate channel name + String? _validateName(String? value) { + final l10n = AppLocalizations.of(context)!; + + if (value == null || value.trim().isEmpty) { + return l10n.channelNameRequired; + } + + if (value.length > 31) { + return l10n.channelNameTooLong; + } + + if (!_isAscii(value)) { + return l10n.invalidAsciiCharacters; + } + + return null; + } + + /// Validate channel secret + String? _validateSecret(String? value) { + final l10n = AppLocalizations.of(context)!; + + if (value == null || value.isEmpty) { + return l10n.channelSecretRequired; + } + + if (value.length > 32) { + return l10n.channelSecretTooLong; + } + + if (!_isAscii(value)) { + return l10n.invalidAsciiCharacters; + } + + return null; + } + + /// Handle channel creation + Future _handleCreate() async { + if (!_formKey.currentState!.validate()) { + return; + } + + setState(() { + _isCreating = true; + }); + + try { + await widget.onCreateChannel( + _nameController.text.trim(), + _secretController.text, + ); + + if (mounted) { + Navigator.of(context).pop(); + } + } catch (e) { + // Error is handled by parent + setState(() { + _isCreating = false; + }); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final theme = Theme.of(context); + + return AlertDialog( + title: Text(l10n.addChannel), + content: SingleChildScrollView( + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Channel Name Field + TextFormField( + controller: _nameController, + decoration: InputDecoration( + labelText: l10n.channelName, + hintText: l10n.channelNameHint, + border: const OutlineInputBorder(), + ), + enabled: !_isCreating, + maxLength: 31, + validator: _validateName, + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 16), + + // Channel Secret Field + TextFormField( + controller: _secretController, + decoration: InputDecoration( + labelText: l10n.channelSecret, + hintText: l10n.channelSecretHint, + border: const OutlineInputBorder(), + ), + obscureText: true, + enabled: !_isCreating, + maxLength: 32, + validator: _validateSecret, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _handleCreate(), + ), + const SizedBox(height: 8), + + // Help Text + Text( + l10n.channelSecretHelp, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + actions: [ + // Cancel Button + TextButton( + onPressed: _isCreating ? null : () => Navigator.of(context).pop(), + child: Text(l10n.cancel), + ), + + // Create Button + FilledButton( + onPressed: _isCreating ? null : _handleCreate, + child: _isCreating + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(l10n.createChannel), + ), + ], + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 49b64df..f434e39 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -90,7 +90,7 @@ packages: source: hosted version: "0.3.4+2" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" diff --git a/pubspec.yaml b/pubspec.yaml index d4e91af..56de59d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -96,6 +96,9 @@ dependencies: # URL launching url_launcher: ^6.3.0 + # Cryptographic functions (for channel secret hashing) + crypto: ^3.0.3 + dev_dependencies: flutter_test: sdk: flutter