Add region management

This adds region management: the user can manage a list of available regions
and for each channel pick a region from that list to apply to messages.

Region discovery from nearby repeaters will be done in a separate PR.

This is a part of the work needed for #120.
This commit is contained in:
Stephan Rodemeier
2026-04-05 21:35:39 +02:00
parent 0757c8e53a
commit 0e074fd806
33 changed files with 1653 additions and 68 deletions
+190 -36
View File
@@ -5,6 +5,9 @@ import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:latlong2/latlong.dart';
import 'package:meshcore_open/screens/region_management_screen.dart';
import 'package:meshcore_open/storage/region_store.dart';
import 'package:meshcore_open/widgets/adaptive_app_bar_title.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
@@ -50,6 +53,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
ChannelMessage? _replyingToMessage;
final Map<String, GlobalKey> _messageKeys = {};
bool _isLoadingOlder = false;
Region region = '';
MeshCoreConnector? _connector;
DateTime? _lastChannelSendAt;
@@ -60,12 +64,17 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
super.initState();
_textFieldFocusNode.addListener(_onTextFieldFocusChange);
_scrollController.onScrollNearTop = _loadOlderMessages;
region = context.read<MeshCoreConnector>().getChannelRegion(
widget.channel.index,
);
SchedulerBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final connector = context.read<MeshCoreConnector>();
final settings = context.read<AppSettingsService>().settings;
final idx = widget.channel.index;
final unread = connector.getUnreadCountForChannelIndex(idx);
ChannelMessage? anchor;
if (settings.jumpToOldestUnread && unread > 0) {
anchor = _findOldestUnreadChannelAnchor(
@@ -166,47 +175,81 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
@override
Widget build(BuildContext context) {
final connector = context.watch<MeshCoreConnector>();
// Determine icon and colors based on channel type
IconData icon = Icons.lock;
Color iconColor = Colors.blue;
Color bgColor = Colors.blue.withValues(alpha: 0.2);
// TODO(clauwn): add community handling
final isCommunityChannel = false;
final isCommunityPublic = false;
if (isCommunityChannel) {
iconColor = Colors.purple;
bgColor = Colors.purple.withValues(alpha: 0.2);
icon = isCommunityPublic ? Icons.groups : Icons.tag;
} else if (widget.channel.isPublicChannel) {
icon = Icons.public;
iconColor = Colors.green;
bgColor = Colors.green.withValues(alpha: 0.2);
} else if (widget.channel.isHashtagChannel) {
icon = Icons.tag;
}
final regionHeader = region != ''
? context.l10n.channels_regionSetTo(region)
: context.l10n.channels_regionNotSet;
return Scaffold(
appBar: AppBar(
title: Row(
children: [
Icon(
widget.channel.isPublicChannel ? Icons.public : Icons.tag,
size: 20,
),
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),
),
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(
onTap: () => openRegionSelectDialog(widget.channel),
child: Row(
children: [
CircleAvatar(
backgroundColor: bgColor,
child: Icon(icon, color: iconColor),
),
),
],
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Consumer<MeshCoreConnector>(
builder: (context, connector, _) {
return Text(
widget.channel.name.isEmpty
? context.l10n.channels_channelIndex(
widget.channel.index,
)
: widget.channel.name,
style: const TextStyle(fontSize: 16),
);
},
),
Consumer<MeshCoreConnector>(
builder: (context, connector, _) {
final unreadCount = connector
.getUnreadCountForChannelIndex(
widget.channel.index,
);
return Text(
'$regionHeader${context.l10n.chat_unread(unreadCount)}',
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12),
);
},
),
],
),
),
],
),
),
centerTitle: false,
titleSpacing: 0,
actions: [
const RadioStatsIconButton(),
PopupMenuButton<String>(
@@ -1341,6 +1384,117 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
.join(',');
}
void openRegionSelectDialog(Channel channel) async {
await showDialog(
context: context,
builder: (BuildContext context) => _RegionSelectDialog(channel: channel),
);
if (context.mounted) {
await _connector?.loadChannelSettings();
setState(() {
region = _connector?.getChannelRegion(channel.index) ?? '';
});
}
}
}
class _RegionSelectDialog extends StatefulWidget {
final Channel channel;
const _RegionSelectDialog({required this.channel});
@override
_RegionSelectDialogState createState() => _RegionSelectDialogState();
}
class _RegionSelectDialogState extends State<_RegionSelectDialog> {
final RegionStore regionStore = RegionStore();
List<Region> regions = [];
int selectedIndex = 0;
@override
void initState() {
super.initState();
loadRegions();
}
void loadRegions() {
setState(() {
regions = regionStore.loadRegions();
Region channelRegion = context.read<MeshCoreConnector>().getChannelRegion(
widget.channel.index,
);
selectedIndex = regions.indexOf(channelRegion);
});
}
@override
Widget build(BuildContext context) {
return Dialog(
child: Padding(
padding: EdgeInsets.all(8.0),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
AppBar(
backgroundColor: Colors.transparent,
title: AdaptiveAppBarTitle(
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,
'',
);
if (context.mounted) {
Navigator.pop(context);
}
},
),
IconButton(
tooltip: context.l10n.settings_regionSettingsSubtitle,
icon: const Icon(Icons.settings),
onPressed: () async {
await pushRegionManagementScreen(context);
loadRegions();
},
),
],
),
SizedBox(height: 15),
Expanded(
child: ListView.builder(
itemCount: regions.length,
itemBuilder: (context, index) => ListTile(
title: Text(regions[index]),
tileColor: selectedIndex == index
? Colors.blue.withValues(alpha: 0.2)
: null,
onTap: () {
context.read<MeshCoreConnector>().setChannelRegion(
widget.channel.index,
regions[index],
);
if (context.mounted) {
Navigator.pop(context);
}
},
),
),
),
],
),
),
);
}
}
class _SwipeReplyBubble extends StatefulWidget {
+11 -15
View File
@@ -1,8 +1,8 @@
import 'dart:async';
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:meshcore_open/storage/channel_message_store.dart';
import 'package:meshcore_open/utils/platform_info.dart';
import 'package:meshcore_open/widgets/app_bar.dart';
@@ -380,10 +380,15 @@ class _ChannelsScreenState extends State<ChannelsScreen>
isCommunityChannel && _isCommunityPublicChannel(channel, community);
// Determine icon and colors based on channel type
IconData icon;
Color iconColor;
Color bgColor;
String subtitle;
IconData icon = Icons.lock;
Color iconColor = Colors.blue;
Color bgColor = Colors.blue.withValues(alpha: 0.2);
String region = connector.hasChannelRegion(channel.index)
? context.l10n.channels_regionSetTo(
connector.getChannelRegion(channel.index),
)
: context.l10n.channels_regionNotSet;
String subtitle = region;
if (isCommunityChannel) {
// Community channel styling
@@ -402,17 +407,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
icon = Icons.public;
iconColor = Colors.green;
bgColor = Colors.green.withValues(alpha: 0.2);
subtitle = context.l10n.channels_publicChannel;
} else if (channel.name.startsWith('#')) {
} else if (channel.isHashtagChannel) {
icon = Icons.tag;
iconColor = Colors.blue;
bgColor = Colors.blue.withValues(alpha: 0.2);
subtitle = context.l10n.channels_hashtagChannel;
} else {
icon = Icons.lock;
iconColor = Colors.blue;
bgColor = Colors.blue.withValues(alpha: 0.2);
subtitle = context.l10n.channels_privateChannel;
}
return Card(
+160
View File
@@ -0,0 +1,160 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:meshcore_open/connector/meshcore_connector.dart';
import 'package:meshcore_open/l10n/l10n.dart';
import 'package:meshcore_open/storage/region_store.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> {
final RegionStore _regionStore = RegionStore();
List<Region> _regions = [];
String region = '';
@override
void initState() {
super.initState();
final connector = context.read<MeshCoreConnector>();
_regionStore.setPublicKeyHex = connector.selfPublicKeyHex;
_loadRegions();
}
void _loadRegions() {
context.read<MeshCoreConnector>().loadChannelSettings();
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),
),
],
),
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(text: region);
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),
),
],
),
);
}
void _handleAddRegion(Region region, BuildContext context) {
Navigator.pop(context);
_regionStore.addRegion(region);
_loadRegions();
}
Widget _buildRegionTile(BuildContext context, Region region) {
return Card(
key: ValueKey(region),
child: ListTile(
dense: false,
title: Text(region),
trailing: IconButton(
icon: Icon(Icons.delete_outline),
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 {
Navigator.pop(dialogContext);
await _regionStore.removeRegion(region);
_loadRegions();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.settings_regionDeleted)),
);
},
child: Text(
context.l10n.common_delete,
style: const TextStyle(color: Colors.red),
),
),
],
),
);
}
}
+9
View File
@@ -15,6 +15,7 @@ import 'app_settings_screen.dart';
import 'app_debug_log_screen.dart';
import 'ble_debug_log_screen.dart';
import '../widgets/radio_stats_entry.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).
@@ -287,6 +288,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
onTap: () => _showRadioSettings(context, connector),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.landscape),
title: Text(l10n.settings_regionSettings),
subtitle: Text(l10n.settings_regionSettingsSubtitle),
trailing: const Icon(Icons.chevron_right),
onTap: () => pushRegionManagementScreen(context),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.sensors_outlined),
title: Text(l10n.radioStats_settingsTile),