mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-07-18 23:00:53 +10:00
Merge branch 'dev' into update-pacox-multibyte
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1457,6 +1457,9 @@ class _MessageBubble extends StatelessWidget {
|
||||
color: textColor.withValues(alpha: 0.72),
|
||||
fontSize: bodyFontSize * textScale,
|
||||
),
|
||||
onSecondaryTap: PlatformInfo.isDesktop
|
||||
? onLongPress
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user