Merge branch 'dev' into update-pacox-multibyte

This commit is contained in:
HDDen
2026-06-16 11:53:59 +03:00
42 changed files with 2711 additions and 279 deletions
+24 -12
View File
@@ -117,20 +117,32 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
final entry = entries[index];
final time =
'${entry.timestamp.hour.toString().padLeft(2, '0')}:${entry.timestamp.minute.toString().padLeft(2, '0')}:${entry.timestamp.second.toString().padLeft(2, '0')}';
return GestureDetector(
onLongPress: () async {
await Clipboard.setData(
ClipboardData(
text: entry.payload
.map(
(b) => b
.toRadixString(16)
.padLeft(2, '0'),
)
.join(''),
Future<void> copyHex() async {
await Clipboard.setData(
ClipboardData(
text: entry.payload
.map(
(b) => b
.toRadixString(16)
.padLeft(2, '0'),
)
.join(''),
),
);
if (context.mounted) {
showDismissibleSnackBar(
context,
content: Text(
context.l10n.debugLog_bleCopied,
),
);
},
}
}
return GestureDetector(
onTap: copyHex,
onLongPress: copyHex,
onSecondaryTap: copyHex,
child: Container(
color: MeshPalette.bg,
padding: const EdgeInsets.symmetric(
+179 -42
View File
@@ -43,6 +43,8 @@ import '../theme/mesh_theme.dart';
import '../widgets/mesh_ui.dart';
import 'channel_message_path_screen.dart';
import 'map_screen.dart';
import 'region_management_screen.dart';
import '../storage/region_store.dart';
class ChannelChatScreen extends StatefulWidget {
final Channel channel;
@@ -274,46 +276,63 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Row(
children: [
_channelIcon(widget.channel),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.channel.name.isEmpty
? context.l10n.channels_channelIndex(
widget.channel.index,
)
: widget.channel.name,
style: const TextStyle(fontSize: 16),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Consumer<MeshCoreConnector>(
builder: (context, connector, _) {
final unreadCount = connector
.getUnreadCountForChannelIndex(widget.channel.index);
final privacy = widget.channel.isPublicChannel
? context.l10n.channels_public
: context.l10n.channels_private;
return Text(
'$privacy${context.l10n.chat_unread(unreadCount)}',
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12),
);
},
),
],
title: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => openRegionSelectDialog(widget.channel),
child: Row(
children: [
_channelIcon(widget.channel),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.channel.name.isEmpty
? context.l10n.channels_channelIndex(
widget.channel.index,
)
: widget.channel.name,
style: const TextStyle(fontSize: 16),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Consumer<MeshCoreConnector>(
builder: (context, connector, _) {
final unreadCount = connector
.getUnreadCountForChannelIndex(
widget.channel.index,
);
final privacy = widget.channel.isPublicChannel
? context.l10n.channels_public
: context.l10n.channels_private;
final region = connector.getChannelRegion(
widget.channel.index,
);
final regionText = region.isNotEmpty
? '${context.l10n.channels_regionSetTo(region)}'
: '';
return Text(
'$privacy${context.l10n.chat_unread(unreadCount)}$regionText',
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12),
);
},
),
],
),
),
),
],
],
),
),
centerTitle: false,
bottom: const SyncProgressAppBarBottom(),
actions: [
IconButton(
tooltip: context.l10n.channels_regionSelect_Title,
icon: const Icon(Icons.landscape),
onPressed: () => openRegionSelectDialog(widget.channel),
),
const RadioStatsIconButton(),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
@@ -655,6 +674,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
fontStyle: FontStyle.italic,
color: textColor.withValues(alpha: 0.72),
),
onSecondaryTap: PlatformInfo.isDesktop
? () => _showMessageActions(message)
: null,
),
),
],
@@ -667,22 +689,25 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
: EdgeInsets.zero,
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RouteChip(
isDirect: (message.pathLength ?? -1) >= 0,
hops: displayHopCount,
),
const SizedBox(width: 4),
Text(
context.l10n.channels_via(
_formatPathPrefixes(
Flexible(
child: Text(
context.l10n.channels_via(
_formatPathPrefixes(
displayPath,
displayPathHashWidth,
),
),
style: MeshTheme.mono(
fontSize: 9.5 * textScale,
color: metaColor,
),
style: MeshTheme.mono(
fontSize: 9.5 * textScale,
color: metaColor,
),
),
),
],
@@ -1593,6 +1618,118 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
}
return hues[h % hues.length];
}
Future<void> openRegionSelectDialog(Channel channel) async {
// The AppBar subtitle reads the region from the connector inside a
// Consumer, so setChannelRegion's notifyListeners refreshes it directly —
// no post-dialog setState needed.
await showDialog(
context: context,
builder: (BuildContext context) => _RegionSelectDialog(channel: channel),
);
}
}
class _RegionSelectDialog extends StatefulWidget {
final Channel channel;
const _RegionSelectDialog({required this.channel});
@override
State<_RegionSelectDialog> createState() => _RegionSelectDialogState();
}
class _RegionSelectDialogState extends State<_RegionSelectDialog> {
final RegionStore regionStore = RegionStore();
List<Region> regions = [];
int selectedIndex = -1;
@override
void initState() {
super.initState();
loadRegions();
}
void loadRegions() {
setState(() {
regions = regionStore.loadRegions();
final channelRegion = context.read<MeshCoreConnector>().getChannelRegion(
widget.channel.index,
);
selectedIndex = regions.indexOf(channelRegion);
});
}
@override
Widget build(BuildContext context) {
return Dialog(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
AppBar(
backgroundColor: Colors.transparent,
title: Text(context.l10n.channels_regionSelect_Title),
centerTitle: true,
actions: [
IconButton(
tooltip: context.l10n.channels_clearRegion,
icon: const Icon(Icons.backspace_outlined),
onPressed: () {
context.read<MeshCoreConnector>().setChannelRegion(
widget.channel.index,
'',
);
Navigator.pop(context);
},
),
IconButton(
tooltip: context.l10n.settings_regionSettingsSubtitle,
icon: const Icon(Icons.settings),
onPressed: () async {
await pushRegionManagementScreen(context);
if (!mounted) return;
loadRegions();
},
),
],
),
const SizedBox(height: 15),
Expanded(
child: ListView.builder(
itemCount: regions.length,
itemBuilder: (context, index) {
final selected = selectedIndex == index;
return ListTile(
leading: Icon(
Icons.landscape,
color: selected ? MeshPalette.blue : null,
),
title: Text(regions[index]),
trailing: selected
? const Icon(Icons.check, color: MeshPalette.blue)
: null,
tileColor: selected ? MeshPalette.blueBg : null,
onTap: () {
// Tapping the already-selected region clears it.
context.read<MeshCoreConnector>().setChannelRegion(
widget.channel.index,
selected ? '' : regions[index],
);
Navigator.pop(context);
},
);
},
),
),
],
),
),
);
}
}
class _SwipeReplyBubble extends StatefulWidget {
+146 -120
View File
@@ -372,13 +372,30 @@ class _ChannelsScreenState extends State<ChannelsScreen>
_communityIndex,
);
final bool isCommunityChannel = Channel.isCommunityChannel(channelType);
final community = isCommunityChannel
? _communityIndex.getCommunityForChannel(channel)
: null;
// Only flood-routed channels carry a region; show it when one is set.
String subtitle = connector.hasChannelRegion(channel.index)
? context.l10n.channels_regionSetTo(
connector.getChannelRegion(channel.index),
)
: '';
switch (channelType) {
case ChannelType.communityPublic:
icon = Icons.groups;
iconColor = MeshPalette.magenta;
if (community != null) {
subtitle =
'${context.l10n.community_publicChannel}${community.name}';
}
case ChannelType.communityHashtag:
icon = Icons.groups;
iconColor = MeshPalette.magenta;
if (community != null) {
subtitle =
'${context.l10n.community_hashtagChannel}${community.name}';
}
case ChannelType.public:
icon = Icons.public;
iconColor = MeshPalette.signal;
@@ -427,142 +444,151 @@ class _ChannelsScreenState extends State<ChannelsScreen>
channelMessageStore,
channel,
),
child: GestureDetector(
onSecondaryTapUp: PlatformInfo.isDesktop
? (_) => _showChannelActions(
this.context,
connector,
channelMessageStore,
channel,
)
: null,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Leading avatar with optional community badge
Stack(
clipBehavior: Clip.none,
children: [
AvatarCircle(
name: channelLabel,
size: 42,
color: iconColor,
icon: icon,
),
if (isCommunityChannel)
Positioned(
right: -2,
bottom: -2,
child: Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: MeshPalette.magenta,
shape: BoxShape.circle,
border: Border.all(
color: Theme.of(
context,
).colorScheme.surfaceContainerLow,
width: 2,
),
),
child: const Icon(
Icons.people,
size: 8,
color: Colors.white,
),
),
),
],
),
const SizedBox(width: 12),
// Title + subtitle + ch chip
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Text(
channelLabel,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(fontWeight: FontWeight.w500),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 6),
StatusChip(
label: 'CH ${channel.index}',
color: MeshPalette.blue,
fontSize: 10,
),
],
),
if (lastPreview.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
lastPreview,
style: MeshTheme.mono(
fontSize: 11.5,
color: scheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
onSecondaryTap: PlatformInfo.isDesktop
? () => _showChannelActions(
this.context,
connector,
channelMessageStore,
channel,
)
: null,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Leading avatar with optional community badge
Stack(
clipBehavior: Clip.none,
children: [
AvatarCircle(
name: channelLabel,
size: 42,
color: iconColor,
icon: icon,
),
),
const SizedBox(width: 8),
// Right side: time + unread badge + muted + drag handle
Column(
crossAxisAlignment: CrossAxisAlignment.end,
if (isCommunityChannel)
Positioned(
right: -2,
bottom: -2,
child: Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: MeshPalette.magenta,
shape: BoxShape.circle,
border: Border.all(
color: Theme.of(
context,
).colorScheme.surfaceContainerLow,
width: 2,
),
),
child: const Icon(
Icons.people,
size: 8,
color: Colors.white,
),
),
),
],
),
const SizedBox(width: 12),
// Title + subtitle + ch chip
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (lastTime != null)
Text(
_relativeTime(lastTime),
style: MeshTheme.mono(
fontSize: 11,
color: scheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (isMuted) ...[
Icon(
Icons.notifications_off,
size: 14,
color: scheme.onSurfaceVariant,
Expanded(
child: Text(
channelLabel,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(fontWeight: FontWeight.w500),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(width: 4),
],
if (unreadCount > 0) UnreadBadge(count: unreadCount),
),
const SizedBox(width: 6),
StatusChip(
label: 'CH ${channel.index}',
color: MeshPalette.blue,
fontSize: 10,
),
],
),
if (subtitle.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
subtitle,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
if (lastPreview.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
lastPreview,
style: MeshTheme.mono(
fontSize: 11.5,
color: scheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
if (showDragHandle && dragIndex != null) ...[
const SizedBox(width: 4),
ReorderableDragStartListener(
index: dragIndex,
child: Padding(
padding: const EdgeInsets.all(8),
child: Icon(
Icons.drag_handle,
),
const SizedBox(width: 8),
// Right side: time + unread badge + muted + drag handle
Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
if (lastTime != null)
Text(
_relativeTime(lastTime),
style: MeshTheme.mono(
fontSize: 11,
color: scheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isMuted) ...[
Icon(
Icons.notifications_off,
size: 14,
color: scheme.onSurfaceVariant,
),
const SizedBox(width: 4),
],
if (unreadCount > 0) UnreadBadge(count: unreadCount),
],
),
],
),
if (showDragHandle && dragIndex != null) ...[
const SizedBox(width: 4),
ReorderableDragStartListener(
index: dragIndex,
child: Padding(
padding: const EdgeInsets.all(8),
child: Icon(
Icons.drag_handle,
color: scheme.onSurfaceVariant,
),
),
),
],
),
],
),
),
);
+3
View File
@@ -1457,6 +1457,9 @@ class _MessageBubble extends StatelessWidget {
color: textColor.withValues(alpha: 0.72),
fontSize: bodyFontSize * textScale,
),
onSecondaryTap: PlatformInfo.isDesktop
? onLongPress
: null,
),
),
],
+3 -7
View File
@@ -147,13 +147,6 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
connector,
index,
);
if (PlatformInfo.isDesktop) {
return GestureDetector(
onSecondaryTapUp: (_) =>
_showContactContextMenu(contact, connector),
child: tile,
);
}
return tile;
},
),
@@ -204,6 +197,9 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
}
},
onLongPress: () => _showContactContextMenu(contact, connector),
onSecondaryTap: PlatformInfo.isDesktop
? () => _showContactContextMenu(contact, connector)
: null,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Row(
children: [
@@ -430,6 +430,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
minZoom: _mapMinZoom,
maxZoom: _mapMaxZoom,
onLongPress: (_, point) => _addCustomPoint(point),
onSecondaryTap: (_, point) => _addCustomPoint(point),
onPositionChanged: (camera, hasGesture) {
final shouldShow = camera.zoom >= _labelZoomThreshold;
if (!_didReceivePositionUpdate ||
+41 -23
View File
@@ -287,6 +287,31 @@ class _MapScreenState extends State<MapScreen> {
);
}
void _handleMapContextPress(
BuildContext context,
MeshCoreConnector connector,
LatLng latLng,
) {
if (_isSelectingPoi) {
setState(() {
_isSelectingPoi = false;
});
_shareMarker(
context: context,
connector: connector,
position: latLng,
defaultLabel: context.l10n.map_pointOfInterest,
flags: 'poi',
);
return;
}
_showShareMarkerAtPositionSheet(
context: context,
connector: connector,
position: latLng,
);
}
@override
Widget build(BuildContext context) {
return Builder(
@@ -714,24 +739,10 @@ class _MapScreenState extends State<MapScreen> {
}
},
onLongPress: (_, latLng) {
if (_isSelectingPoi) {
setState(() {
_isSelectingPoi = false;
});
_shareMarker(
context: context,
connector: connector,
position: latLng,
defaultLabel: context.l10n.map_pointOfInterest,
flags: 'poi',
);
return;
}
_showShareMarkerAtPositionSheet(
context: context,
connector: connector,
position: latLng,
);
_handleMapContextPress(context, connector, latLng);
},
onSecondaryTap: (_, latLng) {
_handleMapContextPress(context, connector, latLng);
},
onPositionChanged: (camera, hasGesture) {
// Track zoom in half-step buckets so cluster/marker
@@ -1185,9 +1196,12 @@ class _MapScreenState extends State<MapScreen> {
width: 48,
height: 48,
child: GestureDetector(
onLongPress: () => _isBuildingPathTrace
? _showNodeInfo(context, guess.contact)
: null,
onLongPress: () {
if (_isBuildingPathTrace) _showNodeInfo(context, guess.contact);
},
onSecondaryTap: () {
if (_isBuildingPathTrace) _showNodeInfo(context, guess.contact);
},
onTap: () => _isBuildingPathTrace
? _addToPath(context, guess.contact, position: guess.position)
: _selectNode(guess.contact, guessedPosition: guess.position),
@@ -1405,8 +1419,12 @@ class _MapScreenState extends State<MapScreen> {
height: size,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onLongPress: () =>
_isBuildingPathTrace ? _showNodeInfo(context, contact) : null,
onLongPress: () {
if (_isBuildingPathTrace) _showNodeInfo(context, contact);
},
onSecondaryTap: () {
if (_isBuildingPathTrace) _showNodeInfo(context, contact);
},
onTap: () => _isBuildingPathTrace
? _addToPath(context, contact)
: _selectNode(contact),
+527
View File
@@ -0,0 +1,527 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:meshcore_open/connector/meshcore_connector.dart';
import 'package:meshcore_open/connector/meshcore_protocol.dart';
import 'package:meshcore_open/l10n/l10n.dart';
import 'package:meshcore_open/models/contact.dart';
import 'package:meshcore_open/storage/region_store.dart';
import 'package:meshcore_open/theme/mesh_theme.dart';
import 'package:meshcore_open/widgets/mesh_ui.dart';
import 'package:provider/provider.dart';
Future<void> pushRegionManagementScreen(BuildContext context) {
return Navigator.push(
context,
MaterialPageRoute<void>(
builder: (context) => const RegionManagementScreen(),
),
);
}
class RegionManagementScreen extends StatefulWidget {
const RegionManagementScreen({super.key});
@override
State<RegionManagementScreen> createState() => _RegionManagementScreenState();
}
class _RegionManagementScreenState extends State<RegionManagementScreen> {
static final RegExp _validFetchedRegion = RegExp(r'^[a-z0-9-]{1,30}$');
final RegionStore _regionStore = RegionStore();
List<Region> _regions = [];
bool _isFetchingRegions = false;
@override
void initState() {
super.initState();
final connector = context.read<MeshCoreConnector>();
_regionStore.setPublicKeyHex = connector.selfPublicKeyHex;
_loadRegions();
}
void _loadRegions() {
final regions = _regionStore.loadRegions();
if (mounted) {
setState(() {
_regions = regions;
});
}
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Scaffold(
appBar: AppBar(
title: Text(l10n.settings_regionManagement_screenTitle),
centerTitle: true,
actions: [
IconButton(
tooltip: l10n.settings_regionAddRegion,
icon: const Icon(Icons.add),
onPressed: () => _showAddRegionDialog(context),
),
IconButton(
tooltip: l10n.settings_regionFetchRegions,
icon: _isFetchingRegions
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.travel_explore),
onPressed: _isFetchingRegions ? null : _showFetchRegionsDialog,
),
],
),
body: ListView.builder(
padding: const EdgeInsets.only(left: 16, right: 16, top: 8, bottom: 88),
itemCount: _regions.length,
itemBuilder: (context, index) {
final region = _regions[index];
return _buildRegionTile(context, region);
},
),
);
}
void _showAddRegionDialog(BuildContext context) {
final l10n = context.l10n;
final controller = TextEditingController();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.settings_regionName),
content: TextField(
controller: controller,
autofocus: true,
textInputAction: TextInputAction.send,
onSubmitted: (_) => _handleAddRegion(controller.text, context),
decoration: InputDecoration(
hintText: l10n.settings_regionNameHint,
border: const OutlineInputBorder(),
),
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.allow(RegExp("[a-z0-9-]")),
],
maxLength: 30,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.common_cancel),
),
TextButton(
onPressed: () => _handleAddRegion(controller.text, context),
child: Text(l10n.common_add),
),
],
),
);
}
Future<void> _showFetchRegionsDialog() async {
if (_isFetchingRegions) return;
setState(() {
_isFetchingRegions = true;
});
Set<Region> fetchedRegions = {};
try {
fetchedRegions = await _fetchRegionsFromRepeaters();
} finally {
if (mounted) {
setState(() {
_isFetchingRegions = false;
});
}
}
if (!mounted) return;
final l10n = context.l10n;
final sortedRegions = fetchedRegions.toList()..sort();
await showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(l10n.settings_regionFetchRegions),
content: sortedRegions.isEmpty
? Text(l10n.settings_regionFetchRegionsFail)
: StatefulBuilder(
builder: (context, setDialogState) {
return SizedBox(
width: double.maxFinite,
child: ListView.builder(
shrinkWrap: true,
itemCount: sortedRegions.length,
itemBuilder: (context, index) {
final fetchedRegion = sortedRegions[index];
final alreadyExists = _regions.contains(fetchedRegion);
return MeshCard(
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.only(left: 14, right: 4),
child: Row(
children: [
const Icon(
Icons.landscape,
color: MeshPalette.blue,
),
const SizedBox(width: 12),
Expanded(
child: Text(
fetchedRegion,
style: Theme.of(context).textTheme.bodyMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
TextButton(
style: alreadyExists
? TextButton.styleFrom(
foregroundColor: Theme.of(
context,
).disabledColor,
)
: null,
onPressed: () {
if (alreadyExists) {
_showDialogSnackBar(
context,
l10n.settings_regionFetchRegionsAlreadyExists,
);
return;
}
_regionStore.addRegion(fetchedRegion);
_loadRegions();
setDialogState(() {});
},
child: Text(l10n.common_add),
),
],
),
);
},
),
);
},
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text(l10n.common_close),
),
],
),
);
}
void _showDialogSnackBar(BuildContext context, String message) {
final overlay = Overlay.maybeOf(context);
if (overlay == null) return;
final theme = Theme.of(context);
final entry = OverlayEntry(
builder: (context) => Positioned(
left: 16,
right: 16,
bottom: 32,
child: SafeArea(
child: Material(
color: theme.colorScheme.inverseSurface,
elevation: 6,
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Text(
message,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onInverseSurface,
),
),
),
),
),
),
);
overlay.insert(entry);
Timer(const Duration(seconds: 3), entry.remove);
}
Future<Set<Region>> _fetchRegionsFromRepeaters() async {
final connector = context.read<MeshCoreConnector>();
final repeaters = await _discoverNearbyRepeaters(connector);
final regions = <Region>{};
for (final repeater in repeaters) {
if (!mounted || !connector.isConnected) break;
regions.addAll(await _requestRegionsFromRepeater(connector, repeater));
}
return regions;
}
Future<List<Contact>> _discoverNearbyRepeaters(
MeshCoreConnector connector,
) async {
final repeaters = connector.contacts
.where((contact) => contact.type == advTypeRepeater)
.toList();
if (repeaters.isEmpty || !connector.isConnected) return <Contact>[];
StreamSubscription<Uint8List>? subscription;
Timer? timeout;
final completer = Completer<Set<String>>();
final respondingPrefixes = <String>{};
final tag = DateTime.now().microsecondsSinceEpoch & 0xFFFFFFFF;
void complete() {
if (completer.isCompleted) return;
timeout?.cancel();
subscription?.cancel();
completer.complete(respondingPrefixes);
}
subscription = connector.receivedFrames.listen((frame) {
if (frame.isEmpty || completer.isCompleted) return;
final reader = BufferReader(frame);
try {
if (reader.readByte() != pushCodeControlData) return;
if (reader.remaining < 9) return;
reader.skipBytes(3); // SNR, RSSI, path_len from companion firmware.
final payloadType = reader.readByte();
if (((payloadType >> 4) & 0x0F) != controlSubtypeDiscoverResp ||
(payloadType & 0x0F) != advTypeRepeater) {
return;
}
reader.skipBytes(1); // Inbound SNR reported by the responding repeater.
if (reader.readUInt32LE() != tag) return;
final publicKeyPrefix = reader.readRemainingBytes();
if (publicKeyPrefix.isEmpty) return;
respondingPrefixes.add(pubKeyToHex(publicKeyPrefix));
} catch (_) {
// Ignore malformed discovery frames; another response may still arrive.
}
});
try {
final payload = buildDiscoveryRequestPayload(tag, prefixOnly: true);
await connector.sendFrame(buildSendControlDataFrame(payload));
timeout = Timer(const Duration(seconds: 10), complete);
final prefixes = await completer.future;
return repeaters.where((contact) {
final contactKey = contact.publicKeyHex.toLowerCase();
return prefixes.any((prefix) => contactKey.startsWith(prefix));
}).toList();
} catch (_) {
timeout?.cancel();
await subscription.cancel();
return <Contact>[];
}
}
Future<Set<Region>> _requestRegionsFromRepeater(
MeshCoreConnector connector,
Contact repeater,
) async {
StreamSubscription<Uint8List>? subscription;
Timer? timeout;
final completer = Completer<Set<Region>>();
int? expectedTag;
final originalPath = Uint8List.fromList(repeater.path);
final originalPathLength = repeater.pathLength;
var pathChangedForRequest = false;
void complete(Set<Region> regions) {
if (completer.isCompleted) return;
timeout?.cancel();
subscription?.cancel();
completer.complete(regions);
}
void restartTimeout(Duration duration) {
timeout?.cancel();
timeout = Timer(duration, () => complete(<Region>{}));
}
try {
final replyPath = Uint8List(0);
const replyHopCount = 0;
await connector.setContactPath(repeater, replyPath, replyHopCount);
pathChangedForRequest = true;
subscription = connector.receivedFrames.listen((frame) {
if (frame.isEmpty || completer.isCompleted) return;
final reader = BufferReader(frame);
try {
final cmd = reader.readByte();
if (cmd == respCodeSent) {
reader.skipBytes(1);
expectedTag = reader.readUInt32LE();
final estimatedTimeoutMs = reader.readUInt32LE();
restartTimeout(
Duration(
milliseconds: estimatedTimeoutMs > 0
? estimatedTimeoutMs + 2000
: 10000,
),
);
return;
}
if (cmd == respCodeErr) {
complete(<Region>{});
return;
}
if (cmd != pushCodeBinaryResponse || expectedTag == null) return;
reader.skipBytes(1);
final tag = reader.readUInt32LE();
if (tag != expectedTag) return;
complete(_parseRegionsResponse(reader.readRemainingBytes()));
} catch (_) {
complete(<Region>{});
}
});
restartTimeout(const Duration(seconds: 10));
final frame = buildSendAnonReqFrame(
repeater.publicKey,
requestType: anonReqTypeRegions,
replyPath: replyPath,
replyHopCount: replyHopCount,
pathHashWidth: connector.pathHashByteWidth,
);
await connector.sendFrame(frame);
final regions = await completer.future;
if (pathChangedForRequest && connector.isConnected) {
await _restoreRepeaterPath(
connector,
repeater,
originalPathLength,
originalPath,
);
}
return regions;
} catch (_) {
timeout?.cancel();
subscription?.cancel();
if (pathChangedForRequest && connector.isConnected) {
await _restoreRepeaterPath(
connector,
repeater,
originalPathLength,
originalPath,
);
}
return <Region>{};
}
}
Future<void> _restoreRepeaterPath(
MeshCoreConnector connector,
Contact repeater,
int originalPathLength,
Uint8List originalPath,
) async {
if (originalPathLength < 0) {
await connector.clearContactPath(repeater);
return;
}
await connector.setContactPath(repeater, originalPath, originalPathLength);
}
Set<Region> _parseRegionsResponse(Uint8List frame) {
if (frame.length <= 4) return <Region>{};
final names = utf8
.decode(frame.sublist(4), allowMalformed: true)
.replaceAll('\x00', '')
.split(',');
return names
.map((name) => name.trim())
.where((name) => _validFetchedRegion.hasMatch(name))
.toSet();
}
void _handleAddRegion(Region region, BuildContext context) {
Navigator.pop(context);
_regionStore.addRegion(region);
_loadRegions();
}
Widget _buildRegionTile(BuildContext context, Region region) {
final scheme = Theme.of(context).colorScheme;
return MeshCard(
key: ValueKey(region),
padding: const EdgeInsets.only(left: 14, right: 4),
child: Row(
children: [
const Icon(Icons.landscape, color: MeshPalette.blue),
const SizedBox(width: 12),
Expanded(
child: Text(
region,
style: Theme.of(context).textTheme.bodyMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
tooltip: context.l10n.settings_deleteRegion,
icon: Icon(Icons.delete_outline, color: scheme.error),
onPressed: () => _confirmDelete(context, region),
),
],
),
);
}
void _confirmDelete(BuildContext context, Region region) {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(context.l10n.settings_deleteRegion),
content: Text(context.l10n.settings_deleteRegionConfirm(region)),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text(context.l10n.common_cancel),
),
TextButton(
onPressed: () async {
final connector = context.read<MeshCoreConnector>();
Navigator.pop(dialogContext);
await _regionStore.removeRegion(region);
// Deleting a region clears it from any channels that used it;
// refresh the connector's in-memory channel regions to match.
await connector.loadChannelSettings();
_loadRegions();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.settings_regionDeleted)),
);
},
child: Text(
context.l10n.common_delete,
style: TextStyle(
color: Theme.of(dialogContext).colorScheme.error,
),
),
),
],
),
);
}
}
+9
View File
@@ -19,6 +19,7 @@ import 'app_debug_log_screen.dart';
import 'ble_debug_log_screen.dart';
import '../widgets/radio_stats_entry.dart';
import '../widgets/sync_progress_overlay.dart';
import 'region_management_screen.dart';
/// Convert device coding-rate value (1-4 on some firmware, 5-8 on others)
/// to the UI enum range (always 5-8).
@@ -449,6 +450,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
onTap: () => _showRadioSettings(context, connector),
),
const Divider(height: 1, indent: 16),
_tappableTile(
context,
icon: Icons.landscape,
title: l10n.settings_regionSettings,
subtitle: l10n.settings_regionSettingsSubtitle,
onTap: () => pushRegionManagementScreen(context),
),
const Divider(height: 1, indent: 16),
_tappableTile(
context,
icon: Icons.sensors_outlined,