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 <noreply@anthropic.com>
feat/844-per-radio-notify
diffwireauto 1 month ago committed by Benjamin Wiechel
parent 7b199ac324
commit 24ca2abe3a

@ -4824,6 +4824,23 @@ class MeshCoreConnector extends ChangeNotifier {
Uint8List frame,
) => _sendChannelFrameScoped(channelIndex, frame);
/// The region name scoping [channelIndex], or null when unscoped (#815).
Future<String?> channelRegionScope(int channelIndex) =>
_channelRegionScopeStore.scopeFor(channelIndex);
/// Set (or, with null, clear) the region scope for [channelIndex] (#815).
Future<void> setChannelRegionScope(
int channelIndex,
String? regionName,
) async {
if (regionName == null) {
await _channelRegionScopeStore.clearScope(channelIndex);
} else {
await _channelRegionScopeStore.setScope(channelIndex, regionName);
}
notifyListeners();
}
Future<void> removeContact(Contact contact) async {
if (!isConnected) return;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -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<ChannelChatScreen> {
);
}
/// Open the Set Region Scope picker for the current channel (#815).
Future<void> _openRegionScopeDialog() async {
final connector = context.read<MeshCoreConnector>();
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<void>(
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<ChannelChatScreen> {
context.read<MeshCoreConnector>().clearMessagesForChannel(
_currentChannel.index,
);
} else if (value == 'setRegionScope') {
_openRegionScopeDialog();
}
},
itemBuilder: (context) => [
@ -456,6 +491,17 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
],
),
),
if (context.read<MeshCoreConnector>().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(

@ -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<RepeaterChoice> repeaters;
final RegionDiscoveryService service;
final Future<void> Function(String? region) onSave;
@override
State<ChannelRegionScopeDialog> createState() =>
_ChannelRegionScopeDialogState();
}
class _ChannelRegionScopeDialogState extends State<ChannelRegionScopeDialog> {
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 = <Widget>[];
if (widget.repeaters.isNotEmpty) {
children.add(
DropdownButton<RepeaterChoice>(
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 = <String>{};
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),
);
}
}

@ -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<String> names) =>
RegionsReply(tag: 1, clock: 1, regionNames: names);
Future<void> 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);
});
}
Loading…
Cancel
Save

Powered by TurnKey Linux.