diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 706e81d..e8eea59 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -2768,6 +2768,8 @@ "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", + "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", "channelNameTooLong": "Kanalname darf maximal 31 Zeichen lang sein", "channelSecretRequired": "Kanal-Passwort ist erforderlich", @@ -2775,6 +2777,10 @@ "invalidAsciiCharacters": "Nur ASCII-Zeichen sind erlaubt", "channelCreatedSuccessfully": "Kanal erfolgreich erstellt", "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)", "createChannel": "Kanal erstellen", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index d737158..685e106 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -3491,6 +3491,16 @@ "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": { "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": { "description": "Error when no channel slots available" diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 62004dc..f0f3313 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2763,6 +2763,8 @@ "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", + "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", "channelNameTooLong": "El nombre del canal debe tener 31 caracteres o menos", "channelSecretRequired": "La contraseña del canal es obligatoria", @@ -2770,6 +2772,10 @@ "invalidAsciiCharacters": "Solo se permiten caracteres ASCII", "channelCreatedSuccessfully": "Canal creado exitosamente", "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)", "createChannel": "Crear Canal", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index fc12451..50125c3 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2771,6 +2771,8 @@ "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", + "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", "channelNameTooLong": "Le nom du canal doit contenir 31 caractères ou moins", "channelSecretRequired": "Le mot de passe du canal est requis", diff --git a/lib/l10n/app_hr.arb b/lib/l10n/app_hr.arb index c639d4e..9338a14 100644 --- a/lib/l10n/app_hr.arb +++ b/lib/l10n/app_hr.arb @@ -1045,6 +1045,8 @@ "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", + "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", "channelNameTooLong": "Ime kanala mora imati najviše 31 znak", "channelSecretRequired": "Lozinka kanala je obavezna", @@ -1052,6 +1054,10 @@ "invalidAsciiCharacters": "Samo ASCII znakovi su dozvoljeni", "channelCreatedSuccessfully": "Kanal uspješno kreiran", "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)", "createChannel": "Kreiraj kanal", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index cb6537a..2fa29b8 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -2771,6 +2771,8 @@ "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", + "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", "channelNameTooLong": "Il nome del canale deve contenere al massimo 31 caratteri", "channelSecretRequired": "La password del canale è obbligatoria", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index b841f70..2527c3c 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3723,6 +3723,18 @@ abstract class AppLocalizations { /// **'This secret must be shared with all team members who need access to this channel'** 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 /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index ea8eb3e..4a3a902 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2076,6 +2076,14 @@ class AppLocalizationsDe extends AppLocalizations { String get channelSecretHelp => '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 String get channelNameRequired => 'Kanalname ist erforderlich'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 326dd8a..d213296 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2056,6 +2056,14 @@ class AppLocalizationsEn extends AppLocalizations { String get channelSecretHelp => '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 String get channelNameRequired => 'Channel name is required'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 7801372..1cd1d41 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -2081,6 +2081,14 @@ class AppLocalizationsEs extends AppLocalizations { String get channelSecretHelp => '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 String get channelNameRequired => 'El nombre del canal es obligatorio'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 0a094ca..d4ea9f2 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -2085,6 +2085,14 @@ class AppLocalizationsFr extends AppLocalizations { 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 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 String get channelNameRequired => 'Le nom du canal est requis'; diff --git a/lib/l10n/app_localizations_hr.dart b/lib/l10n/app_localizations_hr.dart index 9aa11aa..88b4b44 100644 --- a/lib/l10n/app_localizations_hr.dart +++ b/lib/l10n/app_localizations_hr.dart @@ -2066,6 +2066,14 @@ class AppLocalizationsHr extends AppLocalizations { String get channelSecretHelp => '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 String get channelNameRequired => 'Ime kanala je obavezno'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index d96ff0c..ca323b3 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -2076,6 +2076,14 @@ class AppLocalizationsIt extends AppLocalizations { String get channelSecretHelp => '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 String get channelNameRequired => 'Il nome del canale è obbligatorio'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index 839e360..acd7386 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -2066,6 +2066,14 @@ class AppLocalizationsSl extends AppLocalizations { String get channelSecretHelp => '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 String get channelNameRequired => 'Ime kanala je obvezno'; diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index 63f8db8..f6c4f38 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -1049,6 +1049,8 @@ "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", + "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", "channelNameTooLong": "Ime kanala mora imeti največ 31 znakov", "channelSecretRequired": "Geslo kanala je obvezno", @@ -1056,6 +1058,10 @@ "invalidAsciiCharacters": "Dovoljeni so samo ASCII znaki", "channelCreatedSuccessfully": "Kanal uspešno ustvarjen", "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)", "createChannel": "Ustvari kanal", diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index eac2847..1a88c87 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -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 connectionProvider.onChannelInfoReceived = (int channelIdx, String channelName, Uint8List secret, int? flags) { - channelsProvider.addOrUpdateChannel( - index: channelIdx, - name: channelName, - secret: secret, - flags: flags, - ); - debugPrint('📻 [AppProvider] Channel $channelIdx: "$channelName" (isHashChannel: ${channelName.startsWith('#')})'); + try { + debugPrint('🔔 [AppProvider] onChannelInfoReceived called: idx=$channelIdx, name="$channelName"'); + + // Check if this is a channel deletion (empty name) + if (channelName.isEmpty && channelIdx != 0) { + debugPrint(' 🗑️ Channel $channelIdx deleted - removing from providers'); + + // 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 @@ -492,12 +561,18 @@ class AppProvider with ChangeNotifier { // Removed _syncMessages() - messages are automatically synced via PUSH_CODE_MSG_WAITING events // 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 refresh() async { if (!connectionProvider.deviceInfo.isConnected) return; try { + // Sync contacts 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 notifyListeners(); } catch (e) { diff --git a/lib/providers/channels_provider.dart b/lib/providers/channels_provider.dart index 70ab3ec..3527cd3 100644 --- a/lib/providers/channels_provider.dart +++ b/lib/providers/channels_provider.dart @@ -50,6 +50,20 @@ class ChannelsProvider with ChangeNotifier { 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 void selectChannel(int index) { if (_channels.containsKey(index) || index == 0) { diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index 26a823b..f75c530 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -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. /// 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 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 findNextEmptyChannelSlot() async { if (!_bleService.isConnected) { throw Exception('Not connected to device'); @@ -873,26 +914,34 @@ class ConnectionProvider with ChangeNotifier { 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; + // Check each slot starting from 1 (skip 0 = public channel) 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)); + // First check cache + if (getChannelInfo != null) { + 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 - // (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. + // Slot appears empty in cache, verify by querying device + debugPrint(' 🔍 Checking slot $i...'); + final isEmpty = await isChannelSlotEmpty(i); + if (isEmpty) { + debugPrint(' ✅ Found empty slot: $i'); + return i; + } } - - // 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; + + debugPrint(' ❌ All slots (1-${maxChannels - 1}) are in use'); + return null; } catch (e) { debugPrint('❌ [Provider] Failed to find empty channel slot: $e'); rethrow; @@ -921,17 +970,63 @@ class ConnectionProvider with ChangeNotifier { 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)'); + // Determine channel type + final bool isHashChannel = channelName.startsWith('#'); + + // 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 - final secretBytes = _convertSecretToBytes(channelSecret); - debugPrint(' Secret converted to 16-byte key'); + // Generate secret + final List secretBytes; + 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 await _bleService.setChannel( @@ -940,7 +1035,7 @@ class ConnectionProvider with ChangeNotifier { 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 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 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 _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 + /// Used for private channels with explicit secrets List _convertSecretToBytes(String asciiSecret) { // Use MD5 hash to convert any length ASCII string to exactly 16 bytes // This provides a deterministic and secure way to generate channel keys diff --git a/lib/providers/contacts_provider.dart b/lib/providers/contacts_provider.dart index 5ef4b54..9395bd5 100644 --- a/lib/providers/contacts_provider.dart +++ b/lib/providers/contacts_provider.dart @@ -194,6 +194,8 @@ class ContactsProvider with ChangeNotifier { /// Add or update a contact /// Excludes contacts that match the device's own public key 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 if (devicePublicKey != null && _publicKeysMatch(contact.publicKey, devicePublicKey)) { @@ -205,6 +207,7 @@ class ContactsProvider with ChangeNotifier { // Check if this is a new contact final isNewContact = !_contacts.containsKey(contact.publicKeyHex); + debugPrint(' isNew: $isNewContact, total contacts before: ${_contacts.length}'); Contact updatedContact; if (isNewContact) { @@ -242,8 +245,10 @@ class ContactsProvider with ChangeNotifier { } _contacts[contact.publicKeyHex] = updatedContact; + debugPrint(' ✅ Contact added/updated. Total contacts: ${_contacts.length}, channels: ${channels.length}'); _persistContacts(); notifyListeners(); + debugPrint(' 🔔 notifyListeners() called'); } /// Compare two public keys for equality diff --git a/lib/screens/contacts_tab.dart b/lib/screens/contacts_tab.dart index 397fa2c..4440aff 100644 --- a/lib/screens/contacts_tab.dart +++ b/lib/screens/contacts_tab.dart @@ -234,44 +234,43 @@ class _ContactsTabState extends State { const Divider(height: 32), ], - // Channels (hidden in simple mode) - if (!isSimpleMode) ...[ - _SectionHeader( - title: l10n.channels, - count: channels.length, - icon: Icons.broadcast_on_personal, - ), - if (channels.isNotEmpty) ...[ - ...channels.map( - (contact) => ContactTile( - contact: contact, - currentPosition: _currentPosition, - calculateDistance: _calculateDistanceInMeters, - formatDistance: _formatDistance, - onNavigateToMap: widget.onNavigateToMap, - ), + // Channels (visible in both simple and advanced mode) + _SectionHeader( + title: l10n.channels, + count: channels.length, + icon: Icons.broadcast_on_personal, + ), + if (channels.isNotEmpty) ...[ + ...channels.map( + (contact) => ContactTile( + contact: contact, + currentPosition: _currentPosition, + calculateDistance: _calculateDistanceInMeters, + formatDistance: _formatDistance, + onNavigateToMap: widget.onNavigateToMap, ), - ], - // Add Channel Button (only show when connected) - if (context.watch().deviceInfo.isConnected) - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - child: OutlinedButton.icon( - onPressed: () => _showAddChannelDialog(context), - icon: const Icon(Icons.add_circle_outline), - label: Text(l10n.addChannel), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 24, - vertical: 12, - ), + ), + ], + + // Add Channel Button (visible in both simple and advanced mode, only show when connected) + if (context.watch().deviceInfo.isConnected) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: OutlinedButton.icon( + onPressed: () => _showAddChannelDialog(context), + icon: const Icon(Icons.add_circle_outline), + label: Text(l10n.addChannel), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, ), ), ), - ], + ), ], ), ); diff --git a/lib/services/ble/ble_response_handler.dart b/lib/services/ble/ble_response_handler.dart index dc44a74..4ab757f 100644 --- a/lib/services/ble/ble_response_handler.dart +++ b/lib/services/ble/ble_response_handler.dart @@ -1043,7 +1043,19 @@ class BleResponseHandler { final flags = info['flags'] as int?; 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) { debugPrint(' ❌ [ChannelInfo] Parsing error: $e'); diff --git a/lib/services/meshcore_ble_service.dart b/lib/services/meshcore_ble_service.dart index 70947a5..d05ccae 100644 --- a/lib/services/meshcore_ble_service.dart +++ b/lib/services/meshcore_ble_service.dart @@ -609,6 +609,9 @@ class MeshCoreBleService { /// /// The secret must be exactly 16 bytes (128-bit encryption key). /// 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 setChannel({ required int channelIdx, required String channelName, @@ -618,23 +621,58 @@ class MeshCoreBleService { debugPrint(' Channel index: $channelIdx'); debugPrint(' Channel name: $channelName'); debugPrint(' Secret length: ${secret.length} bytes'); + debugPrint(' Secret hex: ${secret.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}'); - await _commandSender.writeDataAndWaitForAck( - FrameBuilder.buildSetChannel( - channelIdx: channelIdx, - channelName: channelName, - secret: secret, - ), + // Send SET_CHANNEL command (fire-and-forget, no ACK expected) + final setChannelData = FrameBuilder.buildSetChannel( + channelIdx: channelIdx, + channelName: channelName, + 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) - /// This queries each channel to get its name and metadata - Future syncAllChannels({int maxChannels = 40}) async { - debugPrint('📻 [Service] Syncing channels (0-${maxChannels - 1})...'); + /// Delete a channel by clearing its slot + /// + /// This removes the channel from the device by setting it to an empty name and zeroed secret. + /// The channel slot becomes available for reuse. + /// + /// Note: Channel 0 (public channel) cannot be deleted. + Future 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 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); // Small delay to avoid overwhelming the device await Future.delayed(const Duration(milliseconds: 50)); diff --git a/lib/services/protocol/frame_parser.dart b/lib/services/protocol/frame_parser.dart index 5e6d1eb..6af85d5 100644 --- a/lib/services/protocol/frame_parser.dart +++ b/lib/services/protocol/frame_parser.dart @@ -390,16 +390,21 @@ class FrameParser { /// Parse ChannelInfo response static Map parseChannelInfo(BufferReader reader) { - // Format: [channel_idx(1)][name(32)][secret(16)][flags(1)] - // Minimum: 1 + 32 + 16 + 1 = 50 bytes - if (reader.remainingBytesCount < 50) { + // Format: [channel_idx(1)][name(32)][secret(16)][flags(1)?] + // Minimum: 1 + 32 + 16 = 49 bytes (flags is optional) + if (reader.remainingBytesCount < 49) { return {}; } final channelIdx = reader.readByte(); final channelName = reader.readCString(32); 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 { 'channelIdx': channelIdx, diff --git a/lib/widgets/contacts/add_channel_dialog.dart b/lib/widgets/contacts/add_channel_dialog.dart index 37c74d5..fa6f6a0 100644 --- a/lib/widgets/contacts/add_channel_dialog.dart +++ b/lib/widgets/contacts/add_channel_dialog.dart @@ -81,10 +81,14 @@ class _AddChannelDialogState extends State { }); try { - await widget.onCreateChannel( - _nameController.text.trim(), - _secretController.text, - ); + final channelName = _nameController.text.trim(); + final isHashChannel = channelName.startsWith('#'); + + // 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) { Navigator.of(context).pop(); @@ -101,6 +105,7 @@ class _AddChannelDialogState extends State { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final theme = Theme.of(context); + final isHashChannel = _nameController.text.startsWith('#'); return AlertDialog( title: Text(l10n.addChannel), @@ -111,6 +116,38 @@ class _AddChannelDialogState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, 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 TextFormField( controller: _nameController, @@ -118,38 +155,74 @@ class _AddChannelDialogState extends State { labelText: l10n.channelName, hintText: l10n.channelNameHint, border: const OutlineInputBorder(), + prefixIcon: Icon( + isHashChannel ? Icons.tag : Icons.lock_outline, + color: isHashChannel ? Colors.blue : Colors.orange, + ), ), enabled: !_isCreating, maxLength: 31, validator: _validateName, textInputAction: TextInputAction.next, + onChanged: (_) => setState(() {}), // Rebuild to update icon ), - const SizedBox(height: 16), - - // Channel Secret Field - TextFormField( - controller: _secretController, - decoration: InputDecoration( - labelText: l10n.channelSecret, - hintText: l10n.channelSecretHint, - border: const OutlineInputBorder(), + // Channel Secret Field (only show for private channels) + if (!isHashChannel) ...[ + const SizedBox(height: 16), + 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(), ), - 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, + const SizedBox(height: 8), + // Help Text for private channels + 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, + ), + ), + ), + ], + ), + ), + ], ], ), ), diff --git a/lib/widgets/contacts/contact_tile.dart b/lib/widgets/contacts/contact_tile.dart index 215bd22..ce513cb 100644 --- a/lib/widgets/contacts/contact_tile.dart +++ b/lib/widgets/contacts/contact_tile.dart @@ -416,7 +416,31 @@ class ContactTile extends StatelessWidget { ], ], ), - trailing: null, + trailing: contact.isChannel && !contact.isPublicChannel + ? PopupMenuButton( + 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: () { // In simple mode, tap directly opens message sheet for chat contacts if (isSimpleMode && contact.type == ContactType.chat) { @@ -1201,4 +1225,57 @@ class ContactTile extends StatelessWidget { 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(); + 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), + ), + ], + ), + ); + } } diff --git a/lib/widgets/permission_request_dialog.dart b/lib/widgets/permission_request_dialog.dart index 53690a1..097daa8 100644 --- a/lib/widgets/permission_request_dialog.dart +++ b/lib/widgets/permission_request_dialog.dart @@ -93,8 +93,13 @@ class _PermissionRequestDialogState extends State { @override Widget build(BuildContext context) { return PopScope( - // Prevent dismissing dialog by tapping outside - canPop: false, + // Allow dismissing dialog by back button or tapping outside + canPop: true, + onPopInvokedWithResult: (didPop, result) { + if (didPop) { + widget.onPermissionsDenied?.call(); + } + }, child: AlertDialog( title: Row( children: [ @@ -162,14 +167,14 @@ class _PermissionRequestDialogState extends State { ], ), actions: [ - if (_errorMessage != null && !_isRequesting) - TextButton( - onPressed: () { - Navigator.of(context).pop(); - widget.onPermissionsDenied?.call(); - }, - child: const Text('Skip'), - ), + // Always show a cancel/skip button + TextButton( + onPressed: () { + Navigator.of(context).pop(); + widget.onPermissionsDenied?.call(); + }, + child: const Text('Skip'), + ), if (_errorMessage != null && !_isRequesting) ElevatedButton( onPressed: () async {