mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-12 17:00:28 +00:00
feat: Add support for hash and private channels with corresponding UI updates
- Introduced new localization strings for channel types and their descriptions in Italian, German, Spanish, French, Croatian, Slovenian, and English. - Updated the `AppProvider` to handle channel info reception, including deletion of channels and updating the UI accordingly. - Enhanced `ChannelsProvider` to support channel removal and notify listeners. - Modified `ConnectionProvider` to include methods for checking empty channel slots and deleting channels. - Updated BLE service methods to handle channel deletion and verification. - Improved the `AddChannelDialog` to differentiate between hash and private channels, including dynamic UI elements based on channel type. - Added delete functionality for channels in the `ContactTile` widget with confirmation dialogs. - Adjusted permission request dialog behavior to allow dismissing by tapping outside.
This commit is contained in:
@@ -2768,6 +2768,8 @@
|
|||||||
"channelSecret": "Kanal-Passwort",
|
"channelSecret": "Kanal-Passwort",
|
||||||
"channelSecretHint": "Gemeinsames Passwort für diesen Kanal",
|
"channelSecretHint": "Gemeinsames Passwort für diesen Kanal",
|
||||||
"channelSecretHelp": "Dieses Passwort muss mit allen Teammitgliedern geteilt werden, die Zugriff auf diesen Kanal benötigen",
|
"channelSecretHelp": "Dieses Passwort muss mit allen Teammitgliedern geteilt werden, die Zugriff auf diesen Kanal benötigen",
|
||||||
|
"channelTypesInfo": "Hash-Kanäle (#team): Passwort automatisch aus dem Namen generiert. Gleicher Name = gleicher Kanal auf allen Geräten.\n\nPrivate Kanäle: Verwenden Sie ein explizites Passwort. Nur diejenigen mit dem Passwort können beitreten.",
|
||||||
|
"hashChannelInfo": "Hash-Kanal: Das Passwort wird automatisch aus dem Kanalnamen generiert. Jeder, der denselben Namen verwendet, wird demselben Kanal beitreten.",
|
||||||
"channelNameRequired": "Kanalname ist erforderlich",
|
"channelNameRequired": "Kanalname ist erforderlich",
|
||||||
"channelNameTooLong": "Kanalname darf maximal 31 Zeichen lang sein",
|
"channelNameTooLong": "Kanalname darf maximal 31 Zeichen lang sein",
|
||||||
"channelSecretRequired": "Kanal-Passwort ist erforderlich",
|
"channelSecretRequired": "Kanal-Passwort ist erforderlich",
|
||||||
@@ -2775,6 +2777,10 @@
|
|||||||
"invalidAsciiCharacters": "Nur ASCII-Zeichen sind erlaubt",
|
"invalidAsciiCharacters": "Nur ASCII-Zeichen sind erlaubt",
|
||||||
"channelCreatedSuccessfully": "Kanal erfolgreich erstellt",
|
"channelCreatedSuccessfully": "Kanal erfolgreich erstellt",
|
||||||
"channelCreationFailed": "Kanal konnte nicht erstellt werden: {error}",
|
"channelCreationFailed": "Kanal konnte nicht erstellt werden: {error}",
|
||||||
|
"deleteChannel": "Kanal löschen",
|
||||||
|
"deleteChannelConfirmation": "Sind Sie sicher, dass Sie den Kanal \"{channelName}\" löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||||
|
"channelDeletedSuccessfully": "Kanal erfolgreich gelöscht",
|
||||||
|
"channelDeletionFailed": "Kanal konnte nicht gelöscht werden: {error}",
|
||||||
"allChannelSlotsInUse": "Alle Kanalplätze sind belegt (maximal 39 benutzerdefinierte Kanäle)",
|
"allChannelSlotsInUse": "Alle Kanalplätze sind belegt (maximal 39 benutzerdefinierte Kanäle)",
|
||||||
"createChannel": "Kanal erstellen",
|
"createChannel": "Kanal erstellen",
|
||||||
|
|
||||||
|
|||||||
@@ -3491,6 +3491,16 @@
|
|||||||
"description": "Help text explaining channel secret"
|
"description": "Help text explaining channel secret"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"channelTypesInfo": "Hash channels (#team): Secret auto-generated from name. Same name = same channel across devices.\n\nPrivate channels: Use explicit secret. Only those with the secret can join.",
|
||||||
|
"@channelTypesInfo": {
|
||||||
|
"description": "Information banner explaining hash and private channel types"
|
||||||
|
},
|
||||||
|
|
||||||
|
"hashChannelInfo": "Hash channel: Secret will be auto-generated from the channel name. Anyone using the same name will join the same channel.",
|
||||||
|
"@hashChannelInfo": {
|
||||||
|
"description": "Help text for hash channels (# prefix)"
|
||||||
|
},
|
||||||
|
|
||||||
"channelNameRequired": "Channel name is required",
|
"channelNameRequired": "Channel name is required",
|
||||||
"@channelNameRequired": {
|
"@channelNameRequired": {
|
||||||
"description": "Validation error for empty channel name"
|
"description": "Validation error for empty channel name"
|
||||||
@@ -3531,6 +3541,36 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"deleteChannel": "Delete Channel",
|
||||||
|
"@deleteChannel": {
|
||||||
|
"description": "Delete channel button/menu item"
|
||||||
|
},
|
||||||
|
|
||||||
|
"deleteChannelConfirmation": "Are you sure you want to delete channel \"{channelName}\"? This action cannot be undone.",
|
||||||
|
"@deleteChannelConfirmation": {
|
||||||
|
"description": "Confirmation dialog when deleting a channel",
|
||||||
|
"placeholders": {
|
||||||
|
"channelName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"channelDeletedSuccessfully": "Channel deleted successfully",
|
||||||
|
"@channelDeletedSuccessfully": {
|
||||||
|
"description": "Success message after deleting channel"
|
||||||
|
},
|
||||||
|
|
||||||
|
"channelDeletionFailed": "Failed to delete channel: {error}",
|
||||||
|
"@channelDeletionFailed": {
|
||||||
|
"description": "Error message when channel deletion fails",
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
"allChannelSlotsInUse": "All channel slots are in use (maximum 39 custom channels)",
|
"allChannelSlotsInUse": "All channel slots are in use (maximum 39 custom channels)",
|
||||||
"@allChannelSlotsInUse": {
|
"@allChannelSlotsInUse": {
|
||||||
"description": "Error when no channel slots available"
|
"description": "Error when no channel slots available"
|
||||||
|
|||||||
@@ -2763,6 +2763,8 @@
|
|||||||
"channelSecret": "Contraseña del Canal",
|
"channelSecret": "Contraseña del Canal",
|
||||||
"channelSecretHint": "Contraseña compartida para este 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",
|
"channelSecretHelp": "Esta contraseña debe compartirse con todos los miembros del equipo que necesiten acceso a este canal",
|
||||||
|
"channelTypesInfo": "Canales hash (#equipo): Contraseña generada automáticamente del nombre. Mismo nombre = mismo canal en todos los dispositivos.\n\nCanales privados: Use contraseña explícita. Solo aquellos con la contraseña pueden unirse.",
|
||||||
|
"hashChannelInfo": "Canal hash: La contraseña se generará automáticamente del nombre del canal. Cualquiera que use el mismo nombre se unirá al mismo canal.",
|
||||||
"channelNameRequired": "El nombre del canal es obligatorio",
|
"channelNameRequired": "El nombre del canal es obligatorio",
|
||||||
"channelNameTooLong": "El nombre del canal debe tener 31 caracteres o menos",
|
"channelNameTooLong": "El nombre del canal debe tener 31 caracteres o menos",
|
||||||
"channelSecretRequired": "La contraseña del canal es obligatoria",
|
"channelSecretRequired": "La contraseña del canal es obligatoria",
|
||||||
@@ -2770,6 +2772,10 @@
|
|||||||
"invalidAsciiCharacters": "Solo se permiten caracteres ASCII",
|
"invalidAsciiCharacters": "Solo se permiten caracteres ASCII",
|
||||||
"channelCreatedSuccessfully": "Canal creado exitosamente",
|
"channelCreatedSuccessfully": "Canal creado exitosamente",
|
||||||
"channelCreationFailed": "Error al crear el canal: {error}",
|
"channelCreationFailed": "Error al crear el canal: {error}",
|
||||||
|
"deleteChannel": "Eliminar Canal",
|
||||||
|
"deleteChannelConfirmation": "¿Está seguro de que desea eliminar el canal \"{channelName}\"? Esta acción no se puede deshacer.",
|
||||||
|
"channelDeletedSuccessfully": "Canal eliminado exitosamente",
|
||||||
|
"channelDeletionFailed": "Error al eliminar el canal: {error}",
|
||||||
"allChannelSlotsInUse": "Todos los espacios de canales están en uso (máximo 39 canales personalizados)",
|
"allChannelSlotsInUse": "Todos los espacios de canales están en uso (máximo 39 canales personalizados)",
|
||||||
"createChannel": "Crear Canal",
|
"createChannel": "Crear Canal",
|
||||||
|
|
||||||
|
|||||||
@@ -2771,6 +2771,8 @@
|
|||||||
"channelSecret": "Mot de Passe du Canal",
|
"channelSecret": "Mot de Passe du Canal",
|
||||||
"channelSecretHint": "Mot de passe partagé pour ce 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",
|
"channelSecretHelp": "Ce mot de passe doit être partagé avec tous les membres de l'équipe qui ont besoin d'accéder à ce canal",
|
||||||
|
"channelTypesInfo": "Canaux hash (#équipe) : Mot de passe généré automatiquement à partir du nom. Même nom = même canal sur tous les appareils.\n\nCanaux privés : Utilisez un mot de passe explicite. Seuls ceux qui ont le mot de passe peuvent rejoindre.",
|
||||||
|
"hashChannelInfo": "Canal hash : Le mot de passe sera automatiquement généré à partir du nom du canal. Toute personne utilisant le même nom rejoindra le même canal.",
|
||||||
"channelNameRequired": "Le nom du canal est requis",
|
"channelNameRequired": "Le nom du canal est requis",
|
||||||
"channelNameTooLong": "Le nom du canal doit contenir 31 caractères ou moins",
|
"channelNameTooLong": "Le nom du canal doit contenir 31 caractères ou moins",
|
||||||
"channelSecretRequired": "Le mot de passe du canal est requis",
|
"channelSecretRequired": "Le mot de passe du canal est requis",
|
||||||
|
|||||||
@@ -1045,6 +1045,8 @@
|
|||||||
"channelSecret": "Lozinka kanala",
|
"channelSecret": "Lozinka kanala",
|
||||||
"channelSecretHint": "Zajednička lozinka za ovaj kanal",
|
"channelSecretHint": "Zajednička lozinka za ovaj kanal",
|
||||||
"channelSecretHelp": "Ova lozinka mora biti podijeljena sa svim članovima tima koji trebaju pristup ovom kanalu",
|
"channelSecretHelp": "Ova lozinka mora biti podijeljena sa svim članovima tima koji trebaju pristup ovom kanalu",
|
||||||
|
"channelTypesInfo": "Hash kanali (#tim): Lozinka automatski generirana iz imena. Isto ime = isti kanal na svim uređajima.\n\nPrivatni kanali: Koristite eksplicitnu lozinku. Samo oni s lozinkom se mogu pridružiti.",
|
||||||
|
"hashChannelInfo": "Hash kanal: Lozinka će biti automatski generirana iz imena kanala. Bilo tko tko koristi isto ime pridružit će se istom kanalu.",
|
||||||
"channelNameRequired": "Ime kanala je obavezno",
|
"channelNameRequired": "Ime kanala je obavezno",
|
||||||
"channelNameTooLong": "Ime kanala mora imati najviše 31 znak",
|
"channelNameTooLong": "Ime kanala mora imati najviše 31 znak",
|
||||||
"channelSecretRequired": "Lozinka kanala je obavezna",
|
"channelSecretRequired": "Lozinka kanala je obavezna",
|
||||||
@@ -1052,6 +1054,10 @@
|
|||||||
"invalidAsciiCharacters": "Samo ASCII znakovi su dozvoljeni",
|
"invalidAsciiCharacters": "Samo ASCII znakovi su dozvoljeni",
|
||||||
"channelCreatedSuccessfully": "Kanal uspješno kreiran",
|
"channelCreatedSuccessfully": "Kanal uspješno kreiran",
|
||||||
"channelCreationFailed": "Neuspješno kreiranje kanala: {error}",
|
"channelCreationFailed": "Neuspješno kreiranje kanala: {error}",
|
||||||
|
"deleteChannel": "Izbriši kanal",
|
||||||
|
"deleteChannelConfirmation": "Jeste li sigurni da želite izbrisati kanal \"{channelName}\"? Ova radnja se ne može poništiti.",
|
||||||
|
"channelDeletedSuccessfully": "Kanal uspješno izbrisan",
|
||||||
|
"channelDeletionFailed": "Neuspješno brisanje kanala: {error}",
|
||||||
"allChannelSlotsInUse": "Svi slotovi kanala su zauzeti (maksimalno 39 prilagođenih kanala)",
|
"allChannelSlotsInUse": "Svi slotovi kanala su zauzeti (maksimalno 39 prilagođenih kanala)",
|
||||||
"createChannel": "Kreiraj kanal",
|
"createChannel": "Kreiraj kanal",
|
||||||
|
|
||||||
|
|||||||
@@ -2771,6 +2771,8 @@
|
|||||||
"channelSecret": "Password del Canale",
|
"channelSecret": "Password del Canale",
|
||||||
"channelSecretHint": "Password condivisa per questo 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",
|
"channelSecretHelp": "Questa password deve essere condivisa con tutti i membri del team che necessitano di accesso a questo canale",
|
||||||
|
"channelTypesInfo": "Canali hash (#squadra): Password generata automaticamente dal nome. Stesso nome = stesso canale su tutti i dispositivi.\n\nCanali privati: Usa una password esplicita. Solo chi ha la password può unirsi.",
|
||||||
|
"hashChannelInfo": "Canale hash: La password verrà generata automaticamente dal nome del canale. Chiunque utilizzi lo stesso nome si unirà allo stesso canale.",
|
||||||
"channelNameRequired": "Il nome del canale è obbligatorio",
|
"channelNameRequired": "Il nome del canale è obbligatorio",
|
||||||
"channelNameTooLong": "Il nome del canale deve contenere al massimo 31 caratteri",
|
"channelNameTooLong": "Il nome del canale deve contenere al massimo 31 caratteri",
|
||||||
"channelSecretRequired": "La password del canale è obbligatoria",
|
"channelSecretRequired": "La password del canale è obbligatoria",
|
||||||
|
|||||||
@@ -3723,6 +3723,18 @@ abstract class AppLocalizations {
|
|||||||
/// **'This secret must be shared with all team members who need access to this channel'**
|
/// **'This secret must be shared with all team members who need access to this channel'**
|
||||||
String get channelSecretHelp;
|
String get channelSecretHelp;
|
||||||
|
|
||||||
|
/// Information banner explaining hash and private channel types
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Hash channels (#team): Secret auto-generated from name. Same name = same channel across devices.\n\nPrivate channels: Use explicit secret. Only those with the secret can join.'**
|
||||||
|
String get channelTypesInfo;
|
||||||
|
|
||||||
|
/// Help text for hash channels (# prefix)
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Hash channel: Secret will be auto-generated from the channel name. Anyone using the same name will join the same channel.'**
|
||||||
|
String get hashChannelInfo;
|
||||||
|
|
||||||
/// Validation error for empty channel name
|
/// Validation error for empty channel name
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|||||||
@@ -2076,6 +2076,14 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get channelSecretHelp =>
|
String get channelSecretHelp =>
|
||||||
'Dieses Passwort muss mit allen Teammitgliedern geteilt werden, die Zugriff auf diesen Kanal benötigen';
|
'Dieses Passwort muss mit allen Teammitgliedern geteilt werden, die Zugriff auf diesen Kanal benötigen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get channelTypesInfo =>
|
||||||
|
'Hash-Kanäle (#team): Passwort automatisch aus dem Namen generiert. Gleicher Name = gleicher Kanal auf allen Geräten.\n\nPrivate Kanäle: Verwenden Sie ein explizites Passwort. Nur diejenigen mit dem Passwort können beitreten.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get hashChannelInfo =>
|
||||||
|
'Hash-Kanal: Das Passwort wird automatisch aus dem Kanalnamen generiert. Jeder, der denselben Namen verwendet, wird demselben Kanal beitreten.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get channelNameRequired => 'Kanalname ist erforderlich';
|
String get channelNameRequired => 'Kanalname ist erforderlich';
|
||||||
|
|
||||||
|
|||||||
@@ -2056,6 +2056,14 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get channelSecretHelp =>
|
String get channelSecretHelp =>
|
||||||
'This secret must be shared with all team members who need access to this channel';
|
'This secret must be shared with all team members who need access to this channel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get channelTypesInfo =>
|
||||||
|
'Hash channels (#team): Secret auto-generated from name. Same name = same channel across devices.\n\nPrivate channels: Use explicit secret. Only those with the secret can join.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get hashChannelInfo =>
|
||||||
|
'Hash channel: Secret will be auto-generated from the channel name. Anyone using the same name will join the same channel.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get channelNameRequired => 'Channel name is required';
|
String get channelNameRequired => 'Channel name is required';
|
||||||
|
|
||||||
|
|||||||
@@ -2081,6 +2081,14 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
String get channelSecretHelp =>
|
String get channelSecretHelp =>
|
||||||
'Esta contraseña debe compartirse con todos los miembros del equipo que necesiten acceso a este canal';
|
'Esta contraseña debe compartirse con todos los miembros del equipo que necesiten acceso a este canal';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get channelTypesInfo =>
|
||||||
|
'Canales hash (#equipo): Contraseña generada automáticamente del nombre. Mismo nombre = mismo canal en todos los dispositivos.\n\nCanales privados: Use contraseña explícita. Solo aquellos con la contraseña pueden unirse.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get hashChannelInfo =>
|
||||||
|
'Canal hash: La contraseña se generará automáticamente del nombre del canal. Cualquiera que use el mismo nombre se unirá al mismo canal.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get channelNameRequired => 'El nombre del canal es obligatorio';
|
String get channelNameRequired => 'El nombre del canal es obligatorio';
|
||||||
|
|
||||||
|
|||||||
@@ -2085,6 +2085,14 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get channelSecretHelp =>
|
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';
|
'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 channelTypesInfo =>
|
||||||
|
'Canaux hash (#équipe) : Mot de passe généré automatiquement à partir du nom. Même nom = même canal sur tous les appareils.\n\nCanaux privés : Utilisez un mot de passe explicite. Seuls ceux qui ont le mot de passe peuvent rejoindre.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get hashChannelInfo =>
|
||||||
|
'Canal hash : Le mot de passe sera automatiquement généré à partir du nom du canal. Toute personne utilisant le même nom rejoindra le même canal.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get channelNameRequired => 'Le nom du canal est requis';
|
String get channelNameRequired => 'Le nom du canal est requis';
|
||||||
|
|
||||||
|
|||||||
@@ -2066,6 +2066,14 @@ class AppLocalizationsHr extends AppLocalizations {
|
|||||||
String get channelSecretHelp =>
|
String get channelSecretHelp =>
|
||||||
'Ova lozinka mora biti podijeljena sa svim članovima tima koji trebaju pristup ovom kanalu';
|
'Ova lozinka mora biti podijeljena sa svim članovima tima koji trebaju pristup ovom kanalu';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get channelTypesInfo =>
|
||||||
|
'Hash kanali (#tim): Lozinka automatski generirana iz imena. Isto ime = isti kanal na svim uređajima.\n\nPrivatni kanali: Koristite eksplicitnu lozinku. Samo oni s lozinkom se mogu pridružiti.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get hashChannelInfo =>
|
||||||
|
'Hash kanal: Lozinka će biti automatski generirana iz imena kanala. Bilo tko tko koristi isto ime pridružit će se istom kanalu.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get channelNameRequired => 'Ime kanala je obavezno';
|
String get channelNameRequired => 'Ime kanala je obavezno';
|
||||||
|
|
||||||
|
|||||||
@@ -2076,6 +2076,14 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
String get channelSecretHelp =>
|
String get channelSecretHelp =>
|
||||||
'Questa password deve essere condivisa con tutti i membri del team che necessitano di accesso a questo canale';
|
'Questa password deve essere condivisa con tutti i membri del team che necessitano di accesso a questo canale';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get channelTypesInfo =>
|
||||||
|
'Canali hash (#squadra): Password generata automaticamente dal nome. Stesso nome = stesso canale su tutti i dispositivi.\n\nCanali privati: Usa una password esplicita. Solo chi ha la password può unirsi.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get hashChannelInfo =>
|
||||||
|
'Canale hash: La password verrà generata automaticamente dal nome del canale. Chiunque utilizzi lo stesso nome si unirà allo stesso canale.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get channelNameRequired => 'Il nome del canale è obbligatorio';
|
String get channelNameRequired => 'Il nome del canale è obbligatorio';
|
||||||
|
|
||||||
|
|||||||
@@ -2066,6 +2066,14 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
String get channelSecretHelp =>
|
String get channelSecretHelp =>
|
||||||
'To geslo mora biti deljeno z vsemi člani ekipe, ki potrebujejo dostop do tega kanala';
|
'To geslo mora biti deljeno z vsemi člani ekipe, ki potrebujejo dostop do tega kanala';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get channelTypesInfo =>
|
||||||
|
'Hash kanali (#ekipa): Geslo samodejno generirano iz imena. Enako ime = isti kanal na vseh napravah.\n\nZasebni kanali: Uporabite eksplicitno geslo. Samo tisti z geslom se lahko pridružijo.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get hashChannelInfo =>
|
||||||
|
'Hash kanal: Geslo bo samodejno generirano iz imena kanala. Kdorkoli uporabi isto ime, se bo pridružil istemu kanalu.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get channelNameRequired => 'Ime kanala je obvezno';
|
String get channelNameRequired => 'Ime kanala je obvezno';
|
||||||
|
|
||||||
|
|||||||
@@ -1049,6 +1049,8 @@
|
|||||||
"channelSecret": "Geslo kanala",
|
"channelSecret": "Geslo kanala",
|
||||||
"channelSecretHint": "Skupno geslo za ta kanal",
|
"channelSecretHint": "Skupno geslo za ta kanal",
|
||||||
"channelSecretHelp": "To geslo mora biti deljeno z vsemi člani ekipe, ki potrebujejo dostop do tega kanala",
|
"channelSecretHelp": "To geslo mora biti deljeno z vsemi člani ekipe, ki potrebujejo dostop do tega kanala",
|
||||||
|
"channelTypesInfo": "Hash kanali (#ekipa): Geslo samodejno generirano iz imena. Enako ime = isti kanal na vseh napravah.\n\nZasebni kanali: Uporabite eksplicitno geslo. Samo tisti z geslom se lahko pridružijo.",
|
||||||
|
"hashChannelInfo": "Hash kanal: Geslo bo samodejno generirano iz imena kanala. Kdorkoli uporabi isto ime, se bo pridružil istemu kanalu.",
|
||||||
"channelNameRequired": "Ime kanala je obvezno",
|
"channelNameRequired": "Ime kanala je obvezno",
|
||||||
"channelNameTooLong": "Ime kanala mora imeti največ 31 znakov",
|
"channelNameTooLong": "Ime kanala mora imeti največ 31 znakov",
|
||||||
"channelSecretRequired": "Geslo kanala je obvezno",
|
"channelSecretRequired": "Geslo kanala je obvezno",
|
||||||
@@ -1056,6 +1058,10 @@
|
|||||||
"invalidAsciiCharacters": "Dovoljeni so samo ASCII znaki",
|
"invalidAsciiCharacters": "Dovoljeni so samo ASCII znaki",
|
||||||
"channelCreatedSuccessfully": "Kanal uspešno ustvarjen",
|
"channelCreatedSuccessfully": "Kanal uspešno ustvarjen",
|
||||||
"channelCreationFailed": "Neuspešno ustvarjanje kanala: {error}",
|
"channelCreationFailed": "Neuspešno ustvarjanje kanala: {error}",
|
||||||
|
"deleteChannel": "Izbriši kanal",
|
||||||
|
"deleteChannelConfirmation": "Ali ste prepričani, da želite izbrisati kanal \"{channelName}\"? Tega dejanja ni mogoče razveljaviti.",
|
||||||
|
"channelDeletedSuccessfully": "Kanal uspešno izbrisan",
|
||||||
|
"channelDeletionFailed": "Neuspešno brisanje kanala: {error}",
|
||||||
"allChannelSlotsInUse": "Vsa mesta za kanale so zasedena (maksimalno 39 prilagojenih kanalov)",
|
"allChannelSlotsInUse": "Vsa mesta za kanale so zasedena (maksimalno 39 prilagojenih kanalov)",
|
||||||
"createChannel": "Ustvari kanal",
|
"createChannel": "Ustvari kanal",
|
||||||
|
|
||||||
|
|||||||
@@ -154,15 +154,84 @@ class AppProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Setup callback for ConnectionProvider to query channel info
|
||||||
|
connectionProvider.getChannelInfo = (int channelIdx) {
|
||||||
|
return channelsProvider.getChannel(channelIdx);
|
||||||
|
};
|
||||||
|
|
||||||
// When channel info is received
|
// When channel info is received
|
||||||
connectionProvider.onChannelInfoReceived = (int channelIdx, String channelName, Uint8List secret, int? flags) {
|
connectionProvider.onChannelInfoReceived = (int channelIdx, String channelName, Uint8List secret, int? flags) {
|
||||||
channelsProvider.addOrUpdateChannel(
|
try {
|
||||||
index: channelIdx,
|
debugPrint('🔔 [AppProvider] onChannelInfoReceived called: idx=$channelIdx, name="$channelName"');
|
||||||
name: channelName,
|
|
||||||
secret: secret,
|
// Check if this is a channel deletion (empty name)
|
||||||
flags: flags,
|
if (channelName.isEmpty && channelIdx != 0) {
|
||||||
);
|
debugPrint(' 🗑️ Channel $channelIdx deleted - removing from providers');
|
||||||
debugPrint('📻 [AppProvider] Channel $channelIdx: "$channelName" (isHashChannel: ${channelName.startsWith('#')})');
|
|
||||||
|
// Remove from ChannelsProvider
|
||||||
|
channelsProvider.removeChannel(channelIdx);
|
||||||
|
debugPrint(' ✅ Removed from ChannelsProvider');
|
||||||
|
|
||||||
|
// Remove from ContactsProvider using pseudo public key
|
||||||
|
final publicKeyBytes = Uint8List(32);
|
||||||
|
publicKeyBytes[0] = 0xFF; // Special marker for channels
|
||||||
|
publicKeyBytes[1] = channelIdx; // Channel index
|
||||||
|
final publicKeyHex = publicKeyBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
|
||||||
|
|
||||||
|
contactsProvider.removeContact(publicKeyHex);
|
||||||
|
debugPrint(' ✅ Removed from ContactsProvider');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add/update in ChannelsProvider
|
||||||
|
channelsProvider.addOrUpdateChannel(
|
||||||
|
index: channelIdx,
|
||||||
|
name: channelName,
|
||||||
|
secret: secret,
|
||||||
|
flags: flags,
|
||||||
|
);
|
||||||
|
debugPrint(' ✅ Added to ChannelsProvider');
|
||||||
|
|
||||||
|
// Also add as Contact to ContactsProvider (for UI display)
|
||||||
|
// Skip if it's public channel (already exists)
|
||||||
|
debugPrint('📻 [AppProvider] Channel $channelIdx: "$channelName" (isEmpty: ${channelName.isEmpty}, isHashChannel: ${channelName.startsWith('#')})');
|
||||||
|
|
||||||
|
if (channelName.isNotEmpty && channelIdx != 0) {
|
||||||
|
debugPrint(' ✅ Adding channel $channelIdx to ContactsProvider as Contact');
|
||||||
|
|
||||||
|
// Create a pseudo public key for the channel based on its index
|
||||||
|
// Use channel index as a unique identifier (pad to 32 bytes)
|
||||||
|
final publicKeyBytes = Uint8List(32);
|
||||||
|
publicKeyBytes[0] = 0xFF; // Special marker for channels
|
||||||
|
publicKeyBytes[1] = channelIdx; // Channel index
|
||||||
|
|
||||||
|
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||||
|
|
||||||
|
contactsProvider.addOrUpdateContact(
|
||||||
|
Contact(
|
||||||
|
publicKey: publicKeyBytes,
|
||||||
|
type: ContactType.channel,
|
||||||
|
flags: flags ?? 0,
|
||||||
|
outPathLen: -1, // Flood mode for channels
|
||||||
|
outPath: Uint8List(0), // Empty path for channels
|
||||||
|
advName: channelName,
|
||||||
|
lastAdvert: now,
|
||||||
|
advLat: 0, // Channels don't have location
|
||||||
|
advLon: 0,
|
||||||
|
lastMod: now,
|
||||||
|
isNew: false, // Don't mark channels as new
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
debugPrint(' ✅ Channel contact added. Total channels in ContactsProvider: ${contactsProvider.channels.length}');
|
||||||
|
} else {
|
||||||
|
debugPrint(' ⏭️ Skipping channel $channelIdx (empty: ${channelName.isEmpty}, isPublic: ${channelIdx == 0})');
|
||||||
|
}
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
debugPrint('❌ [AppProvider] Error in onChannelInfoReceived: $e');
|
||||||
|
debugPrint(' Stack trace: $stackTrace');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// When a message is received
|
// When a message is received
|
||||||
@@ -492,12 +561,18 @@ class AppProvider with ChangeNotifier {
|
|||||||
// Removed _syncMessages() - messages are automatically synced via PUSH_CODE_MSG_WAITING events
|
// Removed _syncMessages() - messages are automatically synced via PUSH_CODE_MSG_WAITING events
|
||||||
// The ConnectionProvider's onMessageWaiting callback handles automatic message fetching
|
// The ConnectionProvider's onMessageWaiting callback handles automatic message fetching
|
||||||
|
|
||||||
/// Refresh data (contacts only - messages are handled via events)
|
/// Refresh data (contacts and channels - messages are handled via events)
|
||||||
Future<void> refresh() async {
|
Future<void> refresh() async {
|
||||||
if (!connectionProvider.deviceInfo.isConnected) return;
|
if (!connectionProvider.deviceInfo.isConnected) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Sync contacts
|
||||||
await connectionProvider.getContacts();
|
await connectionProvider.getContacts();
|
||||||
|
|
||||||
|
// Sync channels (respect simple mode settings)
|
||||||
|
final channelsToSync = _isSimpleMode ? 5 : null;
|
||||||
|
await connectionProvider.syncChannels(maxChannels: channelsToSync);
|
||||||
|
|
||||||
// Messages are automatically synced via PUSH_CODE_MSG_WAITING events
|
// Messages are automatically synced via PUSH_CODE_MSG_WAITING events
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -50,6 +50,20 @@ class ChannelsProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove a channel by index
|
||||||
|
void removeChannel(int index) {
|
||||||
|
if (_channels.containsKey(index)) {
|
||||||
|
_channels.remove(index);
|
||||||
|
|
||||||
|
// If the deleted channel was selected, switch to public channel
|
||||||
|
if (_selectedChannelIndex == index) {
|
||||||
|
_selectedChannelIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Select a channel for sending messages
|
/// Select a channel for sending messages
|
||||||
void selectChannel(int index) {
|
void selectChannel(int index) {
|
||||||
if (_channels.containsKey(index) || index == 0) {
|
if (_channels.containsKey(index) || index == 0) {
|
||||||
|
|||||||
@@ -865,6 +865,47 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
///
|
///
|
||||||
/// Returns the channel index of the first empty slot, or null if all slots are in use.
|
/// Returns the channel index of the first empty slot, or null if all slots are in use.
|
||||||
/// Skips slot 0 (reserved for Public Channel).
|
/// Skips slot 0 (reserved for Public Channel).
|
||||||
|
/// Callback to get channel info for empty slot detection
|
||||||
|
/// This should be set by AppProvider to query ChannelsProvider
|
||||||
|
Function(int channelIdx)? getChannelInfo;
|
||||||
|
|
||||||
|
/// Check if a specific channel slot is empty
|
||||||
|
Future<bool> isChannelSlotEmpty(int channelIdx) async {
|
||||||
|
if (!_bleService.isConnected) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// First check if we already have info about this channel
|
||||||
|
if (getChannelInfo != null) {
|
||||||
|
final channel = getChannelInfo!(channelIdx);
|
||||||
|
if (channel != null) {
|
||||||
|
final channelName = (channel as dynamic).name as String?;
|
||||||
|
return channelName == null || channelName.isEmpty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not cached, query the device
|
||||||
|
await _bleService.getChannel(channelIdx);
|
||||||
|
await Future.delayed(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
|
// Check again after query
|
||||||
|
if (getChannelInfo != null) {
|
||||||
|
final channel = getChannelInfo!(channelIdx);
|
||||||
|
if (channel != null) {
|
||||||
|
final channelName = (channel as dynamic).name as String?;
|
||||||
|
return channelName == null || channelName.isEmpty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If still no info, assume it's empty
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [Provider] Failed to check slot $channelIdx: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<int?> findNextEmptyChannelSlot() async {
|
Future<int?> findNextEmptyChannelSlot() async {
|
||||||
if (!_bleService.isConnected) {
|
if (!_bleService.isConnected) {
|
||||||
throw Exception('Not connected to device');
|
throw Exception('Not connected to device');
|
||||||
@@ -873,26 +914,34 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
debugPrint('🔍 [Provider] Finding next empty channel slot...');
|
debugPrint('🔍 [Provider] Finding next empty channel slot...');
|
||||||
|
|
||||||
// Query channels 1-39 (skip 0 = public channel)
|
|
||||||
// maxChannels from device info, or default to 40
|
// maxChannels from device info, or default to 40
|
||||||
final maxChannels = _deviceInfo.maxChannels ?? 40;
|
final maxChannels = _deviceInfo.maxChannels ?? 40;
|
||||||
|
|
||||||
|
// Check each slot starting from 1 (skip 0 = public channel)
|
||||||
for (int i = 1; i < maxChannels; i++) {
|
for (int i = 1; i < maxChannels; i++) {
|
||||||
await _bleService.getChannel(i);
|
// First check cache
|
||||||
// Small delay to allow response to arrive
|
if (getChannelInfo != null) {
|
||||||
await Future.delayed(const Duration(milliseconds: 50));
|
final channel = getChannelInfo!(i);
|
||||||
|
if (channel != null) {
|
||||||
|
final channelName = (channel as dynamic).name as String?;
|
||||||
|
if (channelName != null && channelName.isNotEmpty) {
|
||||||
|
debugPrint(' ⏭️ Slot $i occupied: "$channelName"');
|
||||||
|
continue; // Skip occupied slots
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check if this channel is empty via channels provider
|
// Slot appears empty in cache, verify by querying device
|
||||||
// (The BLE response handler calls onChannelInfoReceived callback
|
debugPrint(' 🔍 Checking slot $i...');
|
||||||
// which updates the channels provider)
|
final isEmpty = await isChannelSlotEmpty(i);
|
||||||
// For now, we'll return the first slot since the channels provider
|
if (isEmpty) {
|
||||||
// doesn't expose empty slot info. This can be improved later.
|
debugPrint(' ✅ Found empty slot: $i');
|
||||||
|
return i;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For simplicity, return the first slot after public channel
|
debugPrint(' ❌ All slots (1-${maxChannels - 1}) are in use');
|
||||||
// A more robust implementation would check which slots are actually empty
|
return null;
|
||||||
// by querying the channels provider
|
|
||||||
return 1;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [Provider] Failed to find empty channel slot: $e');
|
debugPrint('❌ [Provider] Failed to find empty channel slot: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -921,17 +970,63 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
debugPrint('📻 [Provider] Creating new channel...');
|
debugPrint('📻 [Provider] Creating new channel...');
|
||||||
debugPrint(' Name: $channelName');
|
debugPrint(' Name: $channelName');
|
||||||
|
|
||||||
// Find next empty slot
|
// Determine channel type
|
||||||
final slotIdx = await findNextEmptyChannelSlot();
|
final bool isHashChannel = channelName.startsWith('#');
|
||||||
if (slotIdx == null) {
|
|
||||||
throw Exception('All channel slots are in use (maximum 39 custom channels)');
|
// Check for duplicate channels
|
||||||
|
int? existingSlot;
|
||||||
|
if (getChannelInfo != null) {
|
||||||
|
final maxChannels = _deviceInfo.maxChannels ?? 40;
|
||||||
|
for (int i = 1; i < maxChannels; i++) {
|
||||||
|
final channel = getChannelInfo!(i);
|
||||||
|
if (channel != null) {
|
||||||
|
final existingName = (channel as dynamic).name as String?;
|
||||||
|
if (existingName != null && existingName.isNotEmpty) {
|
||||||
|
// For hash channels (#name), check exact match to prevent duplicates
|
||||||
|
if (isHashChannel && existingName == channelName) {
|
||||||
|
debugPrint(' ⚠️ Hash channel "$channelName" already exists in slot $i');
|
||||||
|
throw Exception('Channel "$channelName" already exists. Hash channels cannot be duplicated.');
|
||||||
|
}
|
||||||
|
// For private channels, check name match to allow overwrite
|
||||||
|
else if (!isHashChannel && existingName == channelName) {
|
||||||
|
debugPrint(' ℹ️ Private channel "$channelName" found in slot $i - will overwrite');
|
||||||
|
existingSlot = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint(' Using slot: $slotIdx');
|
// Determine slot to use
|
||||||
|
final int slotIdx;
|
||||||
|
if (existingSlot != null) {
|
||||||
|
// Overwrite existing private channel
|
||||||
|
slotIdx = existingSlot;
|
||||||
|
debugPrint(' Using existing slot: $slotIdx (overwrite mode)');
|
||||||
|
} else {
|
||||||
|
// Find next empty slot for new channel
|
||||||
|
final emptySlot = await findNextEmptyChannelSlot();
|
||||||
|
if (emptySlot == null) {
|
||||||
|
throw Exception('All channel slots are in use (maximum 39 custom channels)');
|
||||||
|
}
|
||||||
|
slotIdx = emptySlot;
|
||||||
|
debugPrint(' Using empty slot: $slotIdx (new channel)');
|
||||||
|
}
|
||||||
|
|
||||||
// Convert ASCII secret to 16-byte key using MD5
|
// Generate secret
|
||||||
final secretBytes = _convertSecretToBytes(channelSecret);
|
final List<int> secretBytes;
|
||||||
debugPrint(' Secret converted to 16-byte key');
|
if (isHashChannel) {
|
||||||
|
// Hash channel: auto-generate secret from name using SHA256
|
||||||
|
debugPrint(' Channel type: Hash channel (#)');
|
||||||
|
secretBytes = _generateHashChannelSecret(channelName);
|
||||||
|
debugPrint(' Secret auto-generated from channel name using SHA256');
|
||||||
|
} else {
|
||||||
|
// Private channel: use explicit secret with MD5
|
||||||
|
debugPrint(' Channel type: Private channel');
|
||||||
|
secretBytes = _convertSecretToBytes(channelSecret);
|
||||||
|
debugPrint(' Secret converted to 16-byte key using MD5');
|
||||||
|
}
|
||||||
|
|
||||||
// Send CMD_SET_CHANNEL to radio
|
// Send CMD_SET_CHANNEL to radio
|
||||||
await _bleService.setChannel(
|
await _bleService.setChannel(
|
||||||
@@ -940,7 +1035,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
secret: secretBytes,
|
secret: secretBytes,
|
||||||
);
|
);
|
||||||
|
|
||||||
debugPrint('✅ [Provider] Channel created successfully in slot $slotIdx');
|
debugPrint('✅ [Provider] Channel ${existingSlot != null ? 'updated' : 'created'} successfully in slot $slotIdx');
|
||||||
|
|
||||||
// Small delay to allow the response to propagate
|
// Small delay to allow the response to propagate
|
||||||
await Future.delayed(const Duration(milliseconds: 100));
|
await Future.delayed(const Duration(milliseconds: 100));
|
||||||
@@ -956,7 +1051,56 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Delete a channel and remove it from the UI
|
||||||
|
///
|
||||||
|
/// Clears the channel slot on the device and removes it from both
|
||||||
|
/// ChannelsProvider and ContactsProvider. The slot becomes available for reuse.
|
||||||
|
///
|
||||||
|
/// [channelIdx] - Channel slot index (1-39). Channel 0 (public) cannot be deleted.
|
||||||
|
///
|
||||||
|
/// Throws an exception if the channel cannot be deleted or if channel 0 is specified.
|
||||||
|
Future<void> deleteChannel(int channelIdx) async {
|
||||||
|
if (!_bleService.isConnected) {
|
||||||
|
throw Exception('Not connected to device');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channelIdx == 0) {
|
||||||
|
throw Exception('Cannot delete the public channel');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
debugPrint('🗑️ [Provider] Deleting channel in slot $channelIdx...');
|
||||||
|
|
||||||
|
// Delete channel on device (sets empty name and zeroed secret)
|
||||||
|
await _bleService.deleteChannel(channelIdx);
|
||||||
|
|
||||||
|
debugPrint('✅ [Provider] Channel deleted successfully from slot $channelIdx');
|
||||||
|
|
||||||
|
// Small delay to allow the response to propagate
|
||||||
|
await Future.delayed(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
|
// Refresh channels to update UI
|
||||||
|
// The empty channel will trigger removal via onChannelInfoReceived callback
|
||||||
|
await _bleService.getChannel(channelIdx);
|
||||||
|
} catch (e) {
|
||||||
|
_error = 'Failed to delete channel: $e';
|
||||||
|
debugPrint('❌ [Provider] Channel deletion failed: $e');
|
||||||
|
notifyListeners();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate secret for hash channel using SHA256
|
||||||
|
/// Same algorithm as Channel model for consistency
|
||||||
|
/// Python equivalent: hashlib.sha256(channel_name.encode()).digest()[0:16]
|
||||||
|
List<int> _generateHashChannelSecret(String channelName) {
|
||||||
|
final bytes = utf8.encode(channelName);
|
||||||
|
final digest = sha256.convert(bytes);
|
||||||
|
return digest.bytes.sublist(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert ASCII secret string to 16-byte key using MD5 hash
|
/// Convert ASCII secret string to 16-byte key using MD5 hash
|
||||||
|
/// Used for private channels with explicit secrets
|
||||||
List<int> _convertSecretToBytes(String asciiSecret) {
|
List<int> _convertSecretToBytes(String asciiSecret) {
|
||||||
// Use MD5 hash to convert any length ASCII string to exactly 16 bytes
|
// Use MD5 hash to convert any length ASCII string to exactly 16 bytes
|
||||||
// This provides a deterministic and secure way to generate channel keys
|
// This provides a deterministic and secure way to generate channel keys
|
||||||
|
|||||||
@@ -194,6 +194,8 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
/// Add or update a contact
|
/// Add or update a contact
|
||||||
/// Excludes contacts that match the device's own public key
|
/// Excludes contacts that match the device's own public key
|
||||||
void addOrUpdateContact(Contact contact, {Uint8List? devicePublicKey}) {
|
void addOrUpdateContact(Contact contact, {Uint8List? devicePublicKey}) {
|
||||||
|
debugPrint('📝 [ContactsProvider] addOrUpdateContact called: ${contact.advName} (type: ${contact.type.displayName}, key: ${contact.publicKeyHex.substring(0, 8)}...)');
|
||||||
|
|
||||||
// Don't add contacts that match our device's public key
|
// Don't add contacts that match our device's public key
|
||||||
if (devicePublicKey != null &&
|
if (devicePublicKey != null &&
|
||||||
_publicKeysMatch(contact.publicKey, devicePublicKey)) {
|
_publicKeysMatch(contact.publicKey, devicePublicKey)) {
|
||||||
@@ -205,6 +207,7 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Check if this is a new contact
|
// Check if this is a new contact
|
||||||
final isNewContact = !_contacts.containsKey(contact.publicKeyHex);
|
final isNewContact = !_contacts.containsKey(contact.publicKeyHex);
|
||||||
|
debugPrint(' isNew: $isNewContact, total contacts before: ${_contacts.length}');
|
||||||
|
|
||||||
Contact updatedContact;
|
Contact updatedContact;
|
||||||
if (isNewContact) {
|
if (isNewContact) {
|
||||||
@@ -242,8 +245,10 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_contacts[contact.publicKeyHex] = updatedContact;
|
_contacts[contact.publicKeyHex] = updatedContact;
|
||||||
|
debugPrint(' ✅ Contact added/updated. Total contacts: ${_contacts.length}, channels: ${channels.length}');
|
||||||
_persistContacts();
|
_persistContacts();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
debugPrint(' 🔔 notifyListeners() called');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compare two public keys for equality
|
/// Compare two public keys for equality
|
||||||
|
|||||||
@@ -234,44 +234,43 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
],
|
],
|
||||||
|
|
||||||
// Channels (hidden in simple mode)
|
// Channels (visible in both simple and advanced mode)
|
||||||
if (!isSimpleMode) ...[
|
_SectionHeader(
|
||||||
_SectionHeader(
|
title: l10n.channels,
|
||||||
title: l10n.channels,
|
count: channels.length,
|
||||||
count: channels.length,
|
icon: Icons.broadcast_on_personal,
|
||||||
icon: Icons.broadcast_on_personal,
|
),
|
||||||
),
|
if (channels.isNotEmpty) ...[
|
||||||
if (channels.isNotEmpty) ...[
|
...channels.map(
|
||||||
...channels.map(
|
(contact) => ContactTile(
|
||||||
(contact) => ContactTile(
|
contact: contact,
|
||||||
contact: contact,
|
currentPosition: _currentPosition,
|
||||||
currentPosition: _currentPosition,
|
calculateDistance: _calculateDistanceInMeters,
|
||||||
calculateDistance: _calculateDistanceInMeters,
|
formatDistance: _formatDistance,
|
||||||
formatDistance: _formatDistance,
|
onNavigateToMap: widget.onNavigateToMap,
|
||||||
onNavigateToMap: widget.onNavigateToMap,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
// Add Channel Button (only show when connected)
|
],
|
||||||
if (context.watch<ConnectionProvider>().deviceInfo.isConnected)
|
|
||||||
Padding(
|
// Add Channel Button (visible in both simple and advanced mode, only show when connected)
|
||||||
padding: const EdgeInsets.symmetric(
|
if (context.watch<ConnectionProvider>().deviceInfo.isConnected)
|
||||||
horizontal: 16,
|
Padding(
|
||||||
vertical: 8,
|
padding: const EdgeInsets.symmetric(
|
||||||
),
|
horizontal: 16,
|
||||||
child: OutlinedButton.icon(
|
vertical: 8,
|
||||||
onPressed: () => _showAddChannelDialog(context),
|
),
|
||||||
icon: const Icon(Icons.add_circle_outline),
|
child: OutlinedButton.icon(
|
||||||
label: Text(l10n.addChannel),
|
onPressed: () => _showAddChannelDialog(context),
|
||||||
style: OutlinedButton.styleFrom(
|
icon: const Icon(Icons.add_circle_outline),
|
||||||
padding: const EdgeInsets.symmetric(
|
label: Text(l10n.addChannel),
|
||||||
horizontal: 24,
|
style: OutlinedButton.styleFrom(
|
||||||
vertical: 12,
|
padding: const EdgeInsets.symmetric(
|
||||||
),
|
horizontal: 24,
|
||||||
|
vertical: 12,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1043,7 +1043,19 @@ class BleResponseHandler {
|
|||||||
final flags = info['flags'] as int?;
|
final flags = info['flags'] as int?;
|
||||||
|
|
||||||
debugPrint(' ✅ [ChannelInfo] Channel $channelIdx: "$channelName"');
|
debugPrint(' ✅ [ChannelInfo] Channel $channelIdx: "$channelName"');
|
||||||
onChannelInfoReceived?.call(channelIdx, channelName, secret, flags);
|
debugPrint(' Name length: ${channelName.length}');
|
||||||
|
debugPrint(' Name bytes: ${channelName.codeUnits.map((c) => c.toRadixString(16).padLeft(2, '0')).join(' ')}');
|
||||||
|
debugPrint(' Secret: ${secret.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}');
|
||||||
|
debugPrint(' isEmpty: ${channelName.isEmpty}');
|
||||||
|
debugPrint(' Callback exists: ${onChannelInfoReceived != null}');
|
||||||
|
|
||||||
|
if (onChannelInfoReceived != null) {
|
||||||
|
debugPrint(' 🔔 Calling onChannelInfoReceived callback...');
|
||||||
|
onChannelInfoReceived!(channelIdx, channelName, secret, flags);
|
||||||
|
debugPrint(' ✅ Callback completed');
|
||||||
|
} else {
|
||||||
|
debugPrint(' ⚠️ No callback registered!');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint(' ❌ [ChannelInfo] Parsing error: $e');
|
debugPrint(' ❌ [ChannelInfo] Parsing error: $e');
|
||||||
|
|||||||
@@ -609,6 +609,9 @@ class MeshCoreBleService {
|
|||||||
///
|
///
|
||||||
/// The secret must be exactly 16 bytes (128-bit encryption key).
|
/// The secret must be exactly 16 bytes (128-bit encryption key).
|
||||||
/// For the default public channel (channel 0), use [MeshCoreConstants.defaultPublicChannelSecret].
|
/// For the default public channel (channel 0), use [MeshCoreConstants.defaultPublicChannelSecret].
|
||||||
|
///
|
||||||
|
/// Note: Some firmware versions don't send ACK for SET_CHANNEL, so we use
|
||||||
|
/// fire-and-forget and then verify with GET_CHANNEL.
|
||||||
Future<void> setChannel({
|
Future<void> setChannel({
|
||||||
required int channelIdx,
|
required int channelIdx,
|
||||||
required String channelName,
|
required String channelName,
|
||||||
@@ -618,23 +621,58 @@ class MeshCoreBleService {
|
|||||||
debugPrint(' Channel index: $channelIdx');
|
debugPrint(' Channel index: $channelIdx');
|
||||||
debugPrint(' Channel name: $channelName');
|
debugPrint(' Channel name: $channelName');
|
||||||
debugPrint(' Secret length: ${secret.length} bytes');
|
debugPrint(' Secret length: ${secret.length} bytes');
|
||||||
|
debugPrint(' Secret hex: ${secret.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}');
|
||||||
|
|
||||||
await _commandSender.writeDataAndWaitForAck(
|
// Send SET_CHANNEL command (fire-and-forget, no ACK expected)
|
||||||
FrameBuilder.buildSetChannel(
|
final setChannelData = FrameBuilder.buildSetChannel(
|
||||||
channelIdx: channelIdx,
|
channelIdx: channelIdx,
|
||||||
channelName: channelName,
|
channelName: channelName,
|
||||||
secret: secret,
|
secret: secret,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
debugPrint('✅ [BLE] CMD_SET_CHANNEL sent successfully');
|
debugPrint(' SET_CHANNEL data (${setChannelData.length} bytes): ${setChannelData.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}');
|
||||||
|
|
||||||
|
await _commandSender.writeData(setChannelData);
|
||||||
|
debugPrint('✅ [BLE] CMD_SET_CHANNEL sent');
|
||||||
|
|
||||||
|
// Wait a bit for the device to process
|
||||||
|
await Future.delayed(const Duration(milliseconds: 200));
|
||||||
|
|
||||||
|
// Verify the channel was set by reading it back
|
||||||
|
debugPrint('🔍 [BLE] Verifying channel was set...');
|
||||||
|
await getChannel(channelIdx);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sync all channels from the device (typically 0-39)
|
/// Delete a channel by clearing its slot
|
||||||
/// This queries each channel to get its name and metadata
|
///
|
||||||
Future<void> syncAllChannels({int maxChannels = 40}) async {
|
/// This removes the channel from the device by setting it to an empty name and zeroed secret.
|
||||||
debugPrint('📻 [Service] Syncing channels (0-${maxChannels - 1})...');
|
/// The channel slot becomes available for reuse.
|
||||||
|
///
|
||||||
|
/// Note: Channel 0 (public channel) cannot be deleted.
|
||||||
|
Future<void> deleteChannel(int channelIdx) async {
|
||||||
|
if (channelIdx == 0) {
|
||||||
|
throw ArgumentError('Cannot delete channel 0 (public channel)');
|
||||||
|
}
|
||||||
|
|
||||||
for (int i = 0; i < maxChannels; i++) {
|
debugPrint('🗑️ [BLE] Deleting channel $channelIdx...');
|
||||||
|
|
||||||
|
// Clear channel by setting empty name and zeroed secret
|
||||||
|
await setChannel(
|
||||||
|
channelIdx: channelIdx,
|
||||||
|
channelName: '',
|
||||||
|
secret: List.filled(16, 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
debugPrint('✅ [BLE] Channel $channelIdx deleted');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sync all channels from the device (channels 1-39)
|
||||||
|
/// Skips channel 0 (public channel) which is implicit and not stored on device
|
||||||
|
Future<void> syncAllChannels({int maxChannels = 40}) async {
|
||||||
|
debugPrint('📻 [Service] Syncing channels (1-${maxChannels - 1})...');
|
||||||
|
|
||||||
|
// Start from 1 to skip channel 0 (public channel)
|
||||||
|
// Channel 0 is implicit and handled separately via configurePublicChannel()
|
||||||
|
for (int i = 1; i < maxChannels; i++) {
|
||||||
await getChannel(i);
|
await getChannel(i);
|
||||||
// Small delay to avoid overwhelming the device
|
// Small delay to avoid overwhelming the device
|
||||||
await Future.delayed(const Duration(milliseconds: 50));
|
await Future.delayed(const Duration(milliseconds: 50));
|
||||||
|
|||||||
@@ -390,16 +390,21 @@ class FrameParser {
|
|||||||
|
|
||||||
/// Parse ChannelInfo response
|
/// Parse ChannelInfo response
|
||||||
static Map<String, dynamic> parseChannelInfo(BufferReader reader) {
|
static Map<String, dynamic> parseChannelInfo(BufferReader reader) {
|
||||||
// Format: [channel_idx(1)][name(32)][secret(16)][flags(1)]
|
// Format: [channel_idx(1)][name(32)][secret(16)][flags(1)?]
|
||||||
// Minimum: 1 + 32 + 16 + 1 = 50 bytes
|
// Minimum: 1 + 32 + 16 = 49 bytes (flags is optional)
|
||||||
if (reader.remainingBytesCount < 50) {
|
if (reader.remainingBytesCount < 49) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
final channelIdx = reader.readByte();
|
final channelIdx = reader.readByte();
|
||||||
final channelName = reader.readCString(32);
|
final channelName = reader.readCString(32);
|
||||||
final secret = reader.readBytes(16);
|
final secret = reader.readBytes(16);
|
||||||
final flags = reader.readByte();
|
|
||||||
|
// Flags field is optional (some firmware versions don't include it)
|
||||||
|
int? flags;
|
||||||
|
if (reader.remainingBytesCount >= 1) {
|
||||||
|
flags = reader.readByte();
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'channelIdx': channelIdx,
|
'channelIdx': channelIdx,
|
||||||
|
|||||||
@@ -81,10 +81,14 @@ class _AddChannelDialogState extends State<AddChannelDialog> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await widget.onCreateChannel(
|
final channelName = _nameController.text.trim();
|
||||||
_nameController.text.trim(),
|
final isHashChannel = channelName.startsWith('#');
|
||||||
_secretController.text,
|
|
||||||
);
|
// For hash channels, pass empty secret (will be auto-generated)
|
||||||
|
// For private channels, use the provided secret
|
||||||
|
final secret = isHashChannel ? '' : _secretController.text;
|
||||||
|
|
||||||
|
await widget.onCreateChannel(channelName, secret);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
@@ -101,6 +105,7 @@ class _AddChannelDialogState extends State<AddChannelDialog> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
final isHashChannel = _nameController.text.startsWith('#');
|
||||||
|
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: Text(l10n.addChannel),
|
title: Text(l10n.addChannel),
|
||||||
@@ -111,6 +116,38 @@ class _AddChannelDialogState extends State<AddChannelDialog> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
// Info banner explaining channel types
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.primaryContainer.withOpacity(0.3),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: theme.colorScheme.primary.withOpacity(0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.info_outline,
|
||||||
|
size: 20,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
l10n.channelTypesInfo,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Channel Name Field
|
// Channel Name Field
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _nameController,
|
controller: _nameController,
|
||||||
@@ -118,38 +155,74 @@ class _AddChannelDialogState extends State<AddChannelDialog> {
|
|||||||
labelText: l10n.channelName,
|
labelText: l10n.channelName,
|
||||||
hintText: l10n.channelNameHint,
|
hintText: l10n.channelNameHint,
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
|
prefixIcon: Icon(
|
||||||
|
isHashChannel ? Icons.tag : Icons.lock_outline,
|
||||||
|
color: isHashChannel ? Colors.blue : Colors.orange,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
enabled: !_isCreating,
|
enabled: !_isCreating,
|
||||||
maxLength: 31,
|
maxLength: 31,
|
||||||
validator: _validateName,
|
validator: _validateName,
|
||||||
textInputAction: TextInputAction.next,
|
textInputAction: TextInputAction.next,
|
||||||
|
onChanged: (_) => setState(() {}), // Rebuild to update icon
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
// Channel Secret Field (only show for private channels)
|
||||||
|
if (!isHashChannel) ...[
|
||||||
// Channel Secret Field
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _secretController,
|
controller: _secretController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: l10n.channelSecret,
|
labelText: l10n.channelSecret,
|
||||||
hintText: l10n.channelSecretHint,
|
hintText: l10n.channelSecretHint,
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
obscureText: true,
|
||||||
|
enabled: !_isCreating,
|
||||||
|
maxLength: 32,
|
||||||
|
validator: _validateSecret,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onFieldSubmitted: (_) => _handleCreate(),
|
||||||
),
|
),
|
||||||
obscureText: true,
|
const SizedBox(height: 8),
|
||||||
enabled: !_isCreating,
|
// Help Text for private channels
|
||||||
maxLength: 32,
|
Text(
|
||||||
validator: _validateSecret,
|
l10n.channelSecretHelp,
|
||||||
textInputAction: TextInputAction.done,
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
onFieldSubmitted: (_) => _handleCreate(),
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
|
||||||
|
|
||||||
// Help Text
|
|
||||||
Text(
|
|
||||||
l10n.channelSecretHelp,
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
|
|
||||||
|
// Help Text for hash channels
|
||||||
|
if (isHashChannel) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.primaryContainer.withOpacity(0.5),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.auto_awesome,
|
||||||
|
size: 20,
|
||||||
|
color: Colors.blue,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
l10n.hashChannelInfo,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -416,7 +416,31 @@ class ContactTile extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
trailing: null,
|
trailing: contact.isChannel && !contact.isPublicChannel
|
||||||
|
? PopupMenuButton<String>(
|
||||||
|
icon: const Icon(Icons.more_vert),
|
||||||
|
onSelected: (value) {
|
||||||
|
if (value == 'delete') {
|
||||||
|
_showDeleteChannelDialog(context, contact);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'delete',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.delete, color: Colors.red, size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
AppLocalizations.of(context)!.deleteChannel,
|
||||||
|
style: const TextStyle(color: Colors.red),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: null,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
// In simple mode, tap directly opens message sheet for chat contacts
|
// In simple mode, tap directly opens message sheet for chat contacts
|
||||||
if (isSimpleMode && contact.type == ContactType.chat) {
|
if (isSimpleMode && contact.type == ContactType.chat) {
|
||||||
@@ -1201,4 +1225,57 @@ class ContactTile extends StatelessWidget {
|
|||||||
return '${diff.inDays}d ago';
|
return '${diff.inDays}d ago';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Show delete channel confirmation dialog
|
||||||
|
void _showDeleteChannelDialog(BuildContext context, Contact contact) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: Text(l10n.deleteChannel),
|
||||||
|
content: Text(l10n.deleteChannelConfirmation(contact.advName)),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||||
|
child: Text(l10n.cancel),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
Navigator.of(dialogContext).pop();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Extract channel index from pseudo public key
|
||||||
|
// publicKey format: [0xFF, channelIdx, ...]
|
||||||
|
final channelIdx = contact.publicKey[1];
|
||||||
|
|
||||||
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
await connectionProvider.deleteChannel(channelIdx);
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(l10n.channelDeletedSuccessfully),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(l10n.channelDeletionFailed(e.toString())),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||||
|
child: Text(l10n.delete),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,8 +93,13 @@ class _PermissionRequestDialogState extends State<PermissionRequestDialog> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return PopScope(
|
return PopScope(
|
||||||
// Prevent dismissing dialog by tapping outside
|
// Allow dismissing dialog by back button or tapping outside
|
||||||
canPop: false,
|
canPop: true,
|
||||||
|
onPopInvokedWithResult: (didPop, result) {
|
||||||
|
if (didPop) {
|
||||||
|
widget.onPermissionsDenied?.call();
|
||||||
|
}
|
||||||
|
},
|
||||||
child: AlertDialog(
|
child: AlertDialog(
|
||||||
title: Row(
|
title: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -162,14 +167,14 @@ class _PermissionRequestDialogState extends State<PermissionRequestDialog> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
if (_errorMessage != null && !_isRequesting)
|
// Always show a cancel/skip button
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
widget.onPermissionsDenied?.call();
|
widget.onPermissionsDenied?.call();
|
||||||
},
|
},
|
||||||
child: const Text('Skip'),
|
child: const Text('Skip'),
|
||||||
),
|
),
|
||||||
if (_errorMessage != null && !_isRequesting)
|
if (_errorMessage != null && !_isRequesting)
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
|
|||||||
Reference in New Issue
Block a user