From 24ca2abe3a056c614e06ce09c9736ce3c8e0bf7a Mon Sep 17 00:00:00 2001 From: diffwireauto <330573775+diffwireauto@users.noreply.github.com> Date: Thu, 8 Oct 2026 02:52:04 -0400 Subject: [PATCH] feat(#830): channel Set Region Scope action + picker (Epic #815 C2) A firmware-gated channel overflow action opens a picker that discovers regions from a chosen repeater (reusing RegionDiscoveryService), lets the user select a region or None, and persists it via connector.setChannelRegionScope (C1 store). The dialog is decoupled (data + callbacks + injected service) for widget testing. l10n added. Co-Authored-By: Claude Opus 4.8 --- lib/connector/meshcore_connector.dart | 17 ++ lib/l10n/app_en.arb | 14 +- lib/l10n/app_localizations.dart | 30 ++++ lib/l10n/app_localizations_bg.dart | 17 ++ lib/l10n/app_localizations_de.dart | 17 ++ lib/l10n/app_localizations_en.dart | 17 ++ lib/l10n/app_localizations_es.dart | 17 ++ lib/l10n/app_localizations_fr.dart | 17 ++ lib/l10n/app_localizations_hu.dart | 17 ++ lib/l10n/app_localizations_it.dart | 17 ++ lib/l10n/app_localizations_ja.dart | 17 ++ lib/l10n/app_localizations_ko.dart | 17 ++ lib/l10n/app_localizations_nl.dart | 17 ++ lib/l10n/app_localizations_pl.dart | 17 ++ lib/l10n/app_localizations_pt.dart | 17 ++ lib/l10n/app_localizations_ru.dart | 17 ++ lib/l10n/app_localizations_sk.dart | 17 ++ lib/l10n/app_localizations_sl.dart | 17 ++ lib/l10n/app_localizations_sv.dart | 17 ++ lib/l10n/app_localizations_uk.dart | 17 ++ lib/l10n/app_localizations_zh.dart | 17 ++ lib/screens/channel_chat_screen.dart | 46 +++++ lib/widgets/channel_region_scope_dialog.dart | 161 ++++++++++++++++++ .../channel_region_scope_dialog_test.dart | 78 +++++++++ 24 files changed, 651 insertions(+), 1 deletion(-) create mode 100644 lib/widgets/channel_region_scope_dialog.dart create mode 100644 test/widgets/channel_region_scope_dialog_test.dart diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 17fbbe1e..7b46c113 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -4824,6 +4824,23 @@ class MeshCoreConnector extends ChangeNotifier { Uint8List frame, ) => _sendChannelFrameScoped(channelIndex, frame); + /// The region name scoping [channelIndex], or null when unscoped (#815). + Future channelRegionScope(int channelIndex) => + _channelRegionScopeStore.scopeFor(channelIndex); + + /// Set (or, with null, clear) the region scope for [channelIndex] (#815). + Future setChannelRegionScope( + int channelIndex, + String? regionName, + ) async { + if (regionName == null) { + await _channelRegionScopeStore.clearScope(channelIndex); + } else { + await _channelRegionScopeStore.setScope(channelIndex, regionName); + } + notifyListeners(); + } + Future removeContact(Contact contact) async { if (!isConnected) return; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 5cbd5443..2b46eb93 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -3225,5 +3225,17 @@ } }, "discoverRegions_error": "Could not discover regions.", - "discoverRegions_retry": "Retry" + "discoverRegions_retry": "Retry", + "setRegionScope_menu": "Set Region Scope", + "setRegionScope_title": "Region scope for {channel}", + "@setRegionScope_title": { + "placeholders": { + "channel": { + "type": "String" + } + } + }, + "setRegionScope_discover": "Discover from repeater", + "setRegionScope_none": "None (unscoped)", + "setRegionScope_save": "Save" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index b7c4bfc5..1e0cf1d3 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -9027,6 +9027,36 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Retry'** String get discoverRegions_retry; + + /// No description provided for @setRegionScope_menu. + /// + /// In en, this message translates to: + /// **'Set Region Scope'** + String get setRegionScope_menu; + + /// No description provided for @setRegionScope_title. + /// + /// In en, this message translates to: + /// **'Region scope for {channel}'** + String setRegionScope_title(String channel); + + /// No description provided for @setRegionScope_discover. + /// + /// In en, this message translates to: + /// **'Discover from repeater'** + String get setRegionScope_discover; + + /// No description provided for @setRegionScope_none. + /// + /// In en, this message translates to: + /// **'None (unscoped)'** + String get setRegionScope_none; + + /// No description provided for @setRegionScope_save. + /// + /// In en, this message translates to: + /// **'Save'** + String get setRegionScope_save; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index 6308eea2..328b5e06 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -5333,4 +5333,21 @@ class AppLocalizationsBg extends AppLocalizations { @override String get discoverRegions_retry => 'Retry'; + + @override + String get setRegionScope_menu => 'Set Region Scope'; + + @override + String setRegionScope_title(String channel) { + return 'Region scope for $channel'; + } + + @override + String get setRegionScope_discover => 'Discover from repeater'; + + @override + String get setRegionScope_none => 'None (unscoped)'; + + @override + String get setRegionScope_save => 'Save'; } diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 255bb35e..5c9f84e6 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -5350,4 +5350,21 @@ class AppLocalizationsDe extends AppLocalizations { @override String get discoverRegions_retry => 'Retry'; + + @override + String get setRegionScope_menu => 'Set Region Scope'; + + @override + String setRegionScope_title(String channel) { + return 'Region scope for $channel'; + } + + @override + String get setRegionScope_discover => 'Discover from repeater'; + + @override + String get setRegionScope_none => 'None (unscoped)'; + + @override + String get setRegionScope_save => 'Save'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index deef6798..4d9b7f1d 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -5256,4 +5256,21 @@ class AppLocalizationsEn extends AppLocalizations { @override String get discoverRegions_retry => 'Retry'; + + @override + String get setRegionScope_menu => 'Set Region Scope'; + + @override + String setRegionScope_title(String channel) { + return 'Region scope for $channel'; + } + + @override + String get setRegionScope_discover => 'Discover from repeater'; + + @override + String get setRegionScope_none => 'None (unscoped)'; + + @override + String get setRegionScope_save => 'Save'; } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 7ac68848..e1a090bc 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -5338,4 +5338,21 @@ class AppLocalizationsEs extends AppLocalizations { @override String get discoverRegions_retry => 'Retry'; + + @override + String get setRegionScope_menu => 'Set Region Scope'; + + @override + String setRegionScope_title(String channel) { + return 'Region scope for $channel'; + } + + @override + String get setRegionScope_discover => 'Discover from repeater'; + + @override + String get setRegionScope_none => 'None (unscoped)'; + + @override + String get setRegionScope_save => 'Save'; } diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index de96617d..d5243bba 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -5366,4 +5366,21 @@ class AppLocalizationsFr extends AppLocalizations { @override String get discoverRegions_retry => 'Retry'; + + @override + String get setRegionScope_menu => 'Set Region Scope'; + + @override + String setRegionScope_title(String channel) { + return 'Region scope for $channel'; + } + + @override + String get setRegionScope_discover => 'Discover from repeater'; + + @override + String get setRegionScope_none => 'None (unscoped)'; + + @override + String get setRegionScope_save => 'Save'; } diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index 7dc50684..7e225140 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -5355,4 +5355,21 @@ class AppLocalizationsHu extends AppLocalizations { @override String get discoverRegions_retry => 'Retry'; + + @override + String get setRegionScope_menu => 'Set Region Scope'; + + @override + String setRegionScope_title(String channel) { + return 'Region scope for $channel'; + } + + @override + String get setRegionScope_discover => 'Discover from repeater'; + + @override + String get setRegionScope_none => 'None (unscoped)'; + + @override + String get setRegionScope_save => 'Save'; } diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index e849ee28..5a3c5e0c 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -5343,4 +5343,21 @@ class AppLocalizationsIt extends AppLocalizations { @override String get discoverRegions_retry => 'Retry'; + + @override + String get setRegionScope_menu => 'Set Region Scope'; + + @override + String setRegionScope_title(String channel) { + return 'Region scope for $channel'; + } + + @override + String get setRegionScope_discover => 'Discover from repeater'; + + @override + String get setRegionScope_none => 'None (unscoped)'; + + @override + String get setRegionScope_save => 'Save'; } diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index ba18afc7..4354632e 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -5109,4 +5109,21 @@ class AppLocalizationsJa extends AppLocalizations { @override String get discoverRegions_retry => 'Retry'; + + @override + String get setRegionScope_menu => 'Set Region Scope'; + + @override + String setRegionScope_title(String channel) { + return 'Region scope for $channel'; + } + + @override + String get setRegionScope_discover => 'Discover from repeater'; + + @override + String get setRegionScope_none => 'None (unscoped)'; + + @override + String get setRegionScope_save => 'Save'; } diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 7e8bafcb..494486bf 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -5110,4 +5110,21 @@ class AppLocalizationsKo extends AppLocalizations { @override String get discoverRegions_retry => 'Retry'; + + @override + String get setRegionScope_menu => 'Set Region Scope'; + + @override + String setRegionScope_title(String channel) { + return 'Region scope for $channel'; + } + + @override + String get setRegionScope_discover => 'Discover from repeater'; + + @override + String get setRegionScope_none => 'None (unscoped)'; + + @override + String get setRegionScope_save => 'Save'; } diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 9f4b26fd..bf08cee8 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -5318,4 +5318,21 @@ class AppLocalizationsNl extends AppLocalizations { @override String get discoverRegions_retry => 'Retry'; + + @override + String get setRegionScope_menu => 'Set Region Scope'; + + @override + String setRegionScope_title(String channel) { + return 'Region scope for $channel'; + } + + @override + String get setRegionScope_discover => 'Discover from repeater'; + + @override + String get setRegionScope_none => 'None (unscoped)'; + + @override + String get setRegionScope_save => 'Save'; } diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 5465bb80..ce43433f 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -5355,4 +5355,21 @@ class AppLocalizationsPl extends AppLocalizations { @override String get discoverRegions_retry => 'Retry'; + + @override + String get setRegionScope_menu => 'Set Region Scope'; + + @override + String setRegionScope_title(String channel) { + return 'Region scope for $channel'; + } + + @override + String get setRegionScope_discover => 'Discover from repeater'; + + @override + String get setRegionScope_none => 'None (unscoped)'; + + @override + String get setRegionScope_save => 'Save'; } diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 4f96df06..b8914dea 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -5331,4 +5331,21 @@ class AppLocalizationsPt extends AppLocalizations { @override String get discoverRegions_retry => 'Retry'; + + @override + String get setRegionScope_menu => 'Set Region Scope'; + + @override + String setRegionScope_title(String channel) { + return 'Region scope for $channel'; + } + + @override + String get setRegionScope_discover => 'Discover from repeater'; + + @override + String get setRegionScope_none => 'None (unscoped)'; + + @override + String get setRegionScope_save => 'Save'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 57e20a33..ef35a343 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -5349,4 +5349,21 @@ class AppLocalizationsRu extends AppLocalizations { @override String get discoverRegions_retry => 'Retry'; + + @override + String get setRegionScope_menu => 'Set Region Scope'; + + @override + String setRegionScope_title(String channel) { + return 'Region scope for $channel'; + } + + @override + String get setRegionScope_discover => 'Discover from repeater'; + + @override + String get setRegionScope_none => 'None (unscoped)'; + + @override + String get setRegionScope_save => 'Save'; } diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index 5f25b108..5001d9c9 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -5315,4 +5315,21 @@ class AppLocalizationsSk extends AppLocalizations { @override String get discoverRegions_retry => 'Retry'; + + @override + String get setRegionScope_menu => 'Set Region Scope'; + + @override + String setRegionScope_title(String channel) { + return 'Region scope for $channel'; + } + + @override + String get setRegionScope_discover => 'Discover from repeater'; + + @override + String get setRegionScope_none => 'None (unscoped)'; + + @override + String get setRegionScope_save => 'Save'; } diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index 642b0105..fb1f1cee 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -5313,4 +5313,21 @@ class AppLocalizationsSl extends AppLocalizations { @override String get discoverRegions_retry => 'Retry'; + + @override + String get setRegionScope_menu => 'Set Region Scope'; + + @override + String setRegionScope_title(String channel) { + return 'Region scope for $channel'; + } + + @override + String get setRegionScope_discover => 'Discover from repeater'; + + @override + String get setRegionScope_none => 'None (unscoped)'; + + @override + String get setRegionScope_save => 'Save'; } diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index f6af9b3d..b60de897 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -5288,4 +5288,21 @@ class AppLocalizationsSv extends AppLocalizations { @override String get discoverRegions_retry => 'Retry'; + + @override + String get setRegionScope_menu => 'Set Region Scope'; + + @override + String setRegionScope_title(String channel) { + return 'Region scope for $channel'; + } + + @override + String get setRegionScope_discover => 'Discover from repeater'; + + @override + String get setRegionScope_none => 'None (unscoped)'; + + @override + String get setRegionScope_save => 'Save'; } diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 963f1183..f78ba02e 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -5350,4 +5350,21 @@ class AppLocalizationsUk extends AppLocalizations { @override String get discoverRegions_retry => 'Retry'; + + @override + String get setRegionScope_menu => 'Set Region Scope'; + + @override + String setRegionScope_title(String channel) { + return 'Region scope for $channel'; + } + + @override + String get setRegionScope_discover => 'Discover from repeater'; + + @override + String get setRegionScope_none => 'None (unscoped)'; + + @override + String get setRegionScope_save => 'Save'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 937c52b2..f86169dd 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -4983,4 +4983,21 @@ class AppLocalizationsZh extends AppLocalizations { @override String get discoverRegions_retry => 'Retry'; + + @override + String get setRegionScope_menu => 'Set Region Scope'; + + @override + String setRegionScope_title(String channel) { + return 'Region scope for $channel'; + } + + @override + String get setRegionScope_discover => 'Discover from repeater'; + + @override + String get setRegionScope_none => 'None (unscoped)'; + + @override + String get setRegionScope_save => 'Save'; } diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index 3ccdefd8..a33d3350 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -28,6 +28,8 @@ import '../models/translation_support.dart'; import '../models/app_settings.dart'; import '../services/app_settings_service.dart'; import '../services/corescope_service.dart'; +import '../services/region_discovery_service.dart'; +import '../widgets/channel_region_scope_dialog.dart'; import '../widgets/channel_notify_mode.dart'; import '../services/block_service.dart'; import '../services/chat_text_scale_service.dart'; @@ -320,6 +322,37 @@ class _ChannelChatScreenState extends State { ); } + /// Open the Set Region Scope picker for the current channel (#815). + Future _openRegionScopeDialog() async { + final connector = context.read(); + final channelIndex = _currentChannel.index; + final current = await connector.channelRegionScope(channelIndex); + final repeaters = connector.contacts + .where((c) => c.type == advTypeRepeater) + .map((c) => RepeaterChoice(name: c.name, pubKey: c.publicKey)) + .toList(); + final service = RegionDiscoveryService( + (pub, timeout) => + connector.discoverRegions(repeaterPubKey: pub, timeout: timeout), + ); + if (!mounted) { + service.dispose(); + return; + } + await showDialog( + context: context, + builder: (dialogContext) => ChannelRegionScopeDialog( + channelName: _currentChannel.name, + currentScope: current, + repeaters: repeaters, + service: service, + onSave: (region) => + connector.setChannelRegionScope(channelIndex, region), + ), + ); + service.dispose(); + } + /// Switch to another channel picked from the nav panel. /// /// Swaps the conversation in place rather than pushing a route, so a pinned @@ -435,6 +468,8 @@ class _ChannelChatScreenState extends State { context.read().clearMessagesForChannel( _currentChannel.index, ); + } else if (value == 'setRegionScope') { + _openRegionScopeDialog(); } }, itemBuilder: (context) => [ @@ -456,6 +491,17 @@ class _ChannelChatScreenState extends State { ], ), ), + if (context.read().supportsRegionScope) + PopupMenuItem( + value: 'setRegionScope', + child: Row( + children: [ + const Icon(Icons.travel_explore, size: 20), + const SizedBox(width: 12), + Text(context.l10n.setRegionScope_menu), + ], + ), + ), PopupMenuItem( value: 'clearChat', child: Row( diff --git a/lib/widgets/channel_region_scope_dialog.dart b/lib/widgets/channel_region_scope_dialog.dart new file mode 100644 index 00000000..511715fc --- /dev/null +++ b/lib/widgets/channel_region_scope_dialog.dart @@ -0,0 +1,161 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; + +import '../l10n/app_localizations.dart'; +import '../l10n/l10n.dart'; +import '../services/region_discovery_service.dart'; + +/// A repeater the user can discover regions from (name + 32-byte key). +class RepeaterChoice { + const RepeaterChoice({required this.name, required this.pubKey}); + final String name; + final Uint8List pubKey; +} + +/// Pick the flood region a channel's outgoing messages ride (Feature #812, +/// Epic #815). Discovers regions from a chosen repeater (via the injected +/// [service]) and lets the user select one, or None to leave the channel +/// unscoped. Persisting the choice is the caller's job, via [onSave]. +class ChannelRegionScopeDialog extends StatefulWidget { + const ChannelRegionScopeDialog({ + super.key, + required this.channelName, + required this.currentScope, + required this.repeaters, + required this.service, + required this.onSave, + }); + + final String channelName; + final String? currentScope; + final List repeaters; + final RegionDiscoveryService service; + final Future Function(String? region) onSave; + + @override + State createState() => + _ChannelRegionScopeDialogState(); +} + +class _ChannelRegionScopeDialogState extends State { + late RepeaterChoice? _repeater = widget.repeaters.isEmpty + ? null + : widget.repeaters.first; + late String? _selected = widget.currentScope; + + void _discover() { + final repeater = _repeater; + if (repeater != null) widget.service.discover(repeater.pubKey); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return AlertDialog( + title: Text(l10n.setRegionScope_title(widget.channelName)), + content: SizedBox( + width: double.maxFinite, + child: ListenableBuilder( + listenable: widget.service, + builder: (context, _) => _content(l10n), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), + ), + ElevatedButton( + key: const Key('regionScopeSave'), + onPressed: () async { + await widget.onSave(_selected); + if (context.mounted) Navigator.of(context).pop(); + }, + child: Text(l10n.setRegionScope_save), + ), + ], + ); + } + + Widget _content(AppLocalizations l10n) { + final repeaterName = _repeater?.name ?? ''; + final children = []; + + if (widget.repeaters.isNotEmpty) { + children.add( + DropdownButton( + isExpanded: true, + value: _repeater, + items: [ + for (final r in widget.repeaters) + DropdownMenuItem(value: r, child: Text(r.name)), + ], + onChanged: (v) => setState(() => _repeater = v), + ), + ); + children.add( + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + key: const Key('regionScopeDiscover'), + icon: const Icon(Icons.travel_explore), + label: Text(l10n.setRegionScope_discover), + onPressed: widget.service.isLoading ? null : _discover, + ), + ), + ); + } + + switch (widget.service.status) { + case RegionDiscoveryStatus.loading: + children.add( + const Padding( + padding: EdgeInsets.all(12), + child: Center(child: CircularProgressIndicator()), + ), + ); + case RegionDiscoveryStatus.timeout: + children.add(Text(l10n.discoverRegions_timeout(repeaterName))); + case RegionDiscoveryStatus.error: + children.add( + Text(widget.service.errorMessage ?? l10n.discoverRegions_error), + ); + case RegionDiscoveryStatus.empty: + children.add(Text(l10n.discoverRegions_empty(repeaterName))); + case RegionDiscoveryStatus.idle: + case RegionDiscoveryStatus.success: + break; + } + + children.add( + ListTile( + key: const Key('regionScopeNone'), + title: Text(l10n.setRegionScope_none), + trailing: _selected == null ? const Icon(Icons.check) : null, + selected: _selected == null, + onTap: () => setState(() => _selected = null), + ), + ); + + // Current scope first (so it stays visible even before a re-discovery), + // then the freshly discovered regions, deduped by name. + final names = {}; + if (widget.currentScope != null) names.add(widget.currentScope!); + names.addAll(widget.service.regions.map((r) => r.name)); + for (final name in names) { + children.add( + ListTile( + title: Text(name), + trailing: _selected == name ? const Icon(Icons.check) : null, + selected: _selected == name, + onTap: () => setState(() => _selected = name), + ), + ); + } + + return SingleChildScrollView( + child: Column(mainAxisSize: MainAxisSize.min, children: children), + ); + } +} diff --git a/test/widgets/channel_region_scope_dialog_test.dart b/test/widgets/channel_region_scope_dialog_test.dart new file mode 100644 index 00000000..a3b47ad7 --- /dev/null +++ b/test/widgets/channel_region_scope_dialog_test.dart @@ -0,0 +1,78 @@ +// Epic #815 T C2: channel Set Region Scope picker dialog. +// Decoupled from the connector (plain data + callbacks + injected service), +// so it widget-tests without a radio. + +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/connector/meshcore_protocol.dart'; +import 'package:meshcore_open/l10n/app_localizations.dart'; +import 'package:meshcore_open/services/region_discovery_service.dart'; +import 'package:meshcore_open/widgets/channel_region_scope_dialog.dart'; + +void main() { + final repeaters = [RepeaterChoice(name: 'rpt-01', pubKey: Uint8List(32))]; + RegionsReply reply(List names) => + RegionsReply(tag: 1, clock: 1, regionNames: names); + + Future pump( + WidgetTester tester, { + String? current, + required RegionDiscoveryService service, + required void Function(String?) onSave, + }) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: ChannelRegionScopeDialog( + channelName: 'general', + currentScope: current, + repeaters: repeaters, + service: service, + onSave: (r) async => onSave(r), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('discover, pick a region, save returns it', (tester) async { + String? result; + var called = false; + await pump( + tester, + service: RegionDiscoveryService((_, _) async => reply(['oki', 'test'])), + onSave: (r) { + called = true; + result = r; + }, + ); + await tester.tap(find.byKey(const Key('regionScopeDiscover'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('oki')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('regionScopeSave'))); + await tester.pumpAndSettle(); + expect(called, isTrue); + expect(result, 'oki'); + }); + + testWidgets('selecting None saves null (clears the scope)', (tester) async { + String? result = 'sentinel'; + await pump( + tester, + current: 'oki', + service: RegionDiscoveryService((_, _) async => reply(['oki'])), + onSave: (r) => result = r, + ); + await tester.tap(find.byKey(const Key('regionScopeNone'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('regionScopeSave'))); + await tester.pumpAndSettle(); + expect(result, isNull); + }); +}