mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-27 20:57:31 +10:00
feat: add contact UI helpers and path editor for routing management
- Implemented contactTypeIcon and contactTypeColor functions for better UI representation of contact types. - Created colorForName and firstCharacterOrEmoji functions to enhance contact display. - Developed PathEditorSheet widget for managing contact paths with a user-friendly interface. - Introduced RoutingSheet for managing contact routing modes and displaying path history. - Added a script for generating proof of concept (PoC) payloads for clipboard contact import validation.
This commit is contained in:
@@ -0,0 +1,709 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user