mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-20 09:25:34 +10:00
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
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
|
||||
class BatteryUi {
|
||||
final IconData icon;
|
||||
final Color? color;
|
||||
const BatteryUi(this.icon, this.color);
|
||||
}
|
||||
|
||||
BatteryUi batteryUiForPercent(int? percent) {
|
||||
if (percent == null) {
|
||||
return const BatteryUi(Icons.battery_unknown, Colors.grey);
|
||||
}
|
||||
|
||||
final p = percent.clamp(0, 100);
|
||||
|
||||
return switch (p) {
|
||||
<= 5 => const BatteryUi(Icons.battery_alert, Colors.redAccent),
|
||||
<= 15 => const BatteryUi(Icons.battery_0_bar, Colors.redAccent),
|
||||
<= 30 => const BatteryUi(Icons.battery_1_bar, Colors.orange),
|
||||
<= 45 => const BatteryUi(Icons.battery_2_bar, Colors.amber),
|
||||
<= 60 => const BatteryUi(Icons.battery_3_bar, Colors.lightGreen),
|
||||
<= 80 => const BatteryUi(Icons.battery_5_bar, Colors.green),
|
||||
_ => const BatteryUi(Icons.battery_full, Colors.green),
|
||||
};
|
||||
}
|
||||
|
||||
class BatteryIndicator extends StatefulWidget {
|
||||
final MeshCoreConnector connector;
|
||||
|
||||
const BatteryIndicator({
|
||||
super.key,
|
||||
required this.connector,
|
||||
});
|
||||
|
||||
@override
|
||||
State<BatteryIndicator> createState() => _BatteryIndicatorState();
|
||||
}
|
||||
|
||||
class _BatteryIndicatorState extends State<BatteryIndicator> {
|
||||
bool _showBatteryVoltage = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final percent = widget.connector.batteryPercent;
|
||||
final millivolts = widget.connector.batteryMillivolts;
|
||||
|
||||
if (millivolts == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final String displayText;
|
||||
if (_showBatteryVoltage) {
|
||||
displayText = '${(millivolts / 1000.0).toStringAsFixed(2)}V';
|
||||
} else {
|
||||
displayText = percent != null ? '$percent%' : '—';
|
||||
}
|
||||
|
||||
final batteryUi = batteryUiForPercent(percent);
|
||||
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_showBatteryVoltage = !_showBatteryVoltage;
|
||||
});
|
||||
},
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(batteryUi.icon, size: 20, color: batteryUi.color),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
displayText,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: batteryUi.color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
enum ContactSortOption {
|
||||
lastSeen,
|
||||
recentMessages,
|
||||
name,
|
||||
}
|
||||
|
||||
enum ContactTypeFilter {
|
||||
all,
|
||||
users,
|
||||
repeaters,
|
||||
rooms,
|
||||
}
|
||||
|
||||
class SortFilterMenuOption {
|
||||
final int value;
|
||||
final String label;
|
||||
final bool? checked;
|
||||
|
||||
const SortFilterMenuOption({
|
||||
required this.value,
|
||||
required this.label,
|
||||
this.checked,
|
||||
});
|
||||
}
|
||||
|
||||
class SortFilterMenuSection {
|
||||
final String title;
|
||||
final List<SortFilterMenuOption> options;
|
||||
|
||||
const SortFilterMenuSection({
|
||||
required this.title,
|
||||
required this.options,
|
||||
});
|
||||
}
|
||||
|
||||
class SortFilterMenu extends StatelessWidget {
|
||||
final List<SortFilterMenuSection> sections;
|
||||
final ValueChanged<int> onSelected;
|
||||
final String tooltip;
|
||||
final Widget icon;
|
||||
|
||||
const SortFilterMenu({
|
||||
super.key,
|
||||
required this.sections,
|
||||
required this.onSelected,
|
||||
this.tooltip = 'Filter and sort',
|
||||
this.icon = const Icon(Icons.filter_list_outlined),
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopupMenuButton<int>(
|
||||
icon: icon,
|
||||
tooltip: tooltip,
|
||||
onSelected: onSelected,
|
||||
itemBuilder: (context) {
|
||||
final theme = Theme.of(context);
|
||||
final labelStyle = theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
);
|
||||
final visibleSections = sections.where((section) => section.options.isNotEmpty).toList();
|
||||
final entries = <PopupMenuEntry<int>>[];
|
||||
for (int i = 0; i < visibleSections.length; i++) {
|
||||
final section = visibleSections[i];
|
||||
entries.add(
|
||||
PopupMenuItem<int>(
|
||||
enabled: false,
|
||||
child: Text(section.title, style: labelStyle),
|
||||
),
|
||||
);
|
||||
for (final option in section.options) {
|
||||
if (option.checked == null) {
|
||||
entries.add(
|
||||
PopupMenuItem<int>(
|
||||
value: option.value,
|
||||
child: Text(option.label),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
entries.add(
|
||||
CheckedPopupMenuItem<int>(
|
||||
value: option.value,
|
||||
checked: option.checked ?? false,
|
||||
child: Text(option.label),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (i < visibleSections.length - 1) {
|
||||
entries.add(const PopupMenuDivider());
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const int _actionSortRecentMessages = 1;
|
||||
const int _actionSortName = 2;
|
||||
const int _actionSortLastSeen = 3;
|
||||
const int _actionFilterAll = 4;
|
||||
const int _actionFilterUsers = 5;
|
||||
const int _actionFilterRepeaters = 6;
|
||||
const int _actionFilterRooms = 7;
|
||||
const int _actionToggleUnreadOnly = 8;
|
||||
const int _actionNewGroup = 9;
|
||||
|
||||
class ContactsFilterMenu extends StatelessWidget {
|
||||
final ContactSortOption sortOption;
|
||||
final ContactTypeFilter typeFilter;
|
||||
final bool showUnreadOnly;
|
||||
final ValueChanged<ContactSortOption> onSortChanged;
|
||||
final ValueChanged<ContactTypeFilter> onTypeFilterChanged;
|
||||
final ValueChanged<bool> onUnreadOnlyChanged;
|
||||
final VoidCallback onNewGroup;
|
||||
|
||||
const ContactsFilterMenu({
|
||||
super.key,
|
||||
required this.sortOption,
|
||||
required this.typeFilter,
|
||||
required this.showUnreadOnly,
|
||||
required this.onSortChanged,
|
||||
required this.onTypeFilterChanged,
|
||||
required this.onUnreadOnlyChanged,
|
||||
required this.onNewGroup,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SortFilterMenu(
|
||||
sections: [
|
||||
SortFilterMenuSection(
|
||||
title: 'Sort by',
|
||||
options: [
|
||||
SortFilterMenuOption(
|
||||
value: _actionSortRecentMessages,
|
||||
label: 'Latest messages',
|
||||
checked: sortOption == ContactSortOption.recentMessages,
|
||||
),
|
||||
SortFilterMenuOption(
|
||||
value: _actionSortLastSeen,
|
||||
label: 'Heard recently',
|
||||
checked: sortOption == ContactSortOption.lastSeen,
|
||||
),
|
||||
SortFilterMenuOption(
|
||||
value: _actionSortName,
|
||||
label: 'A-Z',
|
||||
checked: sortOption == ContactSortOption.name,
|
||||
),
|
||||
],
|
||||
),
|
||||
SortFilterMenuSection(
|
||||
title: 'Filters',
|
||||
options: [
|
||||
SortFilterMenuOption(
|
||||
value: _actionFilterAll,
|
||||
label: 'All',
|
||||
checked: typeFilter == ContactTypeFilter.all,
|
||||
),
|
||||
SortFilterMenuOption(
|
||||
value: _actionFilterUsers,
|
||||
label: 'Users',
|
||||
checked: typeFilter == ContactTypeFilter.users,
|
||||
),
|
||||
SortFilterMenuOption(
|
||||
value: _actionFilterRepeaters,
|
||||
label: 'Repeaters',
|
||||
checked: typeFilter == ContactTypeFilter.repeaters,
|
||||
),
|
||||
SortFilterMenuOption(
|
||||
value: _actionFilterRooms,
|
||||
label: 'Room servers',
|
||||
checked: typeFilter == ContactTypeFilter.rooms,
|
||||
),
|
||||
SortFilterMenuOption(
|
||||
value: _actionToggleUnreadOnly,
|
||||
label: 'Unread only',
|
||||
checked: showUnreadOnly,
|
||||
),
|
||||
const SortFilterMenuOption(
|
||||
value: _actionNewGroup,
|
||||
label: 'New group',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
onSelected: (action) {
|
||||
switch (action) {
|
||||
case _actionSortRecentMessages:
|
||||
onSortChanged(ContactSortOption.recentMessages);
|
||||
break;
|
||||
case _actionSortName:
|
||||
onSortChanged(ContactSortOption.name);
|
||||
break;
|
||||
case _actionSortLastSeen:
|
||||
onSortChanged(ContactSortOption.lastSeen);
|
||||
break;
|
||||
case _actionFilterAll:
|
||||
onTypeFilterChanged(ContactTypeFilter.all);
|
||||
break;
|
||||
case _actionFilterUsers:
|
||||
onTypeFilterChanged(ContactTypeFilter.users);
|
||||
break;
|
||||
case _actionFilterRepeaters:
|
||||
onTypeFilterChanged(ContactTypeFilter.repeaters);
|
||||
break;
|
||||
case _actionFilterRooms:
|
||||
onTypeFilterChanged(ContactTypeFilter.rooms);
|
||||
break;
|
||||
case _actionToggleUnreadOnly:
|
||||
onUnreadOnlyChanged(!showUnreadOnly);
|
||||
break;
|
||||
case _actionNewGroup:
|
||||
onNewGroup();
|
||||
break;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../services/path_history_service.dart';
|
||||
import 'path_selection_dialog.dart';
|
||||
|
||||
class PathManagementDialog {
|
||||
static Future<void> show(
|
||||
BuildContext context, {
|
||||
required Contact contact,
|
||||
String title = 'Path Management',
|
||||
}) {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => _PathManagementDialog(
|
||||
contact: contact,
|
||||
title: title,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PathManagementDialog extends StatelessWidget {
|
||||
final Contact contact;
|
||||
final String title;
|
||||
|
||||
const _PathManagementDialog({
|
||||
required this.contact,
|
||||
required this.title,
|
||||
});
|
||||
|
||||
Contact _resolveContact(MeshCoreConnector connector) {
|
||||
return connector.contacts.firstWhere(
|
||||
(c) => c.publicKeyHex == contact.publicKeyHex,
|
||||
orElse: () => contact,
|
||||
);
|
||||
}
|
||||
|
||||
String _formatRelativeTime(DateTime time) {
|
||||
final diff = DateTime.now().difference(time);
|
||||
if (diff.inSeconds < 60) return 'Just now';
|
||||
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
|
||||
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
||||
return '${diff.inDays}d ago';
|
||||
}
|
||||
|
||||
void _showFullPathDialog(BuildContext context, List<int> pathBytes) {
|
||||
if (pathBytes.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Path details not available yet. Try sending a message to refresh.'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final formattedPath = pathBytes
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
|
||||
.join(',');
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Full Path'),
|
||||
content: SelectableText(formattedPath),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _setCustomPath(
|
||||
BuildContext context,
|
||||
MeshCoreConnector connector,
|
||||
Contact currentContact,
|
||||
) async {
|
||||
if (currentContact.pathLength > 0 && currentContact.path.isEmpty && connector.isConnected) {
|
||||
connector.getContacts();
|
||||
}
|
||||
|
||||
final pathForInput = currentContact.pathIdList;
|
||||
final availableContacts = connector.contacts
|
||||
.where((c) => c.publicKeyHex != currentContact.publicKeyHex)
|
||||
.toList();
|
||||
|
||||
final result = await PathSelectionDialog.show(
|
||||
context,
|
||||
availableContacts: availableContacts,
|
||||
initialPath: pathForInput.isEmpty ? null : pathForInput,
|
||||
title: 'Set Custom Path',
|
||||
currentPathLabel: currentContact.pathLabel,
|
||||
onRefresh: connector.isConnected ? connector.getContacts : null,
|
||||
);
|
||||
|
||||
if (result != null && context.mounted) {
|
||||
await connector.setPathOverride(
|
||||
currentContact,
|
||||
pathLen: result.length,
|
||||
pathBytes: result,
|
||||
);
|
||||
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Path set: ${result.length} ${result.length == 1 ? "hop" : "hops"}'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer2<MeshCoreConnector, PathHistoryService>(
|
||||
builder: (context, connector, pathService, _) {
|
||||
final currentContact = _resolveContact(connector);
|
||||
final paths = pathService.getRecentPaths(currentContact.publicKeyHex);
|
||||
|
||||
return AlertDialog(
|
||||
title: Text(title),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Current path: ${currentContact.pathLabel}',
|
||||
style: const TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (paths.isNotEmpty) ...[
|
||||
const Text(
|
||||
'Recent ACK Paths (tap to use):',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
),
|
||||
if (paths.length >= 100) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.amberAccent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Text(
|
||||
'Path history is full. Remove entries to add new ones.',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
...paths.map((path) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
leading: CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: path.wasFloodDiscovery ? Colors.blue : Colors.green,
|
||||
child: Text(
|
||||
'${path.hopCount}',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
'${path.hopCount} ${path.hopCount == 1 ? 'hop' : 'hops'}',
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(path.timestamp)} • ${path.successCount} successes',
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 16),
|
||||
tooltip: 'Remove path',
|
||||
onPressed: () async {
|
||||
await pathService.removePathRecord(
|
||||
currentContact.publicKeyHex,
|
||||
path.pathBytes,
|
||||
);
|
||||
},
|
||||
),
|
||||
path.wasFloodDiscovery
|
||||
? const Icon(Icons.waves, size: 16, color: Colors.grey)
|
||||
: const Icon(Icons.route, size: 16, color: Colors.grey),
|
||||
],
|
||||
),
|
||||
onLongPress: () => _showFullPathDialog(context, path.pathBytes),
|
||||
onTap: () async {
|
||||
if (path.pathBytes.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Path details not available yet. Try sending a message to refresh.'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final pathBytes = Uint8List.fromList(path.pathBytes);
|
||||
final pathLength = path.pathBytes.length;
|
||||
|
||||
await connector.setPathOverride(
|
||||
currentContact,
|
||||
pathLen: pathLength,
|
||||
pathBytes: pathBytes,
|
||||
);
|
||||
|
||||
if (!context.mounted) return;
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Using ${path.hopCount} ${path.hopCount == 1 ? 'hop' : 'hops'} path'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
const Divider(),
|
||||
] else ...[
|
||||
const Text('No path history yet.\nSend a message to discover paths.'),
|
||||
const Divider(),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Path Actions:',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ListTile(
|
||||
dense: true,
|
||||
leading: const CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: Colors.purple,
|
||||
child: Icon(Icons.edit_road, size: 16),
|
||||
),
|
||||
title: const Text('Set Custom Path', style: TextStyle(fontSize: 14)),
|
||||
subtitle: const Text('Manually specify routing path', style: TextStyle(fontSize: 11)),
|
||||
onTap: () async {
|
||||
await _setCustomPath(context, connector, currentContact);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
dense: true,
|
||||
leading: const CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: Colors.orange,
|
||||
child: Icon(Icons.clear_all, size: 16),
|
||||
),
|
||||
title: const Text('Clear Path', style: TextStyle(fontSize: 14)),
|
||||
subtitle: const Text('Force rediscovery on next send', style: TextStyle(fontSize: 11)),
|
||||
onTap: () async {
|
||||
await connector.clearContactPath(currentContact);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Path cleared. Next message will rediscover route.'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
dense: true,
|
||||
leading: const CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: Colors.blue,
|
||||
child: Icon(Icons.waves, size: 16),
|
||||
),
|
||||
title: const Text('Force Flood Mode', style: TextStyle(fontSize: 14)),
|
||||
subtitle: const Text('Use routing toggle in app bar', style: TextStyle(fontSize: 11)),
|
||||
onTap: () async {
|
||||
await connector.setPathOverride(currentContact, pathLen: -1);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Flood mode enabled. Toggle back via routing icon in app bar.'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/contact.dart';
|
||||
|
||||
class PathSelectionDialog extends StatefulWidget {
|
||||
final List<Contact> availableContacts;
|
||||
final String? initialPath;
|
||||
final String title;
|
||||
final String? currentPathLabel;
|
||||
final VoidCallback? onRefresh;
|
||||
|
||||
const PathSelectionDialog({
|
||||
super.key,
|
||||
required this.availableContacts,
|
||||
this.initialPath,
|
||||
this.title = 'Enter Custom Path',
|
||||
this.currentPathLabel,
|
||||
this.onRefresh,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PathSelectionDialog> createState() => _PathSelectionDialogState();
|
||||
|
||||
static Future<Uint8List?> show(
|
||||
BuildContext context, {
|
||||
required List<Contact> availableContacts,
|
||||
String? initialPath,
|
||||
String title = 'Enter Custom Path',
|
||||
String? currentPathLabel,
|
||||
VoidCallback? onRefresh,
|
||||
}) {
|
||||
return showDialog<Uint8List?>(
|
||||
context: context,
|
||||
builder: (context) => PathSelectionDialog(
|
||||
availableContacts: availableContacts,
|
||||
initialPath: initialPath,
|
||||
title: title,
|
||||
currentPathLabel: currentPathLabel,
|
||||
onRefresh: onRefresh,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PathSelectionDialogState extends State<PathSelectionDialog> {
|
||||
late TextEditingController _controller;
|
||||
final List<Contact> _selectedContacts = [];
|
||||
List<Contact> _validContacts = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(text: widget.initialPath ?? '');
|
||||
_filterValidContacts();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(PathSelectionDialog oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.availableContacts != oldWidget.availableContacts) {
|
||||
_filterValidContacts();
|
||||
}
|
||||
}
|
||||
|
||||
void _filterValidContacts() {
|
||||
_validContacts = widget.availableContacts
|
||||
.where((c) => c.type == 2 || c.type == 3)
|
||||
.toList();
|
||||
}
|
||||
|
||||
void _updateTextFromContacts() {
|
||||
final pathParts = _selectedContacts.map((contact) {
|
||||
if (contact.publicKeyHex.length >= 2) {
|
||||
return contact.publicKeyHex.substring(0, 2);
|
||||
}
|
||||
return '';
|
||||
}).where((s) => s.isNotEmpty).toList();
|
||||
|
||||
_controller.text = pathParts.join(',');
|
||||
}
|
||||
|
||||
void _toggleContact(Contact contact) {
|
||||
setState(() {
|
||||
if (_selectedContacts.contains(contact)) {
|
||||
_selectedContacts.remove(contact);
|
||||
} else {
|
||||
_selectedContacts.add(contact);
|
||||
}
|
||||
_updateTextFromContacts();
|
||||
});
|
||||
}
|
||||
|
||||
void _clearSelection() {
|
||||
setState(() {
|
||||
_selectedContacts.clear();
|
||||
_controller.clear();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _validateAndSubmit() async {
|
||||
final path = _controller.text.trim().toUpperCase();
|
||||
if (path.isEmpty) {
|
||||
if (mounted) Navigator.pop(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse comma-separated hex prefixes
|
||||
final pathIds = path.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
|
||||
final pathBytesList = <int>[];
|
||||
final invalidPrefixes = <String>[];
|
||||
|
||||
for (final id in pathIds) {
|
||||
if (id.length < 2) {
|
||||
invalidPrefixes.add(id);
|
||||
continue;
|
||||
}
|
||||
|
||||
final prefix = id.substring(0, 2);
|
||||
try {
|
||||
final byte = int.parse(prefix, radix: 16);
|
||||
pathBytesList.add(byte);
|
||||
} catch (e) {
|
||||
invalidPrefixes.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// Show error for invalid prefixes
|
||||
if (invalidPrefixes.isNotEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Invalid hex prefixes: ${invalidPrefixes.join(", ")}'),
|
||||
duration: const Duration(seconds: 3),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check max path length (64 hops)
|
||||
if (pathBytesList.length > 64) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Path too long. Maximum 64 hops allowed.'),
|
||||
duration: Duration(seconds: 3),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathBytesList.isNotEmpty && mounted) {
|
||||
Navigator.pop(context, Uint8List.fromList(pathBytesList));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(widget.title),
|
||||
content: SingleChildScrollView(
|
||||
child: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (widget.currentPathLabel != null) ...[
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'Current path',
|
||||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Spacer(),
|
||||
if (widget.onRefresh != null)
|
||||
TextButton.icon(
|
||||
onPressed: widget.onRefresh,
|
||||
icon: const Icon(Icons.refresh, size: 16),
|
||||
label: const Text('Reload'),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
widget.currentPathLabel!,
|
||||
style: const TextStyle(fontSize: 11, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
const Text(
|
||||
'Enter 2-character hex prefixes for each hop, separated by commas.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Example: A1,F2,3C (each node uses first byte of its public key)',
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _controller,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Path (hex prefixes)',
|
||||
hintText: 'A1,F2,3C',
|
||||
border: OutlineInputBorder(),
|
||||
helperText: 'Max 64 hops. Each prefix is 2 hex characters (1 byte)',
|
||||
),
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
maxLength: 191, // 64 hops * 2 chars + 63 commas
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Divider(),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'Or select from contacts:',
|
||||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Spacer(),
|
||||
if (_selectedContacts.isNotEmpty)
|
||||
TextButton(
|
||||
onPressed: _clearSelection,
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (_validContacts.isEmpty) ...[
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.info_outline, size: 48, color: Colors.grey),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'No repeaters or room servers found.',
|
||||
style: TextStyle(fontSize: 14),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Custom paths require intermediate hops that can relay messages.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 200),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: _validContacts.length,
|
||||
itemBuilder: (context, index) {
|
||||
final contact = _validContacts[index];
|
||||
final isSelected = _selectedContacts.contains(contact);
|
||||
|
||||
return ListTile(
|
||||
dense: true,
|
||||
leading: CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: isSelected
|
||||
? Colors.green
|
||||
: (contact.type == 2 ? Colors.blue : Colors.purple),
|
||||
child: Icon(
|
||||
contact.type == 2 ? Icons.router : Icons.meeting_room,
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
title: Text(contact.name, style: const TextStyle(fontSize: 14)),
|
||||
subtitle: Text(
|
||||
'${contact.typeLabel} • ${contact.publicKeyHex.substring(0, 2)}',
|
||||
style: const TextStyle(fontSize: 10),
|
||||
),
|
||||
trailing: isSelected
|
||||
? const Icon(Icons.check_circle, color: Colors.green)
|
||||
: const Icon(Icons.add_circle_outline),
|
||||
onTap: () => _toggleContact(contact),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _validateAndSubmit,
|
||||
child: const Text('Set Path'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,10 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import '../services/repeater_command_service.dart';
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import 'path_management_dialog.dart';
|
||||
|
||||
class RepeaterLoginDialog extends StatefulWidget {
|
||||
final Contact repeater;
|
||||
@@ -31,8 +32,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
||||
bool _obscurePassword = true;
|
||||
late MeshCoreConnector _connector;
|
||||
int _currentAttempt = 0;
|
||||
final int _maxAttempts = RepeaterCommandService.maxRetries;
|
||||
static const int _loginTimeoutSeconds = 10;
|
||||
static const int _maxAttempts = 5;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -65,6 +65,13 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
||||
|
||||
bool _isLoggingIn = false;
|
||||
|
||||
Contact _resolveRepeater(MeshCoreConnector connector) {
|
||||
return connector.contacts.firstWhere(
|
||||
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
|
||||
orElse: () => widget.repeater,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleLogin() async {
|
||||
if (_isLoggingIn) return;
|
||||
|
||||
@@ -75,6 +82,26 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
||||
|
||||
try {
|
||||
final password = _passwordController.text;
|
||||
final repeater = _resolveRepeater(_connector);
|
||||
appLogger.info(
|
||||
'Login started for ${repeater.name} (${repeater.publicKeyHex})',
|
||||
tag: 'RepeaterLogin',
|
||||
);
|
||||
final selection = await _connector.preparePathForContactSend(repeater);
|
||||
final loginFrame = buildSendLoginFrame(repeater.publicKey, password);
|
||||
final pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
|
||||
final timeoutMs = _connector.calculateTimeout(
|
||||
pathLength: pathLengthValue,
|
||||
messageBytes: loginFrame.length,
|
||||
);
|
||||
final timeoutSeconds = (timeoutMs / 1000).ceil();
|
||||
final timeout = Duration(milliseconds: timeoutMs);
|
||||
final selectionLabel =
|
||||
selection.useFlood ? 'flood' : '${selection.hopCount} hops';
|
||||
appLogger.info(
|
||||
'Login routing: $selectionLabel',
|
||||
tag: 'RepeaterLogin',
|
||||
);
|
||||
bool? loginResult;
|
||||
for (int attempt = 0; attempt < _maxAttempts; attempt++) {
|
||||
if (!mounted) return;
|
||||
@@ -82,17 +109,46 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
||||
_currentAttempt = attempt + 1;
|
||||
});
|
||||
|
||||
appLogger.info(
|
||||
'Sending login attempt ${attempt + 1}/$_maxAttempts',
|
||||
tag: 'RepeaterLogin',
|
||||
);
|
||||
await _connector.sendFrame(
|
||||
buildSendLoginFrame(widget.repeater.publicKey, password),
|
||||
loginFrame,
|
||||
);
|
||||
|
||||
loginResult = await _awaitLoginResponse();
|
||||
loginResult = await _awaitLoginResponse(timeout);
|
||||
if (loginResult == true) {
|
||||
appLogger.info(
|
||||
'Login succeeded for ${repeater.name}',
|
||||
tag: 'RepeaterLogin',
|
||||
);
|
||||
break;
|
||||
}
|
||||
if (loginResult == false) {
|
||||
appLogger.warn(
|
||||
'Login failed for ${repeater.name}',
|
||||
tag: 'RepeaterLogin',
|
||||
);
|
||||
throw Exception('Wrong password or node is unreachable');
|
||||
}
|
||||
appLogger.warn(
|
||||
'Login attempt ${attempt + 1} timed out after ${timeoutSeconds}s',
|
||||
tag: 'RepeaterLogin',
|
||||
);
|
||||
}
|
||||
|
||||
if (loginResult == null) {
|
||||
appLogger.warn(
|
||||
'Login timed out for ${repeater.name}',
|
||||
tag: 'RepeaterLogin',
|
||||
);
|
||||
}
|
||||
|
||||
if (loginResult == true) {
|
||||
_connector.recordRepeaterPathResult(repeater, selection, true, null);
|
||||
} else {
|
||||
_connector.recordRepeaterPathResult(repeater, selection, false, null);
|
||||
}
|
||||
|
||||
if (loginResult != true) {
|
||||
@@ -114,6 +170,11 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
||||
Future.microtask(() => widget.onLogin(password));
|
||||
}
|
||||
} catch (e) {
|
||||
final repeater = _resolveRepeater(_connector);
|
||||
appLogger.warn(
|
||||
'Login error for ${repeater.name}: $e',
|
||||
tag: 'RepeaterLogin',
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoggingIn = false;
|
||||
@@ -128,7 +189,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool?> _awaitLoginResponse() async {
|
||||
Future<bool?> _awaitLoginResponse(Duration timeout) async {
|
||||
final completer = Completer<bool?>();
|
||||
Timer? timer;
|
||||
StreamSubscription<Uint8List>? subscription;
|
||||
@@ -147,7 +208,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
||||
timer?.cancel();
|
||||
});
|
||||
|
||||
timer = Timer(const Duration(seconds: _loginTimeoutSeconds), () {
|
||||
timer = Timer(timeout, () {
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete(null);
|
||||
subscription?.cancel();
|
||||
@@ -162,6 +223,9 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
final repeater = _resolveRepeater(connector);
|
||||
final isFloodMode = repeater.pathOverride == -1;
|
||||
return AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
@@ -173,7 +237,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
||||
children: [
|
||||
const Text('Repeater Login'),
|
||||
Text(
|
||||
widget.repeater.name,
|
||||
repeater.name,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.normal,
|
||||
@@ -244,6 +308,73 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
const Divider(),
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'Routing',
|
||||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Spacer(),
|
||||
PopupMenuButton<String>(
|
||||
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
|
||||
tooltip: 'Routing mode',
|
||||
onSelected: (mode) async {
|
||||
if (mode == 'flood') {
|
||||
await connector.setPathOverride(repeater, pathLen: -1);
|
||||
} else {
|
||||
await connector.setPathOverride(repeater, pathLen: null);
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'auto',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Auto (use saved path)',
|
||||
style: TextStyle(
|
||||
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'flood',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Force Flood Mode',
|
||||
style: TextStyle(
|
||||
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
repeater.pathLabel,
|
||||
style: const TextStyle(fontSize: 11, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => PathManagementDialog.show(context, contact: repeater),
|
||||
icon: const Icon(Icons.timeline, size: 18),
|
||||
label: const Text('Manage Paths'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
@@ -268,7 +399,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text('Retries $_currentAttempt/$_maxAttempts'),
|
||||
Text('Attempt $_currentAttempt/$_maxAttempts'),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user