Files
meshcore-open/lib/widgets/routing_sheet.dart
T
zjs81 26fdf74d69 Refactor UI code for better readability and consistency
- Improved formatting of ListTile icons and text styles in settings_screen.dart, telemetry_screen.dart, usb_screen.dart, gif_picker.dart, path_editor_sheet.dart, repeater_login_dialog.dart, and room_login_dialog.dart for better readability.
- Consolidated TextStyle definitions into single lines where applicable.
- Updated notification_service.dart to enhance readability of notification ID assignment.
- Simplified function signatures in routing_sheet.dart for clarity.
- Cleaned up test assertions in usb_flow_test.dart for conciseness.
- Removed unused translations in untranslated.json to streamline localization files.
2026-06-11 00:28:13 -07:00

723 lines
23 KiB
Dart

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../helpers/path_helper.dart';
import '../l10n/l10n.dart';
import '../models/contact.dart';
import '../models/path_history.dart';
import '../screens/path_trace_map.dart';
import '../services/path_history_service.dart';
import 'path_editor_sheet.dart';
enum _RoutingMode { auto, flood, manual }
enum _PathQuality { strong, good, fair, proven, flood, untested }
class ContactRoutingSheet {
static Future<void> show(BuildContext context, {required Contact contact}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (context) => DraggableScrollableSheet(
expand: false,
initialChildSize: 0.75,
minChildSize: 0.4,
maxChildSize: 0.95,
builder: (context, scrollController) => _RoutingSheetBody(
contact: contact,
scrollController: scrollController,
),
),
);
}
}
class _RoutingSheetBody extends StatefulWidget {
final Contact contact;
final ScrollController scrollController;
const _RoutingSheetBody({
required this.contact,
required this.scrollController,
});
@override
State<_RoutingSheetBody> createState() => _RoutingSheetBodyState();
}
class _RoutingSheetBodyState extends State<_RoutingSheetBody> {
int _resolveContactIndex = -1;
String? _syncStatus;
Contact _resolveContact(MeshCoreConnector connector) {
if (_resolveContactIndex >= 0 &&
_resolveContactIndex < connector.contacts.length &&
connector.contacts[_resolveContactIndex].publicKeyHex ==
widget.contact.publicKeyHex) {
return connector.contacts[_resolveContactIndex];
}
_resolveContactIndex = connector.contacts.indexWhere(
(c) => c.publicKeyHex == widget.contact.publicKeyHex,
);
if (_resolveContactIndex == -1) {
return widget.contact;
}
return connector.contacts[_resolveContactIndex];
}
_RoutingMode _modeOf(Contact contact) {
final override = contact.pathOverride;
if (override == null) return _RoutingMode.auto;
return override < 0 ? _RoutingMode.flood : _RoutingMode.manual;
}
Future<void> _selectMode(
MeshCoreConnector connector,
Contact contact,
_RoutingMode mode,
) async {
switch (mode) {
case _RoutingMode.auto:
setState(() => _syncStatus = null);
await connector.setPathOverride(contact, pathLen: null);
case _RoutingMode.flood:
setState(() => _syncStatus = null);
await connector.setPathOverride(contact, pathLen: -1);
case _RoutingMode.manual:
await _editManualPath(connector, contact);
}
}
Future<void> _editManualPath(
MeshCoreConnector connector,
Contact contact,
) async {
final override = contact.pathOverride;
final initial = override != null && override > 0
? (contact.pathOverrideBytes ?? Uint8List(0))
: (contact.pathLength > 0 ? contact.path : Uint8List(0));
final available = connector.allContacts
.where((c) => c.publicKeyHex != contact.publicKeyHex)
.toList();
final result = await PathEditorSheet.show(
context,
availableContacts: available,
initialPath: initial,
);
if (result == null || !mounted) return;
await connector.setPathOverride(
contact,
pathLen: result.length,
pathBytes: result,
);
await _verifyPath(connector, contact, result);
}
Future<void> _applyHistoryPath(
MeshCoreConnector connector,
Contact contact,
PathRecord record,
) async {
final bytes = Uint8List.fromList(record.pathBytes);
await connector.setPathOverride(
contact,
pathLen: bytes.length,
pathBytes: bytes,
);
await _verifyPath(connector, contact, bytes);
}
Future<void> _verifyPath(
MeshCoreConnector connector,
Contact contact,
Uint8List bytes,
) async {
if (!mounted) return;
if (!connector.isConnected) {
setState(() => _syncStatus = context.l10n.chat_pathSavedLocally);
return;
}
setState(() => _syncStatus = null);
final verified = await connector.verifyContactPathOnDevice(contact, bytes);
if (!mounted) return;
setState(
() => _syncStatus = verified
? context.l10n.chat_pathDeviceConfirmed
: context.l10n.chat_pathDeviceNotConfirmed,
);
}
Future<void> _forgetPath(MeshCoreConnector connector, Contact contact) async {
await connector.clearContactPath(contact);
if (!mounted) return;
setState(() => _syncStatus = context.l10n.chat_pathCleared);
}
_PathQuality _qualityOf(PathRecord record, List<DirectRepeater> ranked) {
if (record.pathBytes.isNotEmpty) {
final first = record.pathBytes.first;
for (var i = 0; i < ranked.length && i < 3; i++) {
if (ranked[i].pubkeyFirstByte == first) {
return switch (i) {
0 => _PathQuality.strong,
1 => _PathQuality.good,
_ => _PathQuality.fair,
};
}
}
}
if (record.successCount > 0) return _PathQuality.proven;
if (record.wasFloodDiscovery) return _PathQuality.flood;
return _PathQuality.untested;
}
String _qualityLabel(BuildContext context, _PathQuality quality) {
final l10n = context.l10n;
return switch (quality) {
_PathQuality.strong => l10n.routing_qualityStrong,
_PathQuality.good => l10n.routing_qualityGood,
_PathQuality.fair => l10n.routing_qualityFair,
_PathQuality.proven => l10n.routing_qualityWorked,
_PathQuality.flood => l10n.routing_qualityFlood,
_PathQuality.untested => l10n.routing_qualityUntested,
};
}
IconData _qualityIcon(_PathQuality quality) {
return switch (quality) {
_PathQuality.strong => Icons.signal_cellular_alt,
_PathQuality.good => Icons.signal_cellular_alt_2_bar,
_PathQuality.fair => Icons.signal_cellular_alt_1_bar,
_PathQuality.proven => Icons.check_circle_outline,
_PathQuality.flood => Icons.waves,
_PathQuality.untested => Icons.route,
};
}
String _relativeTime(BuildContext context, DateTime time) {
final l10n = context.l10n;
final diff = DateTime.now().difference(time);
if (diff.inSeconds < 60) return l10n.time_justNow;
if (diff.inMinutes < 60) return l10n.time_minutesAgo(diff.inMinutes);
if (diff.inHours < 24) return l10n.time_hoursAgo(diff.inHours);
return l10n.time_daysAgo(diff.inDays);
}
String _modeHint(BuildContext context, _RoutingMode mode) {
final l10n = context.l10n;
return switch (mode) {
_RoutingMode.auto => l10n.routing_modeAutoHint,
_RoutingMode.flood => l10n.routing_modeFloodHint,
_RoutingMode.manual => l10n.routing_modeManualHint,
};
}
String _routeText(
BuildContext context,
MeshCoreConnector connector,
Contact contact,
_RoutingMode mode,
) {
final l10n = context.l10n;
switch (mode) {
case _RoutingMode.flood:
return l10n.routing_floodBroadcast;
case _RoutingMode.manual:
final bytes = contact.pathOverrideBytes ?? Uint8List(0);
if (bytes.isEmpty) return l10n.routing_directNoHops;
return PathHelper.resolvePathNames(bytes, connector.allContacts);
case _RoutingMode.auto:
if (contact.pathLength < 0) return l10n.routing_noPathYet;
if (contact.pathLength == 0) return l10n.routing_directNoHops;
if (contact.path.isEmpty)
return l10n.chat_hopsCount(contact.pathLength);
return PathHelper.resolvePathNames(contact.path, connector.allContacts);
}
}
Uint8List _displayBytes(Contact contact, _RoutingMode mode) {
return switch (mode) {
_RoutingMode.flood => Uint8List(0),
_RoutingMode.manual => contact.pathOverrideBytes ?? Uint8List(0),
_RoutingMode.auto => contact.path,
};
}
void _openPathTrace(
BuildContext context,
MeshCoreConnector connector,
Contact contact,
List<int> pathBytes,
) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PathTraceMapScreen(
title: context.l10n.contacts_repeaterPathTrace,
path: Uint8List.fromList(pathBytes),
flipPathAround: true,
targetContact: contact,
pathHashByteWidth: connector.pathHashByteWidth,
),
),
);
}
void _showPathDetail(
BuildContext context,
MeshCoreConnector connector,
Contact contact,
List<int> pathBytes,
) {
final l10n = context.l10n;
final formattedPath = PathHelper.formatPathHex(pathBytes);
final resolvedNames = PathHelper.resolvePathNames(
pathBytes,
connector.allContacts,
);
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(l10n.chat_fullPath),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SelectableText(formattedPath),
const SizedBox(height: 8),
SelectableText(
resolvedNames,
style: TextStyle(
fontSize: 13,
color: Theme.of(dialogContext).colorScheme.onSurfaceVariant,
),
),
],
),
actions: [
TextButton(
onPressed: () =>
_openPathTrace(dialogContext, connector, contact, pathBytes),
child: Text(l10n.contacts_pathTrace),
),
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text(l10n.common_close),
),
],
),
);
}
Widget _currentRouteCard(
BuildContext context,
MeshCoreConnector connector,
Contact contact,
_RoutingMode mode,
({
int successCount,
int failureCount,
int lastTripTimeMs,
DateTime? lastUsed,
})?
floodStats,
) {
final l10n = context.l10n;
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final displayBytes = _displayBytes(contact, mode);
return Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
mode == _RoutingMode.flood ? Icons.waves : Icons.route,
size: 18,
color: scheme.primary,
),
const SizedBox(width: 8),
Text(
l10n.routing_currentRoute,
style: theme.textTheme.titleSmall,
),
],
),
const SizedBox(height: 8),
Text(
_routeText(context, connector, contact, mode),
style: theme.textTheme.bodyMedium,
),
if (mode == _RoutingMode.flood &&
floodStats != null &&
(floodStats.successCount > 0 || floodStats.failureCount > 0))
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
_floodStatsLine(context, floodStats),
style: theme.textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
),
if (_syncStatus != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
_syncStatus!,
style: theme.textTheme.bodySmall?.copyWith(
color: scheme.tertiary,
),
),
),
Wrap(
spacing: 8,
children: [
if (displayBytes.isNotEmpty)
TextButton.icon(
icon: const Icon(Icons.map_outlined, size: 18),
label: Text(l10n.contacts_pathTrace),
onPressed: () => _openPathTrace(
context,
connector,
contact,
displayBytes,
),
),
if (mode == _RoutingMode.manual)
TextButton.icon(
icon: const Icon(Icons.edit, size: 18),
label: Text(l10n.routing_editPath),
onPressed: () => _editManualPath(connector, contact),
),
if (mode == _RoutingMode.auto && contact.pathLength >= 0)
TextButton.icon(
icon: const Icon(Icons.restart_alt, size: 18),
label: Text(l10n.routing_forgetPath),
onPressed: () => _forgetPath(connector, contact),
),
],
),
],
),
),
);
}
String _floodStatsLine(
BuildContext context,
({
int successCount,
int failureCount,
int lastTripTimeMs,
DateTime? lastUsed,
})
stats,
) {
final l10n = context.l10n;
final parts = <String>[
l10n.routing_deliveryCounts(stats.successCount, stats.failureCount),
if (stats.lastTripTimeMs > 0)
'${(stats.lastTripTimeMs / 1000).toStringAsFixed(1)}s',
if (stats.lastUsed != null)
l10n.routing_lastWorked(_relativeTime(context, stats.lastUsed!)),
];
return parts.join('');
}
Widget _floodTile(
BuildContext context,
MeshCoreConnector connector,
Contact contact,
_RoutingMode mode,
({
int successCount,
int failureCount,
int lastTripTimeMs,
DateTime? lastUsed,
})
stats,
) {
final l10n = context.l10n;
final scheme = Theme.of(context).colorScheme;
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: ListTile(
leading: CircleAvatar(
radius: 18,
backgroundColor: scheme.surfaceContainerHighest,
child: Icon(Icons.waves, size: 18, color: scheme.onSurfaceVariant),
),
title: Text(l10n.routing_floodDelivery),
subtitle: Text(
_floodStatsLine(context, stats),
style: const TextStyle(fontSize: 11),
),
trailing: mode == _RoutingMode.flood
? Icon(
Icons.check_circle,
color: scheme.primary,
semanticLabel: l10n.routing_inUse,
)
: null,
onTap: mode == _RoutingMode.flood
? null
: () => _selectMode(connector, contact, _RoutingMode.flood),
),
);
}
Widget _pathRecordTile(
BuildContext context,
MeshCoreConnector connector,
Contact contact,
_RoutingMode mode,
PathHistoryService pathService,
PathRecord record,
_PathQuality quality,
) {
final l10n = context.l10n;
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final (Color bg, Color fg) = switch (quality) {
_PathQuality.strong => (
scheme.primaryContainer,
scheme.onPrimaryContainer,
),
_PathQuality.good => (
scheme.secondaryContainer,
scheme.onSecondaryContainer,
),
_PathQuality.fair => (
scheme.tertiaryContainer,
scheme.onTertiaryContainer,
),
_PathQuality.proven => (
scheme.primaryContainer,
scheme.onPrimaryContainer,
),
_ => (scheme.surfaceContainerHighest, scheme.onSurfaceVariant),
};
final hasBytes = record.pathBytes.isNotEmpty;
final inUse =
hasBytes &&
((mode == _RoutingMode.manual &&
listEquals(record.pathBytes, contact.pathOverrideBytes)) ||
(mode == _RoutingMode.auto &&
listEquals(record.pathBytes, contact.path)));
final title = hasBytes
? PathHelper.resolvePathNames(record.pathBytes, connector.allContacts)
: l10n.chat_hopsCount(record.hopCount);
final line1 =
'${l10n.chat_hopsCount(record.hopCount)}${_qualityLabel(context, quality)}';
final line2Parts = <String>[
record.timestamp != null
? l10n.routing_lastWorked(_relativeTime(context, record.timestamp!))
: l10n.routing_neverWorked,
if (record.tripTimeMs > 0)
'${(record.tripTimeMs / 1000).toStringAsFixed(1)}s',
l10n.routing_deliveryCounts(record.successCount, record.failureCount),
];
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: ListTile(
enabled: hasBytes,
leading: CircleAvatar(
radius: 18,
backgroundColor: bg,
child: Icon(
_qualityIcon(quality),
size: 18,
color: fg,
semanticLabel: _qualityLabel(context, quality),
),
),
title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(
'$line1\n${line2Parts.join('')}',
style: const TextStyle(fontSize: 11),
),
isThreeLine: true,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (inUse)
Tooltip(
message: l10n.routing_inUse,
child: Icon(
Icons.check_circle,
color: scheme.primary,
semanticLabel: l10n.routing_inUse,
),
),
IconButton(
icon: const Icon(Icons.delete_outline, size: 20),
tooltip: l10n.chat_removePath,
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
onPressed: () => pathService.removePathRecord(
contact.publicKeyHex,
record.pathBytes,
),
),
],
),
onTap: hasBytes && !inUse
? () => _applyHistoryPath(connector, contact, record)
: null,
onLongPress: hasBytes
? () =>
_showPathDetail(context, connector, contact, record.pathBytes)
: null,
),
);
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final theme = Theme.of(context);
final scheme = theme.colorScheme;
return Consumer2<MeshCoreConnector, PathHistoryService>(
builder: (context, connector, pathService, _) {
final contact = _resolveContact(connector);
final mode = _modeOf(contact);
final floodStats = pathService.getFloodStats(contact.publicKeyHex);
final hasFloodStats =
floodStats != null &&
(floodStats.successCount > 0 || floodStats.failureCount > 0);
final rankedRepeaters = List.of(connector.directRepeaters)
..sort((a, b) => b.ranking.compareTo(a.ranking));
final entries =
pathService
.getRecentPaths(contact.publicKeyHex)
.map(
(r) => (quality: _qualityOf(r, rankedRepeaters), record: r),
)
.toList()
..sort((a, b) {
final byQuality = a.quality.index.compareTo(b.quality.index);
if (byQuality != 0) return byQuality;
final aTime =
a.record.timestamp ??
DateTime.fromMillisecondsSinceEpoch(0);
final bTime =
b.record.timestamp ??
DateTime.fromMillisecondsSinceEpoch(0);
return bTime.compareTo(aTime);
});
return ListView(
controller: widget.scrollController,
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
children: [
Center(
child: Container(
width: 32,
height: 4,
decoration: BoxDecoration(
color: scheme.outlineVariant,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 12),
Text(l10n.routing_title, style: theme.textTheme.titleLarge),
Text(
contact.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
SegmentedButton<_RoutingMode>(
style: const ButtonStyle(
minimumSize: WidgetStatePropertyAll(Size.fromHeight(44)),
),
segments: [
ButtonSegment(
value: _RoutingMode.auto,
icon: const Icon(Icons.auto_mode),
label: Text(l10n.routing_modeAuto),
),
ButtonSegment(
value: _RoutingMode.flood,
icon: const Icon(Icons.waves),
label: Text(l10n.routing_modeFlood),
),
ButtonSegment(
value: _RoutingMode.manual,
icon: const Icon(Icons.edit_road),
label: Text(l10n.routing_modeManual),
),
],
selected: {mode},
onSelectionChanged: (selection) =>
_selectMode(connector, contact, selection.first),
),
const SizedBox(height: 8),
Text(
_modeHint(context, mode),
style: theme.textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
_currentRouteCard(context, connector, contact, mode, floodStats),
const SizedBox(height: 16),
Text(l10n.routing_knownPaths, style: theme.textTheme.titleSmall),
Text(
l10n.routing_knownPathsHint,
style: theme.textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
if (hasFloodStats)
_floodTile(context, connector, contact, mode, floodStats),
if (entries.isEmpty && !hasFloodStats)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
l10n.chat_noPathHistoryYet,
style: theme.textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
),
...entries.map(
(entry) => _pathRecordTile(
context,
connector,
contact,
mode,
pathService,
entry.record,
entry.quality,
),
),
],
);
},
);
}
}