diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a71dcfd..993d2e7 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2778,5 +2778,24 @@ "captured": "Captured", "size": "Size", "noCustomChannelsToClear": "No custom channels to clear.", - "noDeviceContactsToClear": "No device contacts to clear." + "noDeviceContactsToClear": "No device contacts to clear.", + + "setRegionScope": "Set region scope", + "regionScope": "Region scope", + "regionScopeNone": "None (global)", + "clearRegionScope": "Clear scope", + "regionScopeWarning": "Only repeaters allowing this region will forward.", + "discoverRegions": "Discover from repeaters", + "discoveringRegions": "Discovering regions...", + "enterRegionName": "Region name (e.g. auckland)", + "noRegionsFound": "No regions found on this repeater.", + "regionScopeSet": "Region scope set to {name}", + "@regionScopeSet": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "regionScopeCleared": "Region scope cleared" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 3297b5b..2d9b2ee 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -5009,6 +5009,72 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'No device contacts to clear.'** String get noDeviceContactsToClear; + + /// No description provided for @setRegionScope. + /// + /// In en, this message translates to: + /// **'Set region scope'** + String get setRegionScope; + + /// No description provided for @regionScope. + /// + /// In en, this message translates to: + /// **'Region scope'** + String get regionScope; + + /// No description provided for @regionScopeNone. + /// + /// In en, this message translates to: + /// **'None (global)'** + String get regionScopeNone; + + /// No description provided for @clearRegionScope. + /// + /// In en, this message translates to: + /// **'Clear scope'** + String get clearRegionScope; + + /// No description provided for @regionScopeWarning. + /// + /// In en, this message translates to: + /// **'Only repeaters allowing this region will forward.'** + String get regionScopeWarning; + + /// No description provided for @discoverRegions. + /// + /// In en, this message translates to: + /// **'Discover from repeaters'** + String get discoverRegions; + + /// No description provided for @discoveringRegions. + /// + /// In en, this message translates to: + /// **'Discovering regions...'** + String get discoveringRegions; + + /// No description provided for @enterRegionName. + /// + /// In en, this message translates to: + /// **'Region name (e.g. auckland)'** + String get enterRegionName; + + /// No description provided for @noRegionsFound. + /// + /// In en, this message translates to: + /// **'No regions found on this repeater.'** + String get noRegionsFound; + + /// No description provided for @regionScopeSet. + /// + /// In en, this message translates to: + /// **'Region scope set to {name}'** + String regionScopeSet(String name); + + /// No description provided for @regionScopeCleared. + /// + /// In en, this message translates to: + /// **'Region scope cleared'** + String get regionScopeCleared; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 4f995ed..cfd8cb0 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2705,4 +2705,40 @@ class AppLocalizationsDe extends AppLocalizations { @override String get noDeviceContactsToClear => 'Keine Gerätekontakte zum Löschen.'; + + @override + String get setRegionScope => 'Set region scope'; + + @override + String get regionScope => 'Region scope'; + + @override + String get regionScopeNone => 'None (global)'; + + @override + String get clearRegionScope => 'Clear scope'; + + @override + String get regionScopeWarning => + 'Only repeaters allowing this region will forward.'; + + @override + String get discoverRegions => 'Discover from repeaters'; + + @override + String get discoveringRegions => 'Discovering regions...'; + + @override + String get enterRegionName => 'Region name (e.g. auckland)'; + + @override + String get noRegionsFound => 'No regions found on this repeater.'; + + @override + String regionScopeSet(String name) { + return 'Region scope set to $name'; + } + + @override + String get regionScopeCleared => 'Region scope cleared'; } diff --git a/lib/l10n/app_localizations_el.dart b/lib/l10n/app_localizations_el.dart index e247062..536bc3f 100644 --- a/lib/l10n/app_localizations_el.dart +++ b/lib/l10n/app_localizations_el.dart @@ -2707,4 +2707,40 @@ class AppLocalizationsEl extends AppLocalizations { @override String get noDeviceContactsToClear => 'Δεν υπάρχουν επαφές συσκευής.'; + + @override + String get setRegionScope => 'Set region scope'; + + @override + String get regionScope => 'Region scope'; + + @override + String get regionScopeNone => 'None (global)'; + + @override + String get clearRegionScope => 'Clear scope'; + + @override + String get regionScopeWarning => + 'Only repeaters allowing this region will forward.'; + + @override + String get discoverRegions => 'Discover from repeaters'; + + @override + String get discoveringRegions => 'Discovering regions...'; + + @override + String get enterRegionName => 'Region name (e.g. auckland)'; + + @override + String get noRegionsFound => 'No regions found on this repeater.'; + + @override + String regionScopeSet(String name) { + return 'Region scope set to $name'; + } + + @override + String get regionScopeCleared => 'Region scope cleared'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 6aa0921..e92c807 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2669,4 +2669,40 @@ class AppLocalizationsEn extends AppLocalizations { @override String get noDeviceContactsToClear => 'No device contacts to clear.'; + + @override + String get setRegionScope => 'Set region scope'; + + @override + String get regionScope => 'Region scope'; + + @override + String get regionScopeNone => 'None (global)'; + + @override + String get clearRegionScope => 'Clear scope'; + + @override + String get regionScopeWarning => + 'Only repeaters allowing this region will forward.'; + + @override + String get discoverRegions => 'Discover from repeaters'; + + @override + String get discoveringRegions => 'Discovering regions...'; + + @override + String get enterRegionName => 'Region name (e.g. auckland)'; + + @override + String get noRegionsFound => 'No regions found on this repeater.'; + + @override + String regionScopeSet(String name) { + return 'Region scope set to $name'; + } + + @override + String get regionScopeCleared => 'Region scope cleared'; } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 256c762..369278f 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -2709,4 +2709,40 @@ class AppLocalizationsEs extends AppLocalizations { @override String get noDeviceContactsToClear => 'No hay contactos del dispositivo que borrar.'; + + @override + String get setRegionScope => 'Set region scope'; + + @override + String get regionScope => 'Region scope'; + + @override + String get regionScopeNone => 'None (global)'; + + @override + String get clearRegionScope => 'Clear scope'; + + @override + String get regionScopeWarning => + 'Only repeaters allowing this region will forward.'; + + @override + String get discoverRegions => 'Discover from repeaters'; + + @override + String get discoveringRegions => 'Discovering regions...'; + + @override + String get enterRegionName => 'Region name (e.g. auckland)'; + + @override + String get noRegionsFound => 'No regions found on this repeater.'; + + @override + String regionScopeSet(String name) { + return 'Region scope set to $name'; + } + + @override + String get regionScopeCleared => 'Region scope cleared'; } diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 4aca4b2..2f2bc71 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -2713,4 +2713,40 @@ class AppLocalizationsFr extends AppLocalizations { @override String get noDeviceContactsToClear => 'Aucun contact de l\'appareil à effacer.'; + + @override + String get setRegionScope => 'Set region scope'; + + @override + String get regionScope => 'Region scope'; + + @override + String get regionScopeNone => 'None (global)'; + + @override + String get clearRegionScope => 'Clear scope'; + + @override + String get regionScopeWarning => + 'Only repeaters allowing this region will forward.'; + + @override + String get discoverRegions => 'Discover from repeaters'; + + @override + String get discoveringRegions => 'Discovering regions...'; + + @override + String get enterRegionName => 'Region name (e.g. auckland)'; + + @override + String get noRegionsFound => 'No regions found on this repeater.'; + + @override + String regionScopeSet(String name) { + return 'Region scope set to $name'; + } + + @override + String get regionScopeCleared => 'Region scope cleared'; } diff --git a/lib/l10n/app_localizations_hr.dart b/lib/l10n/app_localizations_hr.dart index 047e7f9..5bda869 100644 --- a/lib/l10n/app_localizations_hr.dart +++ b/lib/l10n/app_localizations_hr.dart @@ -2682,4 +2682,40 @@ class AppLocalizationsHr extends AppLocalizations { @override String get noDeviceContactsToClear => 'Nema kontakata uređaja za brisanje.'; + + @override + String get setRegionScope => 'Set region scope'; + + @override + String get regionScope => 'Region scope'; + + @override + String get regionScopeNone => 'None (global)'; + + @override + String get clearRegionScope => 'Clear scope'; + + @override + String get regionScopeWarning => + 'Only repeaters allowing this region will forward.'; + + @override + String get discoverRegions => 'Discover from repeaters'; + + @override + String get discoveringRegions => 'Discovering regions...'; + + @override + String get enterRegionName => 'Region name (e.g. auckland)'; + + @override + String get noRegionsFound => 'No regions found on this repeater.'; + + @override + String regionScopeSet(String name) { + return 'Region scope set to $name'; + } + + @override + String get regionScopeCleared => 'Region scope cleared'; } diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 48ce635..ca15f0b 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -2699,4 +2699,40 @@ class AppLocalizationsIt extends AppLocalizations { @override String get noDeviceContactsToClear => 'Nessun contatto dispositivo da cancellare.'; + + @override + String get setRegionScope => 'Set region scope'; + + @override + String get regionScope => 'Region scope'; + + @override + String get regionScopeNone => 'None (global)'; + + @override + String get clearRegionScope => 'Clear scope'; + + @override + String get regionScopeWarning => + 'Only repeaters allowing this region will forward.'; + + @override + String get discoverRegions => 'Discover from repeaters'; + + @override + String get discoveringRegions => 'Discovering regions...'; + + @override + String get enterRegionName => 'Region name (e.g. auckland)'; + + @override + String get noRegionsFound => 'No regions found on this repeater.'; + + @override + String regionScopeSet(String name) { + return 'Region scope set to $name'; + } + + @override + String get regionScopeCleared => 'Region scope cleared'; } diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 1009305..f69f5f4 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -2696,4 +2696,40 @@ class AppLocalizationsPl extends AppLocalizations { @override String get noDeviceContactsToClear => 'Brak kontaktów urządzenia do wyczyszczenia.'; + + @override + String get setRegionScope => 'Set region scope'; + + @override + String get regionScope => 'Region scope'; + + @override + String get regionScopeNone => 'None (global)'; + + @override + String get clearRegionScope => 'Clear scope'; + + @override + String get regionScopeWarning => + 'Only repeaters allowing this region will forward.'; + + @override + String get discoverRegions => 'Discover from repeaters'; + + @override + String get discoveringRegions => 'Discovering regions...'; + + @override + String get enterRegionName => 'Region name (e.g. auckland)'; + + @override + String get noRegionsFound => 'No regions found on this repeater.'; + + @override + String regionScopeSet(String name) { + return 'Region scope set to $name'; + } + + @override + String get regionScopeCleared => 'Region scope cleared'; } diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index c4b5c8e..198a305 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -2707,4 +2707,40 @@ class AppLocalizationsPt extends AppLocalizations { @override String get noDeviceContactsToClear => 'Sem contactos do dispositivo para limpar.'; + + @override + String get setRegionScope => 'Set region scope'; + + @override + String get regionScope => 'Region scope'; + + @override + String get regionScopeNone => 'None (global)'; + + @override + String get clearRegionScope => 'Clear scope'; + + @override + String get regionScopeWarning => + 'Only repeaters allowing this region will forward.'; + + @override + String get discoverRegions => 'Discover from repeaters'; + + @override + String get discoveringRegions => 'Discovering regions...'; + + @override + String get enterRegionName => 'Region name (e.g. auckland)'; + + @override + String get noRegionsFound => 'No regions found on this repeater.'; + + @override + String regionScopeSet(String name) { + return 'Region scope set to $name'; + } + + @override + String get regionScopeCleared => 'Region scope cleared'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index ff5a7a4..67b3129 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -2692,4 +2692,40 @@ class AppLocalizationsRu extends AppLocalizations { @override String get noDeviceContactsToClear => 'Нет контактов устройства для очистки.'; + + @override + String get setRegionScope => 'Set region scope'; + + @override + String get regionScope => 'Region scope'; + + @override + String get regionScopeNone => 'None (global)'; + + @override + String get clearRegionScope => 'Clear scope'; + + @override + String get regionScopeWarning => + 'Only repeaters allowing this region will forward.'; + + @override + String get discoverRegions => 'Discover from repeaters'; + + @override + String get discoveringRegions => 'Discovering regions...'; + + @override + String get enterRegionName => 'Region name (e.g. auckland)'; + + @override + String get noRegionsFound => 'No regions found on this repeater.'; + + @override + String regionScopeSet(String name) { + return 'Region scope set to $name'; + } + + @override + String get regionScopeCleared => 'Region scope cleared'; } diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index e009bce..22c5ee8 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -2682,4 +2682,40 @@ class AppLocalizationsSl extends AppLocalizations { @override String get noDeviceContactsToClear => 'Ni stikov naprave za brisanje.'; + + @override + String get setRegionScope => 'Set region scope'; + + @override + String get regionScope => 'Region scope'; + + @override + String get regionScopeNone => 'None (global)'; + + @override + String get clearRegionScope => 'Clear scope'; + + @override + String get regionScopeWarning => + 'Only repeaters allowing this region will forward.'; + + @override + String get discoverRegions => 'Discover from repeaters'; + + @override + String get discoveringRegions => 'Discovering regions...'; + + @override + String get enterRegionName => 'Region name (e.g. auckland)'; + + @override + String get noRegionsFound => 'No regions found on this repeater.'; + + @override + String regionScopeSet(String name) { + return 'Region scope set to $name'; + } + + @override + String get regionScopeCleared => 'Region scope cleared'; } diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index ec00510..621f427 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -2680,4 +2680,40 @@ class AppLocalizationsTr extends AppLocalizations { @override String get noDeviceContactsToClear => 'Temizlenecek cihaz kişisi yok.'; + + @override + String get setRegionScope => 'Set region scope'; + + @override + String get regionScope => 'Region scope'; + + @override + String get regionScopeNone => 'None (global)'; + + @override + String get clearRegionScope => 'Clear scope'; + + @override + String get regionScopeWarning => + 'Only repeaters allowing this region will forward.'; + + @override + String get discoverRegions => 'Discover from repeaters'; + + @override + String get discoveringRegions => 'Discovering regions...'; + + @override + String get enterRegionName => 'Region name (e.g. auckland)'; + + @override + String get noRegionsFound => 'No regions found on this repeater.'; + + @override + String regionScopeSet(String name) { + return 'Region scope set to $name'; + } + + @override + String get regionScopeCleared => 'Region scope cleared'; } diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 23f3896..4df790b 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -2703,4 +2703,40 @@ class AppLocalizationsUk extends AppLocalizations { @override String get noDeviceContactsToClear => 'Немає контактів пристрою для очищення.'; + + @override + String get setRegionScope => 'Set region scope'; + + @override + String get regionScope => 'Region scope'; + + @override + String get regionScopeNone => 'None (global)'; + + @override + String get clearRegionScope => 'Clear scope'; + + @override + String get regionScopeWarning => + 'Only repeaters allowing this region will forward.'; + + @override + String get discoverRegions => 'Discover from repeaters'; + + @override + String get discoveringRegions => 'Discovering regions...'; + + @override + String get enterRegionName => 'Region name (e.g. auckland)'; + + @override + String get noRegionsFound => 'No regions found on this repeater.'; + + @override + String regionScopeSet(String name) { + return 'Region scope set to $name'; + } + + @override + String get regionScopeCleared => 'Region scope cleared'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 1784942..30b2fd9 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -2575,4 +2575,40 @@ class AppLocalizationsZh extends AppLocalizations { @override String get noDeviceContactsToClear => '没有设备联系人可清除。'; + + @override + String get setRegionScope => 'Set region scope'; + + @override + String get regionScope => 'Region scope'; + + @override + String get regionScopeNone => 'None (global)'; + + @override + String get clearRegionScope => 'Clear scope'; + + @override + String get regionScopeWarning => + 'Only repeaters allowing this region will forward.'; + + @override + String get discoverRegions => 'Discover from repeaters'; + + @override + String get discoveringRegions => 'Discovering regions...'; + + @override + String get enterRegionName => 'Region name (e.g. auckland)'; + + @override + String get noRegionsFound => 'No regions found on this repeater.'; + + @override + String regionScopeSet(String name) { + return 'Region scope set to $name'; + } + + @override + String get regionScopeCleared => 'Region scope cleared'; } diff --git a/lib/l10n/untranslated.json b/lib/l10n/untranslated.json index 1530c86..b4389f4 100644 --- a/lib/l10n/untranslated.json +++ b/lib/l10n/untranslated.json @@ -1,66 +1,209 @@ { "de": [ "postConnectDiscoveryTitle", - "postConnectDiscoveryDescription" + "postConnectDiscoveryDescription", + "setRegionScope", + "regionScope", + "regionScopeNone", + "clearRegionScope", + "regionScopeWarning", + "discoverRegions", + "discoveringRegions", + "enterRegionName", + "noRegionsFound", + "regionScopeSet", + "regionScopeCleared" ], "el": [ "postConnectDiscoveryTitle", - "postConnectDiscoveryDescription" + "postConnectDiscoveryDescription", + "setRegionScope", + "regionScope", + "regionScopeNone", + "clearRegionScope", + "regionScopeWarning", + "discoverRegions", + "discoveringRegions", + "enterRegionName", + "noRegionsFound", + "regionScopeSet", + "regionScopeCleared" ], "es": [ "postConnectDiscoveryTitle", - "postConnectDiscoveryDescription" + "postConnectDiscoveryDescription", + "setRegionScope", + "regionScope", + "regionScopeNone", + "clearRegionScope", + "regionScopeWarning", + "discoverRegions", + "discoveringRegions", + "enterRegionName", + "noRegionsFound", + "regionScopeSet", + "regionScopeCleared" ], "fr": [ "postConnectDiscoveryTitle", - "postConnectDiscoveryDescription" + "postConnectDiscoveryDescription", + "setRegionScope", + "regionScope", + "regionScopeNone", + "clearRegionScope", + "regionScopeWarning", + "discoverRegions", + "discoveringRegions", + "enterRegionName", + "noRegionsFound", + "regionScopeSet", + "regionScopeCleared" ], "hr": [ "postConnectDiscoveryTitle", - "postConnectDiscoveryDescription" + "postConnectDiscoveryDescription", + "setRegionScope", + "regionScope", + "regionScopeNone", + "clearRegionScope", + "regionScopeWarning", + "discoverRegions", + "discoveringRegions", + "enterRegionName", + "noRegionsFound", + "regionScopeSet", + "regionScopeCleared" ], "it": [ "postConnectDiscoveryTitle", - "postConnectDiscoveryDescription" + "postConnectDiscoveryDescription", + "setRegionScope", + "regionScope", + "regionScopeNone", + "clearRegionScope", + "regionScopeWarning", + "discoverRegions", + "discoveringRegions", + "enterRegionName", + "noRegionsFound", + "regionScopeSet", + "regionScopeCleared" ], "pl": [ "postConnectDiscoveryTitle", - "postConnectDiscoveryDescription" + "postConnectDiscoveryDescription", + "setRegionScope", + "regionScope", + "regionScopeNone", + "clearRegionScope", + "regionScopeWarning", + "discoverRegions", + "discoveringRegions", + "enterRegionName", + "noRegionsFound", + "regionScopeSet", + "regionScopeCleared" ], "pt": [ "postConnectDiscoveryTitle", - "postConnectDiscoveryDescription" + "postConnectDiscoveryDescription", + "setRegionScope", + "regionScope", + "regionScopeNone", + "clearRegionScope", + "regionScopeWarning", + "discoverRegions", + "discoveringRegions", + "enterRegionName", + "noRegionsFound", + "regionScopeSet", + "regionScopeCleared" ], "ru": [ "postConnectDiscoveryTitle", - "postConnectDiscoveryDescription" + "postConnectDiscoveryDescription", + "setRegionScope", + "regionScope", + "regionScopeNone", + "clearRegionScope", + "regionScopeWarning", + "discoverRegions", + "discoveringRegions", + "enterRegionName", + "noRegionsFound", + "regionScopeSet", + "regionScopeCleared" ], "sl": [ "postConnectDiscoveryTitle", - "postConnectDiscoveryDescription" + "postConnectDiscoveryDescription", + "setRegionScope", + "regionScope", + "regionScopeNone", + "clearRegionScope", + "regionScopeWarning", + "discoverRegions", + "discoveringRegions", + "enterRegionName", + "noRegionsFound", + "regionScopeSet", + "regionScopeCleared" ], "tr": [ "postConnectDiscoveryTitle", - "postConnectDiscoveryDescription" + "postConnectDiscoveryDescription", + "setRegionScope", + "regionScope", + "regionScopeNone", + "clearRegionScope", + "regionScopeWarning", + "discoverRegions", + "discoveringRegions", + "enterRegionName", + "noRegionsFound", + "regionScopeSet", + "regionScopeCleared" ], "uk": [ "postConnectDiscoveryTitle", - "postConnectDiscoveryDescription" + "postConnectDiscoveryDescription", + "setRegionScope", + "regionScope", + "regionScopeNone", + "clearRegionScope", + "regionScopeWarning", + "discoverRegions", + "discoveringRegions", + "enterRegionName", + "noRegionsFound", + "regionScopeSet", + "regionScopeCleared" ], "zh": [ "postConnectDiscoveryTitle", - "postConnectDiscoveryDescription" + "postConnectDiscoveryDescription", + "setRegionScope", + "regionScope", + "regionScopeNone", + "clearRegionScope", + "regionScopeWarning", + "discoverRegions", + "discoveringRegions", + "enterRegionName", + "noRegionsFound", + "regionScopeSet", + "regionScopeCleared" ] } diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index b762c94..208b584 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -562,6 +562,13 @@ class ConnectionProvider with ChangeNotifier { clientRepeat: deviceInfo['clientRepeat'] as bool?, pathHashMode: deviceInfo['pathHashMode'] as int?, ); + + // Clear any stale flood scope on connect (firmware v8+) + final fwVer = deviceInfo['firmwareVersion'] as int?; + if (fwVer != null && fwVer >= 8) { + clearFloodScope(); + } + notifyListeners(); }; @@ -1734,6 +1741,7 @@ class ConnectionProvider with ChangeNotifier { required int channelIdx, required String text, String? messageId, + Uint8List? floodScopeKey, }) async { if (!_activeService.isConnected) { _error = 'Not connected to device'; @@ -1746,10 +1754,12 @@ class ConnectionProvider with ChangeNotifier { debugPrint(' Channel: $channelIdx'); debugPrint(' Text: $text'); debugPrint(' MessageID: $messageId'); + debugPrint(' FloodScope: ${floodScopeKey != null ? "set (${floodScopeKey.length}B)" : "none"}'); await _activeService.sendChannelMessage( channelIdx: channelIdx, text: text, + floodScopeKey: floodScopeKey, ); debugPrint('✅ [ConnectionProvider] BLE send completed'); @@ -1814,10 +1824,21 @@ class ConnectionProvider with ChangeNotifier { ); } + /// Clear the flood scope on the device (firmware v8+). + Future clearFloodScope() async { + if (!_activeService.isConnected) return; + try { + await _activeService.clearFloodScope(); + } catch (e) { + debugPrint('⚠️ [ConnectionProvider] Failed to clear flood scope: $e'); + } + } + Future sendChannelData({ required int channelIdx, required int dataType, required Uint8List payload, + Uint8List? floodScopeKey, }) async { if (!_activeService.isConnected) { _error = 'Not connected to device'; @@ -1830,6 +1851,7 @@ class ConnectionProvider with ChangeNotifier { channelIdx: channelIdx, dataType: dataType, payload: payload, + floodScopeKey: floodScopeKey, ); } catch (e) { _error = 'Failed to send channel data: $e'; diff --git a/lib/screens/map_tab.dart b/lib/screens/map_tab.dart index be3ae27..198e3ad 100644 --- a/lib/screens/map_tab.dart +++ b/lib/screens/map_tab.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'dart:math' as math; import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show MissingPluginException; import 'package:flutter_map/flutter_map.dart' as flutter_map; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; @@ -10,6 +11,7 @@ import 'package:provider/provider.dart'; import 'package:geolocator/geolocator.dart'; import 'package:flutter_compass/flutter_compass.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../services/compass_support.dart'; import '../utils/slovenian_crs.dart'; import '../providers/contacts_provider.dart'; import '../providers/messages_provider.dart'; @@ -200,46 +202,64 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { } void _startCompassTracking() { + if (!CompassSupport.isAvailable) { + return; + } + final compassStream = FlutterCompass.events; if (compassStream == null) { return; } // Start listening to compass events - _compassStreamSubscription = compassStream.listen((CompassEvent event) { - // Check if widget is disposing, mounted, and event has valid heading - if (_isDisposing || !mounted || event.heading == null) return; + _compassStreamSubscription = compassStream.listen( + (CompassEvent event) { + // Check if widget is disposing, mounted, and event has valid heading + if (_isDisposing || !mounted || event.heading == null) return; - try { - setState(() { - _compassHeading = event.heading; - }); + try { + setState(() { + _compassHeading = event.heading; + }); - // Rotate map if rotation mode is enabled and we have compass heading - // Only rotate if map is ready - if (_rotateMarkerWithHeading && - event.heading != null && - _isMapReady && - !_isDisposing) { - try { - // Use moveAndRotate to set absolute rotation - final camera = _mapController.camera; - _mapController.moveAndRotate( - camera.center, - camera.zoom, - -event.heading!, - ); - } catch (e) { - // Map not ready yet, ignore + // Rotate map if rotation mode is enabled and we have compass heading + // Only rotate if map is ready + if (_rotateMarkerWithHeading && + event.heading != null && + _isMapReady && + !_isDisposing) { + try { + // Use moveAndRotate to set absolute rotation + final camera = _mapController.camera; + _mapController.moveAndRotate( + camera.center, + camera.zoom, + -event.heading!, + ); + } catch (e) { + // Map not ready yet, ignore + } + } + } catch (e) { + // Widget disposed during setState, ignore + if (!_isDisposing) { + debugPrint('Compass tracking error: $e'); } } - } catch (e) { - // Widget disposed during setState, ignore - if (!_isDisposing) { - debugPrint('Compass tracking error: $e'); + }, + onError: (Object error, StackTrace stackTrace) { + if (_isDisposing) { + return; } - } - }); + + if (error is MissingPluginException) { + debugPrint('Compass plugin is unavailable on this platform'); + return; + } + + debugPrint('Compass tracking stream error: $error'); + }, + ); } Future _loadSettings() async { @@ -1004,7 +1024,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { ), child: DetailedCompassDialog( initialPosition: _locationService.currentPosition, - initialHeading: _currentHeading, + initialHeading: _compassHeading, contacts: contacts, sarMarkers: sarMarkers, ), @@ -1030,7 +1050,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { ), child: DetailedCompassDialog( initialPosition: _locationService.currentPosition, - initialHeading: _currentHeading, + initialHeading: _compassHeading, contacts: contacts, sarMarkers: sarMarkers, preSelectedContact: selectedContact, diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index bf578e3..9d75b53 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -36,6 +36,8 @@ import '../utils/tictactoe_message_parser.dart'; import '../providers/image_provider.dart' as ip; import '../services/image_codec_service.dart'; import '../services/image_preferences.dart'; +import '../services/region_scope_preferences.dart'; +import '../services/region_discovery_service.dart'; import 'package:image_picker/image_picker.dart'; import '../l10n/app_localizations.dart'; @@ -153,6 +155,11 @@ class _MessagesTabState extends State { MessageDestinationPreferences.destinationTypeChannel; Contact? _selectedRecipient; + // Region scope state + String? _channelRegionScopeName; + Uint8List? _channelRegionScopeKey; + Map _channelRegionScopes = {}; + // Image sending state bool _isSendingImage = false; final ImagePicker _imagePicker = ImagePicker(); @@ -180,6 +187,7 @@ class _MessagesTabState extends State { // Load saved message destination _loadSavedDestination(); _loadVoiceSettings(); + _loadAllChannelRegionScopes(); WidgetsBinding.instance.addPostFrameCallback((_) { _checkForNavigationRequest(); }); @@ -483,6 +491,9 @@ class _MessagesTabState extends State { } _enforceMessageByteLimit(); + + // Load region scope for channel destinations + await _loadRegionScope(); } /// Show recipient selector bottom sheet @@ -536,6 +547,7 @@ class _MessagesTabState extends State { currentDestinationType: _destinationType, currentRecipientPublicKey: _selectedRecipient?.publicKeyHex, onSelect: _onRecipientSelected, + channelRegionScopes: _channelRegionScopes, ), ); } @@ -551,6 +563,9 @@ class _MessagesTabState extends State { _enforceMessageByteLimit(); + // Load region scope for channel destinations + await _loadRegionScope(); + // Save to preferences await MessageDestinationPreferences.setDestination( type, @@ -561,6 +576,51 @@ class _MessagesTabState extends State { if (!mounted) return; } + Future _loadRegionScope() async { + if (_destinationType != + MessageDestinationPreferences.destinationTypeChannel) { + if (_channelRegionScopeName != null) { + setState(() { + _channelRegionScopeName = null; + _channelRegionScopeKey = null; + }); + } + return; + } + final channelIdx = _selectedRecipient?.publicKey[1] ?? 0; + final scope = await RegionScopePreferences.getScope(channelIdx); + if (!mounted) return; + setState(() { + _channelRegionScopeName = scope?.name; + _channelRegionScopeKey = scope?.key; + if (scope != null) { + _channelRegionScopes[channelIdx] = scope.name; + } else { + _channelRegionScopes.remove(channelIdx); + } + }); + } + + Future _loadAllChannelRegionScopes() async { + final contactsProvider = context.read(); + final channels = contactsProvider.chatContacts + .where((c) => c.publicKey.length > 1 && c.publicKey[0] == 0) + .toList(); + final scopes = {}; + // Always check channel 0 (public) + final scope0 = await RegionScopePreferences.getScope(0); + if (scope0 != null) scopes[0] = scope0.name; + for (final ch in channels) { + final idx = ch.publicKey[1]; + final scope = await RegionScopePreferences.getScope(idx); + if (scope != null) scopes[idx] = scope.name; + } + if (!mounted) return; + setState(() { + _channelRegionScopes = scopes; + }); + } + Future _applyPendingDestination({ required String type, String? recipientPublicKeyHex, @@ -827,11 +887,12 @@ class _MessagesTabState extends State { // Add to messages list with "sending" status messagesProvider.addSentMessage(sentMessage, contact: _selectedRecipient); - // Send to selected channel + // Send to selected channel (with region scope if set) await connectionProvider.sendChannelMessage( channelIdx: channelIdx, text: text, messageId: messageId, + floodScopeKey: _channelRegionScopeKey, ); } @@ -1063,6 +1124,7 @@ class _MessagesTabState extends State { channelIdx: channelIdx ?? 0, text: envelopeText, messageId: msgId, + floodScopeKey: _channelRegionScopeKey, ); } else if (recipient != null) { final sent = await connectionProvider.sendTextMessage( @@ -1096,6 +1158,7 @@ class _MessagesTabState extends State { channelIdx: channelIdx ?? 0, dataType: MeshCoreConstants.dataTypeDev, payload: fragment.encodeBinary(), + floodScopeKey: _channelRegionScopeKey, ); } } @@ -1390,6 +1453,7 @@ class _MessagesTabState extends State { channelIdx: channelIdx ?? 0, text: envelopeText, messageId: msgId, + floodScopeKey: _channelRegionScopeKey, ); } else if (recipient != null) { final sentSuccessfully = await connectionProvider.sendTextMessage( @@ -1421,6 +1485,7 @@ class _MessagesTabState extends State { channelIdx: channelIdx ?? 0, dataType: MeshCoreConstants.dataTypeDev, payload: packet.encodeBinary(), + floodScopeKey: _channelRegionScopeKey, ); } } @@ -1649,6 +1714,20 @@ class _MessagesTabState extends State { await _startTicTacToeGame(); }), ), + if (_destinationType == + MessageDestinationPreferences.destinationTypeChannel) + _ComposerActionTile( + icon: _channelRegionScopeName != null + ? Icons.language_rounded + : Icons.public_rounded, + title: _channelRegionScopeName != null + ? '${l10n.regionScope}: $_channelRegionScopeName' + : l10n.setRegionScope, + color: const Color(0xFF7C3AED), + onTap: () => runAction(() async { + _showRegionScopeSheet(); + }), + ), ]; return SafeArea( @@ -1682,6 +1761,55 @@ class _MessagesTabState extends State { ); } + void _showRegionScopeSheet() { + final channelIdx = _selectedRecipient?.publicKey[1] ?? 0; + final contactsProvider = context.read(); + final connectionProvider = context.read(); + final l10n = AppLocalizations.of(context)!; + final repeaters = contactsProvider.contacts + .where((c) => c.isRepeater) + .toList(); + + showModalBottomSheet( + context: context, + showDragHandle: true, + isScrollControlled: true, + builder: (sheetContext) { + return _RegionScopeSheet( + currentScopeName: _channelRegionScopeName, + repeaters: repeaters, + connectionProvider: connectionProvider, + l10n: l10n, + onScopeSelected: (String? name) async { + Navigator.of(sheetContext).pop(); + if (name == null) { + // Clear scope + await RegionScopePreferences.clearScope(channelIdx); + if (!mounted) return; + setState(() { + _channelRegionScopeName = null; + _channelRegionScopeKey = null; + _channelRegionScopes.remove(channelIdx); + }); + ToastLogger.success(context, l10n.regionScopeCleared); + } else { + // Set scope + final key = RegionScopePreferences.deriveRegionKey(name); + await RegionScopePreferences.setScope(channelIdx, name, key); + if (!mounted) return; + setState(() { + _channelRegionScopeName = name; + _channelRegionScopeKey = key; + _channelRegionScopes[channelIdx] = name; + }); + ToastLogger.success(context, l10n.regionScopeSet(name)); + } + }, + ); + }, + ); + } + void _showFilteredMessageSearch() { final messagesProvider = context.read(); final scopedMessages = _getFilteredMessages(messagesProvider); @@ -1968,6 +2096,7 @@ class _MessagesTabState extends State { channelIdx: 0, text: sarMessage, messageId: messageId, + floodScopeKey: _channelRegionScopeKey, ); if (!mounted) return; @@ -2266,6 +2395,10 @@ class _MessagesTabState extends State { onStartVoiceRecording: _startVoiceRecording, onStopAndSendVoice: _stopAndSendVoice, onSendMessage: _sendMessage, + regionScopeName: _channelRegionScopeName, + onRegionScopeTap: _channelRegionScopeName != null + ? _showRegionScopeSheet + : null, ), ), ], @@ -2275,3 +2408,229 @@ class _MessagesTabState extends State { ); } } + +/// Bottom sheet for selecting a region scope for the current channel. +class _RegionScopeSheet extends StatefulWidget { + final String? currentScopeName; + final List repeaters; + final ConnectionProvider connectionProvider; + final AppLocalizations l10n; + final ValueChanged onScopeSelected; + + const _RegionScopeSheet({ + required this.currentScopeName, + required this.repeaters, + required this.connectionProvider, + required this.l10n, + required this.onScopeSelected, + }); + + @override + State<_RegionScopeSheet> createState() => _RegionScopeSheetState(); +} + +class _RegionScopeSheetState extends State<_RegionScopeSheet> { + final TextEditingController _nameController = TextEditingController(); + List _discoveredRegions = []; + bool _isDiscovering = false; + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } + + Future _discoverRegions() async { + if (widget.repeaters.isEmpty) return; + setState(() => _isDiscovering = true); + + final allRegions = {}; + for (final repeater in widget.repeaters) { + final regions = await RegionDiscoveryService.discoverFromRepeater( + repeaterPublicKey: repeater.publicKey, + connectionProvider: widget.connectionProvider, + ); + allRegions.addAll(regions); + } + + if (!mounted) return; + setState(() { + _discoveredRegions = allRegions.toList()..sort(); + _isDiscovering = false; + }); + + if (_discoveredRegions.isEmpty && mounted) { + ToastLogger.info(context, widget.l10n.noRegionsFound); + } + } + + void _submitManualName() { + var name = _nameController.text.trim(); + if (name.isEmpty) return; + if (!name.startsWith('#')) name = '#$name'; + if (utf8.encode(name).length > 30) return; + widget.onScopeSelected(name); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final l10n = widget.l10n; + + return SafeArea( + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.72, + ), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.regionScope, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + l10n.regionScopeWarning, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + + // "None" option + _RegionOptionTile( + label: l10n.regionScopeNone, + isSelected: widget.currentScopeName == null, + onTap: () => widget.onScopeSelected(null), + ), + const SizedBox(height: 8), + + // Manual entry + Row( + children: [ + Expanded( + child: TextField( + controller: _nameController, + decoration: InputDecoration( + hintText: l10n.enterRegionName, + isDense: true, + prefixText: '#', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + ), + onSubmitted: (_) => _submitManualName(), + ), + ), + const SizedBox(width: 8), + FilledButton.tonal( + onPressed: _submitManualName, + child: const Icon(Icons.check_rounded, size: 20), + ), + ], + ), + const SizedBox(height: 16), + + // Discover button + if (widget.repeaters.isNotEmpty) + FilledButton.tonalIcon( + onPressed: _isDiscovering ? null : _discoverRegions, + icon: _isDiscovering + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.search_rounded, size: 18), + label: Text( + _isDiscovering + ? l10n.discoveringRegions + : l10n.discoverRegions, + ), + ), + + // Discovered regions + if (_discoveredRegions.isNotEmpty) ...[ + const SizedBox(height: 12), + for (final region in _discoveredRegions) ...[ + _RegionOptionTile( + label: region, + isSelected: widget.currentScopeName == region, + onTap: () => widget.onScopeSelected(region), + ), + const SizedBox(height: 4), + ], + ], + ], + ), + ), + ), + ); + } +} + +class _RegionOptionTile extends StatelessWidget { + final String label; + final bool isSelected; + final VoidCallback onTap; + + const _RegionOptionTile({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Material( + color: isSelected + ? colorScheme.primaryContainer + : colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Icon( + isSelected + ? Icons.radio_button_checked_rounded + : Icons.radio_button_off_rounded, + size: 20, + color: isSelected + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurface, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/sensors_tab.dart b/lib/screens/sensors_tab.dart index a6b92e7..191d931 100644 --- a/lib/screens/sensors_tab.dart +++ b/lib/screens/sensors_tab.dart @@ -523,6 +523,7 @@ class _AddSensorSheetState extends State { class SensorCustomizeView extends StatelessWidget { final String publicKeyHex; final Contact? initialContact; + final bool showLivePreview; final Future Function({ required BuildContext context, required String publicKeyHex, @@ -532,9 +533,11 @@ class SensorCustomizeView extends StatelessWidget { onRenameMetric; const SensorCustomizeView({ + super.key, required this.publicKeyHex, required this.initialContact, required this.onRenameMetric, + this.showLivePreview = true, }); @override @@ -579,35 +582,39 @@ class SensorCustomizeView extends StatelessWidget { body: ListView( padding: EdgeInsets.fromLTRB(16, 16, 16, 24), children: [ - _SensorCustomizeSectionCard( - title: AppLocalizations.of(context)!.livePreview, - subtitle: - 'Changes apply immediately. This card matches the current dashboard layout for this sensor.', - child: SensorTelemetryCard( - contact: contact, - state: sensorsProvider.stateFor(publicKeyHex), - visibleFields: visibleFields, - fieldOrder: sensorsProvider.metricOrderFor( - publicKeyHex, - visibleFields, - ), - labelOverrides: sensorsProvider.labelOverridesFor( - publicKeyHex, - ), - onShowMetHistory: contact == null - ? null - : (contact) => showBTHomeMetHistorySheet( - context, - contact: contact, + if (showLivePreview) + _SensorCustomizeSectionCard( + title: AppLocalizations.of(context)!.livePreview, + subtitle: + 'Changes apply immediately. This card matches the current dashboard layout for this sensor.', + child: SensorTelemetryCard( + contact: contact, + state: sensorsProvider.stateFor(publicKeyHex), + visibleFields: visibleFields, + fieldOrder: sensorsProvider.metricOrderFor( + publicKeyHex, + visibleFields, + ), + labelOverrides: sensorsProvider.labelOverridesFor( + publicKeyHex, + ), + onShowMetHistory: contact == null + ? null + : (contact) => showBTHomeMetHistorySheet( + context, + contact: contact, + ), + fieldSpans: { + for (final field in visibleFields) + field: sensorsProvider.fieldSpanFor( + publicKeyHex, + field, ), - fieldSpans: { - for (final field in visibleFields) - field: sensorsProvider.fieldSpanFor(publicKeyHex, field), - }, - margin: EdgeInsets.zero, - emptyMetricsMessage: 'No telemetry fields available yet.', + }, + margin: EdgeInsets.zero, + emptyMetricsMessage: 'No telemetry fields available yet.', + ), ), - ), _SensorCustomizeSectionCard( title: AppLocalizations.of(context)!.refreshSchedule, subtitle: diff --git a/lib/services/compass_support.dart b/lib/services/compass_support.dart new file mode 100644 index 0000000..b3dcf61 --- /dev/null +++ b/lib/services/compass_support.dart @@ -0,0 +1,11 @@ +import 'package:flutter/foundation.dart' + show TargetPlatform, defaultTargetPlatform, kIsWeb; + +class CompassSupport { + const CompassSupport._(); + + static bool get isAvailable => + !kIsWeb && + (defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS); +} diff --git a/lib/services/region_discovery_service.dart b/lib/services/region_discovery_service.dart new file mode 100644 index 0000000..44497e5 --- /dev/null +++ b/lib/services/region_discovery_service.dart @@ -0,0 +1,72 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import '../providers/connection_provider.dart'; + +/// Discovers available regions from repeater contacts via anonymous requests. +/// +/// The firmware repeater responds to ANON_REQ_TYPE_REGIONS (0x01) with a +/// comma-separated list of region names that have flood allowed. +class RegionDiscoveryService { + static const int _anonReqTypeRegions = 0x01; + + /// Discover regions from a single repeater. + /// + /// Sends an anonymous request to the repeater and waits for the response. + /// Returns a list of region names (with `#` prefix). + /// Returns empty list on timeout or error. + static Future> discoverFromRepeater({ + required Uint8List repeaterPublicKey, + required ConnectionProvider connectionProvider, + Duration timeout = const Duration(seconds: 10), + }) async { + final result = await connectionProvider.sendAnonRequest( + contactPublicKey: repeaterPublicKey, + requestData: Uint8List.fromList([_anonReqTypeRegions]), + ); + if (result == null) return []; + + final tag = result.tag; + final completer = Completer>(); + + void onResponse(Uint8List publicKeyPrefix, int responseTag, Uint8List data) { + if (responseTag != tag || completer.isCompleted) return; + completer.complete(_parseRegionResponse(data)); + } + + connectionProvider.onBinaryResponse = onResponse; + + try { + return await completer.future.timeout( + timeout, + onTimeout: () => [], + ); + } catch (e) { + debugPrint('⚠️ [RegionDiscovery] Error discovering regions: $e'); + return []; + } finally { + // Restore previous handler — callers should re-set if needed + if (connectionProvider.onBinaryResponse == onResponse) { + connectionProvider.onBinaryResponse = null; + } + } + } + + /// Parse the region response payload. + /// + /// Format: [4B sender_timestamp][4B repeater_clock][comma-separated names] + /// Names are returned without `#` prefix from firmware; we add it back. + static List _parseRegionResponse(Uint8List data) { + if (data.length <= 8) return []; + + final namesStr = utf8.decode(data.sublist(8), allowMalformed: true).trim(); + if (namesStr.isEmpty || namesStr == '-none-') return []; + + return namesStr + .split(',') + .map((name) => name.trim()) + .where((name) => name.isNotEmpty && name != '*' && !name.startsWith('\$')) + .map((name) => name.startsWith('#') ? name : '#$name') + .toList(); + } +} diff --git a/lib/services/region_scope_preferences.dart b/lib/services/region_scope_preferences.dart new file mode 100644 index 0000000..0d4c6f6 --- /dev/null +++ b/lib/services/region_scope_preferences.dart @@ -0,0 +1,86 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:crypto/crypto.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'profiles_feature_service.dart'; + +/// Stores per-channel region scope settings. +/// +/// Each channel can optionally have a region scope assigned. When set, the app +/// will set the flood scope on the device before sending channel messages so +/// that repeaters outside the region won't forward them. +class RegionScopePreferences { + static const String _namePrefix = 'region_scope_name_'; + static const String _keyPrefix = 'region_scope_key_'; + + /// Get the saved scope for a channel. + /// Returns null if no scope is set. + static Future<({String name, Uint8List key})?> getScope( + int channelIdx, + ) async { + final prefs = await SharedPreferences.getInstance(); + final name = prefs.getString( + ProfileStorageScope.scopedKey('$_namePrefix$channelIdx'), + ); + final keyBase64 = prefs.getString( + ProfileStorageScope.scopedKey('$_keyPrefix$channelIdx'), + ); + if (name == null || keyBase64 == null) return null; + try { + return (name: name, key: Uint8List.fromList(base64.decode(keyBase64))); + } catch (_) { + return null; + } + } + + /// Save a region scope for a channel. + static Future setScope( + int channelIdx, + String name, + Uint8List key, + ) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + ProfileStorageScope.scopedKey('$_namePrefix$channelIdx'), + name, + ); + await prefs.setString( + ProfileStorageScope.scopedKey('$_keyPrefix$channelIdx'), + base64.encode(key), + ); + } + + /// Clear the region scope for a channel. + static Future clearScope(int channelIdx) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove( + ProfileStorageScope.scopedKey('$_namePrefix$channelIdx'), + ); + await prefs.remove( + ProfileStorageScope.scopedKey('$_keyPrefix$channelIdx'), + ); + } + + /// Clear all region scopes (all channels). + static Future clearAllScopes() async { + final prefs = await SharedPreferences.getInstance(); + final keys = prefs.getKeys(); + for (final key in keys) { + if (key.contains(_namePrefix) || key.contains(_keyPrefix)) { + await prefs.remove(key); + } + } + } + + /// Derive the 16-byte transport key for a region name. + /// + /// Matches firmware `TransportKeyStore::getAutoKeyFor`: + /// `SHA256("#regionname")` → first 16 bytes. + /// The name must include the leading `#` (auto-prepended if missing). + static Uint8List deriveRegionKey(String regionName) { + final normalized = + regionName.startsWith('#') ? regionName : '#$regionName'; + final digest = sha256.convert(utf8.encode(normalized)); + return Uint8List.fromList(digest.bytes.sublist(0, 16)); + } +} diff --git a/lib/widgets/map/detailed_compass_dialog.dart b/lib/widgets/map/detailed_compass_dialog.dart index 417a1cb..29c49b8 100644 --- a/lib/widgets/map/detailed_compass_dialog.dart +++ b/lib/widgets/map/detailed_compass_dialog.dart @@ -1,9 +1,11 @@ import 'dart:async'; import 'dart:math'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show MissingPluginException; import 'package:geolocator/geolocator.dart'; import 'package:flutter_compass/flutter_compass.dart'; import 'package:latlong2/latlong.dart'; +import '../../services/compass_support.dart'; import '../../models/contact.dart'; import '../../models/sar_marker.dart'; import '../common/location_display.dart'; @@ -90,17 +92,33 @@ class _DetailedCompassDialogState extends State { }); } - // Subscribe to compass updates - final compassStream = FlutterCompass.events; + // Subscribe to compass updates on supported targets only. + final compassStream = CompassSupport.isAvailable + ? FlutterCompass.events + : null; if (compassStream != null) { - _compassSubscription = compassStream.listen((event) { - if (mounted && event.heading != null) { - setState(() { - _currentHeading = event.heading; - _compassAccuracy = event.accuracy; - }); - } - }); + _compassSubscription = compassStream.listen( + (event) { + if (mounted && event.heading != null) { + setState(() { + _currentHeading = event.heading; + _compassAccuracy = event.accuracy; + }); + } + }, + onError: (Object error, StackTrace stackTrace) { + if (!mounted) { + return; + } + + if (error is MissingPluginException) { + debugPrint('Compass plugin is unavailable on this platform'); + return; + } + + debugPrint('Compass dialog stream error: $error'); + }, + ); } // Subscribe to position updates diff --git a/lib/widgets/messages/messages_composer.dart b/lib/widgets/messages/messages_composer.dart index d18fffe..1a72c62 100644 --- a/lib/widgets/messages/messages_composer.dart +++ b/lib/widgets/messages/messages_composer.dart @@ -27,6 +27,8 @@ class MessagesComposer extends StatelessWidget { final Future Function() onStartVoiceRecording; final Future Function() onStopAndSendVoice; final Future Function() onSendMessage; + final String? regionScopeName; + final VoidCallback? onRegionScopeTap; const MessagesComposer({ super.key, @@ -49,6 +51,8 @@ class MessagesComposer extends StatelessWidget { required this.onStartVoiceRecording, required this.onStopAndSendVoice, required this.onSendMessage, + this.regionScopeName, + this.onRegionScopeTap, }); @override @@ -102,6 +106,14 @@ class MessagesComposer extends StatelessWidget { ? onStopAndSendVoice : onShowComposerActions, ), + if (regionScopeName != null && + onRegionScopeTap != null) ...[ + const SizedBox(width: 6), + _RegionScopeChip( + name: regionScopeName!, + onTap: onRegionScopeTap!, + ), + ], const SizedBox(width: 8), Expanded( child: _DestinationSelector( @@ -534,3 +546,52 @@ class _SendButton extends StatelessWidget { ); } } + +class _RegionScopeChip extends StatelessWidget { + final String name; + final VoidCallback onTap; + + const _RegionScopeChip({required this.name, required this.onTap}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return GestureDetector( + onTap: onTap, + child: Container( + height: 40, + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: colorScheme.tertiary.withValues(alpha: 0.3), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.language_rounded, + size: 16, + color: colorScheme.onTertiaryContainer, + ), + const SizedBox(width: 4), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 80), + child: Text( + name, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: colorScheme.onTertiaryContainer, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/messages/recipient_selector_sheet.dart b/lib/widgets/messages/recipient_selector_sheet.dart index 7aa90c1..df85696 100644 --- a/lib/widgets/messages/recipient_selector_sheet.dart +++ b/lib/widgets/messages/recipient_selector_sheet.dart @@ -18,6 +18,9 @@ class RecipientSelectorSheet extends StatefulWidget { final bool showAllOption; final Function(String type, Contact? recipient) onSelect; + /// Region scope names per channel index (e.g. {0: "#auckland"}). + final Map channelRegionScopes; + const RecipientSelectorSheet({ super.key, required this.contacts, @@ -29,6 +32,7 @@ class RecipientSelectorSheet extends StatefulWidget { this.currentRecipientPublicKey, this.showAllOption = true, required this.onSelect, + this.channelRegionScopes = const {}, }); @override @@ -165,13 +169,18 @@ class _RecipientSelectorSheetState extends State { String _channelSubtitle(BuildContext context, Contact channel) { final l10n = AppLocalizations.of(context)!; + final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0; + final scopeName = widget.channelRegionScopes[channelIdx]; + if (channel.isPublicChannel) { - return l10n.broadcastToAllNearby; + return scopeName != null + ? '${l10n.broadcastToAllNearby} • $scopeName' + : l10n.broadcastToAllNearby; } - final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0; final shortKey = channel.publicKeyShort.toUpperCase(); - return '${l10n.channel} $channelIdx • $shortKey'; + final base = '${l10n.channel} $channelIdx • $shortKey'; + return scopeName != null ? '$base • $scopeName' : base; } bool _isDenseSection(String type) => type == 'channel' || type == 'contact'; diff --git a/pubspec.lock b/pubspec.lock index f32ab84..2362c37 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -827,8 +827,8 @@ packages: dependency: "direct main" description: path: "." - ref: dfc5b82c3f8a7f160ca77e45dc65a9eb39cebc5a - resolved-ref: dfc5b82c3f8a7f160ca77e45dc65a9eb39cebc5a + ref: a0deff8 + resolved-ref: a0deff80fcbca974f0e18fe47971b76bec583109 url: "https://github.com/dz0ny/meshcore_client.git" source: git version: "0.1.0" diff --git a/pubspec.yaml b/pubspec.yaml index 4c99b0d..9059be3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,7 +44,7 @@ dependencies: meshcore_client: git: url: https://github.com/dz0ny/meshcore_client.git - ref: dfc5b82c3f8a7f160ca77e45dc65a9eb39cebc5a + ref: a0deff8 # Codec2 ultra-low-bitrate speech codec (FFI plugin) codec2_flutter: diff --git a/test/screens/sensors_tab_test.dart b/test/screens/sensors_tab_test.dart index 26d2f95..a5f85bd 100644 --- a/test/screens/sensors_tab_test.dart +++ b/test/screens/sensors_tab_test.dart @@ -4,25 +4,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:meshcore_sar_app/l10n/app_localizations.dart'; import 'package:meshcore_sar_app/models/contact.dart'; -import 'package:meshcore_sar_app/providers/contacts_provider.dart'; -import 'package:meshcore_sar_app/providers/sensors_provider.dart'; import 'package:meshcore_sar_app/screens/sensors_tab.dart'; import 'package:meshcore_sar_app/widgets/sensors/sensor_telemetry_card.dart'; -import 'package:provider/provider.dart'; -import 'package:shared_preferences/shared_preferences.dart'; void main() { - setUp(() { - SharedPreferences.setMockInitialValues({}); - }); - - Future waitUntilLoaded(SensorsProvider provider) async { - for (var i = 0; i < 20 && !provider.isLoaded; i++) { - await Future.delayed(Duration.zero); - } - expect(provider.isLoaded, isTrue); - } - Contact buildSensorContact({ int firstByte = 0x44, String name = 'WX Station', @@ -54,49 +39,37 @@ void main() { ); } - testWidgets('customize action opens full customization view', (tester) async { + testWidgets('metric selector item shows channel-specific preview', ( + tester, + ) async { final contact = buildSensorContact(); - final sensorsProvider = SensorsProvider(); - final contactsProvider = ContactsProvider(); - - await waitUntilLoaded(sensorsProvider); - contactsProvider.addOrUpdateContact(contact); - await sensorsProvider.addSensor(contact); + final option = sensorMetricOptionsFor(contact, labelOverrides: const {}) + .firstWhere( + (entry) => entry.key.contains('illuminance') && entry.channel == 2, + ); await tester.pumpWidget( - MultiProvider( - providers: [ - ChangeNotifierProvider.value( - value: contactsProvider, - ), - ChangeNotifierProvider.value(value: sensorsProvider), - ], - child: MaterialApp( - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: SensorCustomizeView( - publicKeyHex: contact.publicKeyHex, - initialContact: contact, - onRenameMetric: - ({ - required BuildContext context, - required String publicKeyHex, - required SensorMetricOption option, - required SensorsProvider sensorsProvider, - }) async {}, + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SensorMetricSelectorItem( + option: option, + visible: true, + span: 1, + canMoveUp: false, + canMoveDown: false, + onToggle: (_) {}, + onRename: () {}, + onSpanChanged: (_) {}, ), ), ), ); await tester.pump(); - expect(find.text('Customize WX Station'), findsOneWidget); - expect(find.text('Live preview'), findsOneWidget); - expect(find.text('Refresh schedule'), findsOneWidget); expect( - find.byKey( - const ValueKey('sensor_selector_metric_channel_extra:illuminance_2'), - ), + find.byKey(ValueKey('sensor_selector_metric_${option.key}')), findsOneWidget, ); expect(find.text('Channel 2'), findsOneWidget); diff --git a/test/services/compass_support_test.dart b/test/services/compass_support_test.dart new file mode 100644 index 0000000..2591c7b --- /dev/null +++ b/test/services/compass_support_test.dart @@ -0,0 +1,23 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/services/compass_support.dart'; + +void main() { + tearDown(() { + debugDefaultTargetPlatformOverride = null; + }); + + test('reports compass support only on mobile targets', () { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + expect(CompassSupport.isAvailable, isTrue); + + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + expect(CompassSupport.isAvailable, isTrue); + + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + expect(CompassSupport.isAvailable, isFalse); + + debugDefaultTargetPlatformOverride = TargetPlatform.windows; + expect(CompassSupport.isAvailable, isFalse); + }); +}