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 disconnectchore/offband-rebrand
parent
361dfb7808
commit
ad911a1d80
@ -0,0 +1,106 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../services/app_debug_log_service.dart';
|
||||
|
||||
class AppDebugLogScreen extends StatelessWidget {
|
||||
const AppDebugLogScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<AppDebugLogService>(
|
||||
builder: (context, logService, _) {
|
||||
final entries = logService.entries.reversed.toList();
|
||||
final hasEntries = entries.isNotEmpty;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('App Debug Log'),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Copy log',
|
||||
icon: const Icon(Icons.copy),
|
||||
onPressed: hasEntries
|
||||
? () async {
|
||||
final text = entries
|
||||
.map((entry) =>
|
||||
'[${entry.formattedTime}] [${entry.levelLabel}] [${entry.tag}] ${entry.message}')
|
||||
.join('\n');
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Debug log copied')),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Clear log',
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: hasEntries
|
||||
? () {
|
||||
logService.clear();
|
||||
}
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: hasEntries
|
||||
? ListView.separated(
|
||||
itemCount: entries.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final entry = entries[index];
|
||||
return ListTile(
|
||||
dense: true,
|
||||
leading: _buildLevelIcon(entry.level),
|
||||
title: Text(
|
||||
'[${entry.tag}] ${entry.message}',
|
||||
style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
|
||||
),
|
||||
subtitle: Text(
|
||||
entry.formattedTime,
|
||||
style: TextStyle(fontSize: 10, color: Colors.grey[600]),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.bug_report_outlined, size: 64, color: Colors.grey[400]),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No debug logs yet',
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Enable app debug logging in settings',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLevelIcon(AppDebugLogLevel level) {
|
||||
switch (level) {
|
||||
case AppDebugLogLevel.info:
|
||||
return const Icon(Icons.info_outline, size: 18, color: Colors.blue);
|
||||
case AppDebugLogLevel.warning:
|
||||
return const Icon(Icons.warning_amber_outlined, size: 18, color: Colors.orange);
|
||||
case AppDebugLogLevel.error:
|
||||
return const Icon(Icons.error_outline, size: 18, color: Colors.red);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,92 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
enum AppDebugLogLevel {
|
||||
info,
|
||||
warning,
|
||||
error,
|
||||
}
|
||||
|
||||
class AppDebugLogEntry {
|
||||
final DateTime timestamp;
|
||||
final AppDebugLogLevel level;
|
||||
final String tag;
|
||||
final String message;
|
||||
|
||||
AppDebugLogEntry({
|
||||
required this.timestamp,
|
||||
required this.level,
|
||||
required this.tag,
|
||||
required this.message,
|
||||
});
|
||||
|
||||
String get levelLabel {
|
||||
switch (level) {
|
||||
case AppDebugLogLevel.info:
|
||||
return 'INFO';
|
||||
case AppDebugLogLevel.warning:
|
||||
return 'WARN';
|
||||
case AppDebugLogLevel.error:
|
||||
return 'ERROR';
|
||||
}
|
||||
}
|
||||
|
||||
String get formattedTime {
|
||||
return '${timestamp.hour.toString().padLeft(2, '0')}:'
|
||||
'${timestamp.minute.toString().padLeft(2, '0')}:'
|
||||
'${timestamp.second.toString().padLeft(2, '0')}.'
|
||||
'${timestamp.millisecond.toString().padLeft(3, '0')}';
|
||||
}
|
||||
}
|
||||
|
||||
class AppDebugLogService extends ChangeNotifier {
|
||||
static const int maxEntries = 1000;
|
||||
final List<AppDebugLogEntry> _entries = [];
|
||||
bool _enabled = false;
|
||||
|
||||
List<AppDebugLogEntry> get entries => List.unmodifiable(_entries);
|
||||
bool get enabled => _enabled;
|
||||
|
||||
void setEnabled(bool value) {
|
||||
_enabled = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void log(String message, {String tag = 'App', AppDebugLogLevel level = AppDebugLogLevel.info}) {
|
||||
if (!_enabled) return;
|
||||
|
||||
_entries.add(
|
||||
AppDebugLogEntry(
|
||||
timestamp: DateTime.now(),
|
||||
level: level,
|
||||
tag: tag,
|
||||
message: message,
|
||||
),
|
||||
);
|
||||
|
||||
if (_entries.length > maxEntries) {
|
||||
_entries.removeRange(0, _entries.length - maxEntries);
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
|
||||
// Also print to console for development
|
||||
debugPrint('[$tag] $message');
|
||||
}
|
||||
|
||||
void info(String message, {String tag = 'App'}) {
|
||||
log(message, tag: tag, level: AppDebugLogLevel.info);
|
||||
}
|
||||
|
||||
void warn(String message, {String tag = 'App'}) {
|
||||
log(message, tag: tag, level: AppDebugLogLevel.warning);
|
||||
}
|
||||
|
||||
void error(String message, {String tag = 'App'}) {
|
||||
log(message, tag: tag, level: AppDebugLogLevel.error);
|
||||
}
|
||||
|
||||
void clear() {
|
||||
_entries.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
import '../services/app_debug_log_service.dart';
|
||||
|
||||
/// Global app logger instance
|
||||
/// Usage: appLogger.info('Message', tag: 'MyClass');
|
||||
class AppLogger {
|
||||
AppDebugLogService? _service;
|
||||
bool _enabled = false;
|
||||
|
||||
/// Initialize the logger with the debug log service
|
||||
void initialize(AppDebugLogService service, {bool enabled = false}) {
|
||||
_service = service;
|
||||
_enabled = enabled;
|
||||
_service?.setEnabled(enabled);
|
||||
}
|
||||
|
||||
/// Update whether logging is enabled
|
||||
void setEnabled(bool enabled) {
|
||||
_enabled = enabled;
|
||||
_service?.setEnabled(enabled);
|
||||
}
|
||||
|
||||
/// Check if logging is currently enabled
|
||||
bool get isEnabled => _enabled;
|
||||
|
||||
/// Log an info message
|
||||
void info(String message, {String tag = 'App'}) {
|
||||
if (_enabled && _service != null) {
|
||||
_service!.info(message, tag: tag);
|
||||
}
|
||||
}
|
||||
|
||||
/// Log a warning message
|
||||
void warn(String message, {String tag = 'App'}) {
|
||||
if (_enabled && _service != null) {
|
||||
_service!.warn(message, tag: tag);
|
||||
}
|
||||
}
|
||||
|
||||
/// Log an error message
|
||||
void error(String message, {String tag = 'App'}) {
|
||||
if (_enabled && _service != null) {
|
||||
_service!.error(message, tag: tag);
|
||||
}
|
||||
}
|
||||
|
||||
/// Log a message with custom level
|
||||
void log(String message, {String tag = 'App', AppDebugLogLevel level = AppDebugLogLevel.info}) {
|
||||
if (_enabled && _service != null) {
|
||||
_service!.log(message, tag: tag, level: level);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Global logger instance
|
||||
final appLogger = AppLogger();
|
||||
@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in new issue