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.
This commit is contained in:
Janez T
2025-10-28 10:17:02 +01:00
parent 6b15006d4e
commit e6289199ba
20 changed files with 1092 additions and 122 deletions

View File

@@ -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"
}

View File

@@ -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"
}
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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

View File

@@ -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';
}

View File

@@ -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';
}

View File

@@ -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';
}

View File

@@ -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';
}

View File

@@ -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';
}

View File

@@ -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';
}

View File

@@ -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';
}

View File

@@ -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"
}

View File

@@ -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<int?> 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<void> 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<int> _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.

View File

@@ -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<ContactsTab> {
}
}
/// Show the add channel dialog
Future<void> _showAddChannelDialog(BuildContext context) async {
final l10n = AppLocalizations.of(context)!;
await showDialog(
context: context,
builder: (context) => AddChannelDialog(
onCreateChannel: (name, secret) async {
final connectionProvider = context.read<ConnectionProvider>();
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<AppProvider>();
final connectionProvider = context.watch<ConnectionProvider>();
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<AppProvider>();
final isSimpleMode = appProvider.isSimpleMode;
return Consumer<ContactsProvider>(
builder: (context, contactsProvider, child) {
final chatContacts = contactsProvider.chatContacts;
final repeaters = contactsProvider.repeaters;
final rooms = contactsProvider.rooms;
final channels = contactsProvider.channels;
return Scaffold(
body: Consumer<ContactsProvider>(
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),
);
}
}

View File

@@ -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<void> Function(String name, String secret) onCreateChannel;
const AddChannelDialog({
super.key,
required this.onCreateChannel,
});
@override
State<AddChannelDialog> createState() => _AddChannelDialogState();
}
class _AddChannelDialogState extends State<AddChannelDialog> {
final _formKey = GlobalKey<FormState>();
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<void> _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),
),
],
);
}
}

View File

@@ -90,7 +90,7 @@ packages:
source: hosted
version: "0.3.4+2"
crypto:
dependency: transitive
dependency: "direct main"
description:
name: crypto
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"

View File

@@ -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