meshcore-open/lib/screens/channels_screen.dart
zach ad911a1d80 Add advanced path management, debug logging, and fix channel sync
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
2026-01-02 14:22:39 -07:00

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