mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-04-20 22:13:48 +00:00
New features: - In-app debug log viewer with copy/clear functionality - Advanced path management UI with history and custom path builder - Battery indicator widget with voltage/percentage toggle - Contact/channel filtering and sorting improvements - Repeater command ACK tracking with path history integration Fixes: - Switch channel sync from parallel to sequential to prevent timeouts - Preserve path overrides when contacts refresh from device - Fix ACK hash computation for SMAZ-encoded messages - Proper cleanup of pending operations on disconnect
782 lines
27 KiB
Dart
782 lines
27 KiB
Dart
import 'dart:async';
|
|
import 'dart:math';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../connector/meshcore_connector.dart';
|
|
import '../models/channel.dart';
|
|
import '../utils/dialog_utils.dart';
|
|
import '../utils/disconnect_navigation_mixin.dart';
|
|
import '../utils/route_transitions.dart';
|
|
import '../widgets/battery_indicator.dart';
|
|
import '../widgets/list_filter_widget.dart';
|
|
import '../widgets/empty_state.dart';
|
|
import '../widgets/quick_switch_bar.dart';
|
|
import '../widgets/unread_badge.dart';
|
|
import 'channel_chat_screen.dart';
|
|
import 'contacts_screen.dart';
|
|
import 'map_screen.dart';
|
|
import 'settings_screen.dart';
|
|
|
|
enum ChannelSortOption {
|
|
manual,
|
|
name,
|
|
latestMessages,
|
|
unread,
|
|
}
|
|
|
|
class ChannelsScreen extends StatefulWidget {
|
|
final bool hideBackButton;
|
|
|
|
const ChannelsScreen({
|
|
super.key,
|
|
this.hideBackButton = false,
|
|
});
|
|
|
|
@override
|
|
State<ChannelsScreen> createState() => _ChannelsScreenState();
|
|
}
|
|
|
|
class _ChannelsScreenState extends State<ChannelsScreen>
|
|
with DisconnectNavigationMixin {
|
|
final TextEditingController _searchController = TextEditingController();
|
|
String _searchQuery = '';
|
|
Timer? _searchDebounce;
|
|
ChannelSortOption _sortOption = ChannelSortOption.manual;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
context.read<MeshCoreConnector>().getChannels();
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_searchDebounce?.cancel();
|
|
_searchController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final connector = context.watch<MeshCoreConnector>();
|
|
|
|
// Auto-navigate back to scanner if disconnected
|
|
if (!checkConnectionAndNavigate(connector)) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
final allowBack = !connector.isConnected;
|
|
|
|
return PopScope(
|
|
canPop: allowBack,
|
|
child: Scaffold(
|
|
appBar: AppBar(
|
|
leading: BatteryIndicator(connector: connector),
|
|
title: const Text('Channels'),
|
|
centerTitle: true,
|
|
automaticallyImplyLeading: false,
|
|
actions: [
|
|
IconButton(
|
|
icon: const Icon(Icons.bluetooth_disabled),
|
|
tooltip: 'Disconnect',
|
|
onPressed: () => _disconnect(context),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.tune),
|
|
tooltip: 'Settings',
|
|
onPressed: () => Navigator.push(
|
|
context,
|
|
MaterialPageRoute(builder: (context) => const SettingsScreen()),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
body: RefreshIndicator(
|
|
onRefresh: () async {
|
|
await context.read<MeshCoreConnector>().getChannels();
|
|
},
|
|
child: () {
|
|
if (connector.isLoadingChannels) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
final channels = connector.channels;
|
|
|
|
if (channels.isEmpty) {
|
|
return ListView(
|
|
children: [
|
|
SizedBox(
|
|
height: MediaQuery.of(context).size.height - 200,
|
|
child: EmptyState(
|
|
icon: Icons.tag,
|
|
title: 'No channels configured',
|
|
action: FilledButton.icon(
|
|
onPressed: () => _addPublicChannel(context, connector),
|
|
icon: const Icon(Icons.public),
|
|
label: const Text('Add Public Channel'),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
final filteredChannels = _filterAndSortChannels(channels, connector);
|
|
|
|
return Column(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.all(8.0),
|
|
child: TextField(
|
|
controller: _searchController,
|
|
decoration: InputDecoration(
|
|
hintText: 'Search channels...',
|
|
prefixIcon: const Icon(Icons.search),
|
|
suffixIcon: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (_searchQuery.isNotEmpty)
|
|
IconButton(
|
|
icon: const Icon(Icons.clear),
|
|
onPressed: () {
|
|
_searchController.clear();
|
|
setState(() {
|
|
_searchQuery = '';
|
|
});
|
|
},
|
|
),
|
|
_buildFilterButton(),
|
|
],
|
|
),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
contentPadding:
|
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
),
|
|
onChanged: (value) {
|
|
_searchDebounce?.cancel();
|
|
_searchDebounce = Timer(const Duration(milliseconds: 300), () {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_searchQuery = value.toLowerCase();
|
|
});
|
|
});
|
|
},
|
|
),
|
|
),
|
|
Expanded(
|
|
child: filteredChannels.isEmpty
|
|
? ListView(
|
|
children: [
|
|
SizedBox(
|
|
height: MediaQuery.of(context).size.height - 300,
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(Icons.search_off, size: 64, color: Colors.grey[400]),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'No channels found',
|
|
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
)
|
|
: (_sortOption == ChannelSortOption.manual && _searchQuery.isEmpty)
|
|
? ReorderableListView.builder(
|
|
padding: const EdgeInsets.only(
|
|
left: 16,
|
|
right: 16,
|
|
top: 8,
|
|
bottom: 88,
|
|
),
|
|
buildDefaultDragHandles: false,
|
|
itemCount: filteredChannels.length,
|
|
onReorder: (oldIndex, newIndex) {
|
|
if (newIndex > oldIndex) newIndex -= 1;
|
|
final reordered = List<Channel>.from(filteredChannels);
|
|
final item = reordered.removeAt(oldIndex);
|
|
reordered.insert(newIndex, item);
|
|
unawaited(
|
|
connector.setChannelOrder(
|
|
reordered.map((c) => c.index).toList(),
|
|
),
|
|
);
|
|
},
|
|
itemBuilder: (context, index) {
|
|
final channel = filteredChannels[index];
|
|
return _buildChannelTile(
|
|
context,
|
|
connector,
|
|
channel,
|
|
showDragHandle: true,
|
|
dragIndex: index,
|
|
);
|
|
},
|
|
)
|
|
: ListView.builder(
|
|
padding: const EdgeInsets.only(
|
|
left: 16,
|
|
right: 16,
|
|
top: 8,
|
|
bottom: 88,
|
|
),
|
|
itemCount: filteredChannels.length,
|
|
itemBuilder: (context, index) {
|
|
final channel = filteredChannels[index];
|
|
return _buildChannelTile(context, connector, channel);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}(),
|
|
),
|
|
floatingActionButton: FloatingActionButton(
|
|
onPressed: () => _showAddChannelDialog(context),
|
|
child: const Icon(Icons.add),
|
|
),
|
|
bottomNavigationBar: SafeArea(
|
|
top: false,
|
|
child: QuickSwitchBar(
|
|
selectedIndex: 1,
|
|
onDestinationSelected: (index) => _handleQuickSwitch(index, context),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildChannelTile(
|
|
BuildContext context,
|
|
MeshCoreConnector connector,
|
|
Channel channel,
|
|
{
|
|
bool showDragHandle = false,
|
|
int? dragIndex,
|
|
}
|
|
) {
|
|
final unreadCount = connector.getUnreadCountForChannel(channel);
|
|
return Card(
|
|
key: ValueKey('channel_${channel.index}'),
|
|
margin: const EdgeInsets.only(bottom: 12),
|
|
child: ListTile(
|
|
dense: true,
|
|
minVerticalPadding: 0,
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
|
visualDensity: const VisualDensity(vertical: -2),
|
|
leading: CircleAvatar(
|
|
backgroundColor: channel.isPublicChannel
|
|
? Colors.green.withValues(alpha: 0.2)
|
|
: Colors.blue.withValues(alpha: 0.2),
|
|
child: Icon(
|
|
channel.isPublicChannel
|
|
? Icons.public
|
|
: channel.name.startsWith('#')
|
|
? Icons.tag
|
|
: Icons.lock,
|
|
color: channel.isPublicChannel ? Colors.green : Colors.blue,
|
|
),
|
|
),
|
|
title: Text(
|
|
channel.name.isEmpty ? 'Channel ${channel.index}' : channel.name,
|
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
|
),
|
|
subtitle: Text(
|
|
channel.name.startsWith('#')
|
|
? 'Hashtag channel'
|
|
: channel.isPublicChannel
|
|
? 'Public channel'
|
|
: 'Private channel',
|
|
),
|
|
trailing: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (unreadCount > 0) ...[
|
|
UnreadBadge(count: unreadCount),
|
|
const SizedBox(width: 4),
|
|
],
|
|
if (showDragHandle && dragIndex != null)
|
|
ReorderableDelayedDragStartListener(
|
|
index: dragIndex,
|
|
child: Icon(
|
|
Icons.drag_handle,
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
onTap: () async {
|
|
connector.markChannelRead(channel.index);
|
|
await Future.delayed(const Duration(milliseconds: 50));
|
|
if (context.mounted) {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => ChannelChatScreen(channel: channel),
|
|
),
|
|
);
|
|
}
|
|
},
|
|
onLongPress: () => _showChannelActions(context, connector, channel),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showChannelActions(
|
|
BuildContext context,
|
|
MeshCoreConnector connector,
|
|
Channel channel,
|
|
) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
builder: (context) => SafeArea(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
ListTile(
|
|
leading: const Icon(Icons.edit_outlined),
|
|
title: const Text('Edit channel'),
|
|
onTap: () async {
|
|
Navigator.pop(context);
|
|
await Future.delayed(const Duration(milliseconds: 100));
|
|
if (context.mounted) {
|
|
_showEditChannelDialog(context, connector, channel);
|
|
}
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.delete_outline, color: Colors.red),
|
|
title: const Text('Delete channel', style: TextStyle(color: Colors.red)),
|
|
onTap: () async {
|
|
Navigator.pop(context);
|
|
await Future.delayed(const Duration(milliseconds: 100));
|
|
if (context.mounted) {
|
|
_confirmDeleteChannel(context, connector, channel);
|
|
}
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _handleQuickSwitch(int index, BuildContext context) {
|
|
if (index == 1) return;
|
|
switch (index) {
|
|
case 0:
|
|
Navigator.pushReplacement(
|
|
context,
|
|
buildQuickSwitchRoute(
|
|
const ContactsScreen(hideBackButton: true),
|
|
),
|
|
);
|
|
break;
|
|
case 2:
|
|
Navigator.pushReplacement(
|
|
context,
|
|
buildQuickSwitchRoute(
|
|
const MapScreen(hideBackButton: true),
|
|
),
|
|
);
|
|
break;
|
|
}
|
|
}
|
|
|
|
Future<void> _disconnect(BuildContext context) async {
|
|
final connector = context.read<MeshCoreConnector>();
|
|
await showDisconnectDialog(context, connector);
|
|
}
|
|
|
|
Widget _buildFilterButton() {
|
|
const actionSortManual = 0;
|
|
const actionSortName = 1;
|
|
const actionSortLatest = 2;
|
|
const actionSortUnread = 3;
|
|
|
|
return SortFilterMenu(
|
|
sections: [
|
|
SortFilterMenuSection(
|
|
title: 'Sort by',
|
|
options: [
|
|
SortFilterMenuOption(
|
|
value: actionSortManual,
|
|
label: 'Manual',
|
|
checked: _sortOption == ChannelSortOption.manual,
|
|
),
|
|
SortFilterMenuOption(
|
|
value: actionSortName,
|
|
label: 'A-Z',
|
|
checked: _sortOption == ChannelSortOption.name,
|
|
),
|
|
SortFilterMenuOption(
|
|
value: actionSortLatest,
|
|
label: 'Latest messages',
|
|
checked: _sortOption == ChannelSortOption.latestMessages,
|
|
),
|
|
SortFilterMenuOption(
|
|
value: actionSortUnread,
|
|
label: 'Unread',
|
|
checked: _sortOption == ChannelSortOption.unread,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
onSelected: (action) {
|
|
setState(() {
|
|
switch (action) {
|
|
case actionSortManual:
|
|
_sortOption = ChannelSortOption.manual;
|
|
break;
|
|
case actionSortLatest:
|
|
_sortOption = ChannelSortOption.latestMessages;
|
|
break;
|
|
case actionSortUnread:
|
|
_sortOption = ChannelSortOption.unread;
|
|
break;
|
|
case actionSortName:
|
|
default:
|
|
_sortOption = ChannelSortOption.name;
|
|
break;
|
|
}
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
List<Channel> _filterAndSortChannels(
|
|
List<Channel> channels,
|
|
MeshCoreConnector connector,
|
|
) {
|
|
var filtered = channels.where((channel) {
|
|
if (_searchQuery.isEmpty) return true;
|
|
final label = _normalizeChannelName(channel);
|
|
return label.toLowerCase().contains(_searchQuery);
|
|
}).toList();
|
|
|
|
int compareByName(Channel a, Channel b) {
|
|
final nameA = _normalizeChannelName(a);
|
|
final nameB = _normalizeChannelName(b);
|
|
return nameA.toLowerCase().compareTo(nameB.toLowerCase());
|
|
}
|
|
|
|
switch (_sortOption) {
|
|
case ChannelSortOption.manual:
|
|
break;
|
|
case ChannelSortOption.latestMessages:
|
|
filtered.sort((a, b) {
|
|
final aMessages = connector.getChannelMessages(a);
|
|
final bMessages = connector.getChannelMessages(b);
|
|
final aLast = aMessages.isEmpty ? DateTime(1970) : aMessages.last.timestamp;
|
|
final bLast = bMessages.isEmpty ? DateTime(1970) : bMessages.last.timestamp;
|
|
final timeCompare = bLast.compareTo(aLast);
|
|
if (timeCompare != 0) return timeCompare;
|
|
return compareByName(a, b);
|
|
});
|
|
break;
|
|
case ChannelSortOption.unread:
|
|
filtered.sort((a, b) {
|
|
final aUnread = connector.getUnreadCountForChannel(a);
|
|
final bUnread = connector.getUnreadCountForChannel(b);
|
|
final unreadCompare = bUnread.compareTo(aUnread);
|
|
if (unreadCompare != 0) return unreadCompare;
|
|
return compareByName(a, b);
|
|
});
|
|
break;
|
|
case ChannelSortOption.name:
|
|
filtered.sort(compareByName);
|
|
break;
|
|
}
|
|
|
|
return filtered;
|
|
}
|
|
|
|
String _normalizeChannelName(Channel channel) {
|
|
if (channel.name.isEmpty) return 'Channel ${channel.index}';
|
|
final trimmed = channel.name.trim();
|
|
if (trimmed.startsWith('#') && trimmed.length > 1) {
|
|
return trimmed.substring(1);
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
void _showAddChannelDialog(BuildContext context) {
|
|
final connector = context.read<MeshCoreConnector>();
|
|
final nameController = TextEditingController();
|
|
final pskController = TextEditingController();
|
|
final maxChannels = connector.maxChannels;
|
|
int selectedIndex = _findNextAvailableIndex(connector.channels, maxChannels);
|
|
bool usePublicPsk = false;
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => StatefulBuilder(
|
|
builder: (context, setDialogState) => AlertDialog(
|
|
title: const Text('Add Channel'),
|
|
content: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
DropdownButtonFormField<int>(
|
|
initialValue: selectedIndex,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Channel Index',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
items: List.generate(maxChannels, (i) => i)
|
|
.map((i) => DropdownMenuItem(
|
|
value: i,
|
|
child: Text('Channel $i'),
|
|
))
|
|
.toList(),
|
|
onChanged: (value) {
|
|
if (value != null) {
|
|
setDialogState(() => selectedIndex = value);
|
|
}
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: nameController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Channel Name',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
maxLength: 31,
|
|
),
|
|
const SizedBox(height: 8),
|
|
CheckboxListTile(
|
|
title: const Text('Use Public Channel'),
|
|
subtitle: const Text('Standard public PSK'),
|
|
value: usePublicPsk,
|
|
onChanged: (value) {
|
|
setDialogState(() {
|
|
usePublicPsk = value ?? false;
|
|
if (usePublicPsk) {
|
|
nameController.text = 'Public';
|
|
pskController.text = Channel.publicChannelPsk;
|
|
} else {
|
|
pskController.clear();
|
|
}
|
|
});
|
|
},
|
|
),
|
|
if (!usePublicPsk) ...[
|
|
const SizedBox(height: 8),
|
|
TextField(
|
|
controller: pskController,
|
|
decoration: InputDecoration(
|
|
labelText: 'PSK (Hex)',
|
|
border: const OutlineInputBorder(),
|
|
suffixIcon: IconButton(
|
|
icon: const Icon(Icons.casino),
|
|
tooltip: 'Generate random PSK',
|
|
onPressed: () {
|
|
final random = Random.secure();
|
|
final bytes = Uint8List(16);
|
|
for (int i = 0; i < 16; i++) {
|
|
bytes[i] = random.nextInt(256);
|
|
}
|
|
pskController.text = Channel.formatPskHex(bytes);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () {
|
|
final name = nameController.text.trim();
|
|
final pskHex = usePublicPsk
|
|
? Channel.publicChannelPsk
|
|
: pskController.text.trim();
|
|
|
|
if (name.isEmpty) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Please enter a channel name')),
|
|
);
|
|
return;
|
|
}
|
|
|
|
Uint8List psk;
|
|
try {
|
|
psk = Channel.parsePskHex(pskHex);
|
|
} on FormatException {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('PSK must be 32 hex characters')),
|
|
);
|
|
return;
|
|
}
|
|
|
|
Navigator.pop(context);
|
|
connector.setChannel(selectedIndex, name, psk);
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Channel "$name" added')),
|
|
);
|
|
}
|
|
},
|
|
child: const Text('Add'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showEditChannelDialog(
|
|
BuildContext context,
|
|
MeshCoreConnector connector,
|
|
Channel channel,
|
|
) {
|
|
final nameController = TextEditingController(text: channel.name);
|
|
final pskController = TextEditingController(text: channel.pskHex);
|
|
bool smazEnabled = connector.isChannelSmazEnabled(channel.index);
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => StatefulBuilder(
|
|
builder: (context, setState) => AlertDialog(
|
|
title: Text('Edit Channel ${channel.index}'),
|
|
content: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
TextField(
|
|
controller: nameController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Channel Name',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
maxLength: 31,
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: pskController,
|
|
decoration: InputDecoration(
|
|
labelText: 'PSK (Hex)',
|
|
border: const OutlineInputBorder(),
|
|
suffixIcon: IconButton(
|
|
icon: const Icon(Icons.casino),
|
|
tooltip: 'Generate random PSK',
|
|
onPressed: () {
|
|
final random = Random.secure();
|
|
final bytes = Uint8List(16);
|
|
for (int i = 0; i < 16; i++) {
|
|
bytes[i] = random.nextInt(256);
|
|
}
|
|
pskController.text = Channel.formatPskHex(bytes);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
SwitchListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: const Text('SMAZ compression'),
|
|
value: smazEnabled,
|
|
onChanged: (value) => setState(() => smazEnabled = value),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () {
|
|
final name = nameController.text.trim();
|
|
final pskHex = pskController.text.trim();
|
|
|
|
Uint8List psk;
|
|
try {
|
|
psk = Channel.parsePskHex(pskHex);
|
|
} on FormatException {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('PSK must be 32 hex characters')),
|
|
);
|
|
return;
|
|
}
|
|
|
|
Navigator.pop(context);
|
|
connector.setChannel(channel.index, name, psk);
|
|
connector.setChannelSmazEnabled(channel.index, smazEnabled);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Channel "$name" updated')),
|
|
);
|
|
},
|
|
child: const Text('Save'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _confirmDeleteChannel(
|
|
BuildContext context,
|
|
MeshCoreConnector connector,
|
|
Channel channel,
|
|
) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('Delete Channel'),
|
|
content: Text('Delete "${channel.name}"? This cannot be undone.'),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Cancel'),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
connector.deleteChannel(channel.index);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Channel "${channel.name}" deleted')),
|
|
);
|
|
},
|
|
child: const Text('Delete', style: TextStyle(color: Colors.red)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _addPublicChannel(BuildContext context, MeshCoreConnector connector) {
|
|
final psk = Channel.parsePskHex(Channel.publicChannelPsk);
|
|
connector.setChannel(0, 'Public', psk);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Public channel added')),
|
|
);
|
|
}
|
|
|
|
int _findNextAvailableIndex(List<Channel> channels, int maxChannels) {
|
|
final usedIndices = channels.map((c) => c.index).toSet();
|
|
for (int i = 0; i < maxChannels; i++) {
|
|
if (!usedIndices.contains(i)) return i;
|
|
}
|
|
return 0;
|
|
}
|
|
}
|