mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-12 00:40:28 +00:00
Fix tracing path and display issues
This commit is contained in:
@@ -13,6 +13,7 @@ import '../../providers/messages_provider.dart';
|
||||
import '../../providers/sensors_provider.dart';
|
||||
import '../../services/message_destination_preferences.dart';
|
||||
import 'contact_route_dialog.dart';
|
||||
import 'contact_trace_sheet.dart';
|
||||
import 'room_login_sheet.dart';
|
||||
import '../common/contact_avatar.dart';
|
||||
import '../../utils/toast_logger.dart';
|
||||
@@ -363,6 +364,15 @@ class ContactTile extends StatelessWidget {
|
||||
_showSetRouteDialog(context, contact);
|
||||
},
|
||||
),
|
||||
if (!contact.isChannel)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.route),
|
||||
title: const Text('Trace'),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_showTraceSheet(context, contact);
|
||||
},
|
||||
),
|
||||
if (!contact.isPublicChannel)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete, color: Colors.red),
|
||||
@@ -453,6 +463,18 @@ class ContactTile extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void _showTraceSheet(BuildContext context, Contact contact) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (context) => ContactTraceSheet(contact: contact),
|
||||
);
|
||||
}
|
||||
|
||||
void _showDeleteConfirmation(
|
||||
BuildContext context,
|
||||
Contact contact, {
|
||||
|
||||
525
lib/widgets/contacts/contact_trace_sheet.dart
Normal file
525
lib/widgets/contacts/contact_trace_sheet.dart
Normal file
@@ -0,0 +1,525 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' as flutter_map;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../models/contact.dart';
|
||||
import '../../providers/connection_provider.dart';
|
||||
import '../../providers/contacts_provider.dart';
|
||||
import '../../services/mesh_map_nodes_service.dart';
|
||||
import '../../utils/trace_node_resolver.dart';
|
||||
|
||||
class ContactTraceSheet extends StatefulWidget {
|
||||
final Contact contact;
|
||||
|
||||
const ContactTraceSheet({super.key, required this.contact});
|
||||
|
||||
@override
|
||||
State<ContactTraceSheet> createState() => _ContactTraceSheetState();
|
||||
}
|
||||
|
||||
class _ContactTraceSheetState extends State<ContactTraceSheet> {
|
||||
late final Future<_ContactTraceResult> _future;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_future = _loadTrace();
|
||||
}
|
||||
|
||||
Future<_ContactTraceResult> _loadTrace() async {
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
final localNodes = _localNodesFromContacts(
|
||||
contactsProvider,
|
||||
connectionProvider: connectionProvider,
|
||||
);
|
||||
final localPublicKeys = localNodes.map((node) => node.publicKey).toSet();
|
||||
|
||||
var trace = _buildTraceResult(
|
||||
nodes: localNodes,
|
||||
localPublicKeys: localPublicKeys,
|
||||
selfPublicKey: connectionProvider.deviceInfo.publicKey,
|
||||
);
|
||||
if (_isCompleteTrace(trace)) {
|
||||
return trace;
|
||||
}
|
||||
|
||||
unawaited(
|
||||
MeshMapNodesService.syncInBackgroundIfStale(
|
||||
cacheTtl: MeshMapNodesService.traceCacheTtl,
|
||||
),
|
||||
);
|
||||
|
||||
final remoteNodes = await MeshMapNodesService.loadCachedNodes(
|
||||
cacheTtl: MeshMapNodesService.traceCacheTtl,
|
||||
);
|
||||
trace = _buildTraceResult(
|
||||
nodes: _mergeNodes(localNodes, remoteNodes),
|
||||
localPublicKeys: localPublicKeys,
|
||||
selfPublicKey: connectionProvider.deviceInfo.publicKey,
|
||||
);
|
||||
return trace;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: FutureBuilder<_ContactTraceResult>(
|
||||
future: _future,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return const SizedBox(
|
||||
height: 360,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
return SizedBox(
|
||||
height: 360,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text('Failed to load trace: ${snapshot.error}'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final trace = snapshot.data!;
|
||||
final routeEntries = _displayRouteEntries(trace);
|
||||
final concreteNodes = routeEntries
|
||||
.where((entry) => entry.resolved.node != null)
|
||||
.map((entry) => entry.resolved.node!)
|
||||
.toList();
|
||||
final mapPoints = concreteNodes
|
||||
.map((node) => LatLng(node.latitude, node.longitude))
|
||||
.toList();
|
||||
final hasMapPath = mapPoints.length >= 2;
|
||||
final relayNodes = trace.matchedRelayNodes.whereType<MeshMapNode>();
|
||||
|
||||
return SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.75,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).dividerColor,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 8),
|
||||
child: Text(
|
||||
'Trace',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
trace.routeHashes.isEmpty
|
||||
? 'Direct route to ${widget.contact.displayName}'
|
||||
: 'Route from saved contact path (${trace.routeHashes.length} hop${trace.routeHashes.length == 1 ? '' : 's'})',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: SizedBox(
|
||||
height: 240,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor,
|
||||
),
|
||||
),
|
||||
child: hasMapPath
|
||||
? flutter_map.FlutterMap(
|
||||
options: flutter_map.MapOptions(
|
||||
initialCameraFit:
|
||||
flutter_map.CameraFit.bounds(
|
||||
bounds:
|
||||
flutter_map
|
||||
.LatLngBounds.fromPoints(
|
||||
mapPoints,
|
||||
),
|
||||
padding: const EdgeInsets.all(28),
|
||||
),
|
||||
),
|
||||
children: [
|
||||
flutter_map.TileLayer(
|
||||
urlTemplate:
|
||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName:
|
||||
'com.meshcore.sar',
|
||||
),
|
||||
flutter_map.PolylineLayer(
|
||||
polylines: [
|
||||
flutter_map.Polyline(
|
||||
points: mapPoints,
|
||||
strokeWidth: 4,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
flutter_map.MarkerLayer(
|
||||
markers: concreteNodes
|
||||
.asMap()
|
||||
.entries
|
||||
.map(
|
||||
(entry) => flutter_map.Marker(
|
||||
point: LatLng(
|
||||
entry.value.latitude,
|
||||
entry.value.longitude,
|
||||
),
|
||||
width: 34,
|
||||
height: 34,
|
||||
child: CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor:
|
||||
entry.key == 0
|
||||
? Colors.green
|
||||
: (entry.key ==
|
||||
concreteNodes
|
||||
.length -
|
||||
1
|
||||
? Colors.red
|
||||
: Colors.blue),
|
||||
child: Text(
|
||||
'${entry.key + 1}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight:
|
||||
FontWeight.bold,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
)
|
||||
: const Center(
|
||||
child: Text(
|
||||
'Not enough geolocated nodes to draw path',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
'Route',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
if (routeEntries.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Text(
|
||||
'No named nodes could be matched for this trace.',
|
||||
),
|
||||
),
|
||||
...routeEntries.asMap().entries.map(
|
||||
(entry) => ListTile(
|
||||
leading: CircleAvatar(
|
||||
radius: 14,
|
||||
backgroundColor: entry.key == 0
|
||||
? Colors.green
|
||||
: (entry.key == routeEntries.length - 1
|
||||
? Colors.red
|
||||
: Colors.blue),
|
||||
child: Text(
|
||||
'${entry.key + 1}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(entry.value.label),
|
||||
subtitle: Text(
|
||||
'${_routeRoleLabel(entry.key, routeEntries.length)}${entry.value.keyLabel == null ? '' : ' • ${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : ' • ${entry.value.matchSummary}'}',
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
'Relays (${relayNodes.length})',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
if (relayNodes.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Text(
|
||||
'No relay nodes could be matched for this contact.',
|
||||
),
|
||||
),
|
||||
...relayNodes.map(
|
||||
(node) => ListTile(
|
||||
leading: const Icon(Icons.router),
|
||||
title: Text(node.name),
|
||||
subtitle: Text(
|
||||
'${node.publicKey.substring(0, math.min(12, node.publicKey.length))} • '
|
||||
'${node.latitude.toStringAsFixed(5)}, ${node.longitude.toStringAsFixed(5)}',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<_RouteDisplayEntry> _displayRouteEntries(_ContactTraceResult trace) {
|
||||
final entries = <_RouteDisplayEntry>[];
|
||||
if (trace.sender.node != null) {
|
||||
entries.add(_RouteDisplayEntry.fromResolved(trace.sender));
|
||||
}
|
||||
entries.addAll(
|
||||
trace.matchedRelayNodes.asMap().entries.map((entry) {
|
||||
final resolved = entry.value;
|
||||
final node = resolved.node;
|
||||
final hashHex = trace.routeHashes[entry.key].toUpperCase();
|
||||
return _RouteDisplayEntry(
|
||||
resolved: resolved,
|
||||
label: node?.name ?? 'Unknown',
|
||||
keyLabel: node != null ? _prefixKeyLabel(node.publicKey) : hashHex,
|
||||
matchSummary: resolved.matchSummary,
|
||||
);
|
||||
}),
|
||||
);
|
||||
if (trace.recipient.node != null) {
|
||||
entries.add(_RouteDisplayEntry.fromResolved(trace.recipient));
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
String _routeRoleLabel(int index, int total) {
|
||||
if (index == 0) return 'Sender';
|
||||
if (index == total - 1) return 'Recipient';
|
||||
return 'Relay';
|
||||
}
|
||||
|
||||
String _prefixKeyLabel(String publicKey) =>
|
||||
publicKey.substring(0, math.min(12, publicKey.length));
|
||||
|
||||
bool _isCompleteTrace(_ContactTraceResult trace) {
|
||||
if (trace.sender.node == null || trace.recipient.node == null) {
|
||||
return false;
|
||||
}
|
||||
if (trace.routeHashes.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
return trace.matchedRelayNodes.every((node) => node.node != null);
|
||||
}
|
||||
|
||||
_ContactTraceResult _buildTraceResult({
|
||||
required List<MeshMapNode> nodes,
|
||||
required Set<String> localPublicKeys,
|
||||
required List<int>? selfPublicKey,
|
||||
}) {
|
||||
final senderNode = TraceNodeResolver.resolveBest(
|
||||
nodes: nodes,
|
||||
localPublicKeys: localPublicKeys,
|
||||
prefixHex: _toPrefixHex(selfPublicKey),
|
||||
);
|
||||
final recipientNode = TraceNodeResolver.resolveBest(
|
||||
nodes: nodes,
|
||||
localPublicKeys: localPublicKeys,
|
||||
prefixHex: _toPrefixHex(widget.contact.publicKey),
|
||||
);
|
||||
final senderLatLng = senderNode.node == null
|
||||
? null
|
||||
: LatLng(senderNode.node!.latitude, senderNode.node!.longitude);
|
||||
final recipientLatLng = recipientNode.node == null
|
||||
? null
|
||||
: LatLng(recipientNode.node!.latitude, recipientNode.node!.longitude);
|
||||
final routeHashes =
|
||||
widget.contact.routeHasPath && widget.contact.routeHopCount > 0
|
||||
? widget.contact.routeCanonicalText
|
||||
.split(',')
|
||||
.where((token) => token.isNotEmpty)
|
||||
.map((token) => token.toLowerCase())
|
||||
.toList()
|
||||
: const <String>[];
|
||||
|
||||
final matchedRelayNodes = routeHashes
|
||||
.map(
|
||||
(hash) => TraceNodeResolver.resolveBest(
|
||||
nodes: nodes,
|
||||
localPublicKeys: localPublicKeys,
|
||||
prefixHex: hash,
|
||||
referenceA: senderLatLng,
|
||||
referenceB: recipientLatLng,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
return _ContactTraceResult(
|
||||
sender: senderNode,
|
||||
recipient: recipientNode,
|
||||
routeHashes: routeHashes,
|
||||
matchedRelayNodes: matchedRelayNodes,
|
||||
);
|
||||
}
|
||||
|
||||
String? _toPrefixHex(List<int>? key) {
|
||||
if (key == null || key.isEmpty) return null;
|
||||
final take = key.length < 6 ? key.length : 6;
|
||||
return key
|
||||
.take(take)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
List<MeshMapNode> _localNodesFromContacts(
|
||||
ContactsProvider contactsProvider, {
|
||||
required ConnectionProvider connectionProvider,
|
||||
}) {
|
||||
final nodes = contactsProvider.contactsWithLocation
|
||||
.map((contact) {
|
||||
final location = contact.displayLocation;
|
||||
if (location == null) return null;
|
||||
return MeshMapNode(
|
||||
type: contact.type.index,
|
||||
name: contact.displayName,
|
||||
publicKey: contact.publicKeyHex.toLowerCase(),
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
updatedAtMs: contact.lastAdvert * 1000,
|
||||
);
|
||||
})
|
||||
.whereType<MeshMapNode>()
|
||||
.toList();
|
||||
|
||||
final selfNode = _selfNode(connectionProvider);
|
||||
if (selfNode != null) {
|
||||
nodes.add(selfNode);
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
MeshMapNode? _selfNode(ConnectionProvider connectionProvider) {
|
||||
final publicKey = connectionProvider.deviceInfo.publicKey;
|
||||
final advLat = connectionProvider.deviceInfo.advLat;
|
||||
final advLon = connectionProvider.deviceInfo.advLon;
|
||||
if (publicKey == null || advLat == null || advLon == null) {
|
||||
return null;
|
||||
}
|
||||
if (advLat == 0 && advLon == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return MeshMapNode(
|
||||
type: -1,
|
||||
name: connectionProvider.deviceInfo.selfName?.trim().isNotEmpty == true
|
||||
? connectionProvider.deviceInfo.selfName!.trim()
|
||||
: 'You',
|
||||
publicKey: publicKey
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toLowerCase(),
|
||||
latitude: advLat / 1e6,
|
||||
longitude: advLon / 1e6,
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch,
|
||||
);
|
||||
}
|
||||
|
||||
List<MeshMapNode> _mergeNodes(
|
||||
List<MeshMapNode> preferred,
|
||||
List<MeshMapNode> fallback,
|
||||
) {
|
||||
final merged = <String, MeshMapNode>{};
|
||||
for (final node in fallback) {
|
||||
merged[node.publicKey] = node;
|
||||
}
|
||||
for (final node in preferred) {
|
||||
merged[node.publicKey] = node;
|
||||
}
|
||||
return merged.values.toList();
|
||||
}
|
||||
}
|
||||
|
||||
class _ContactTraceResult {
|
||||
final ResolvedTraceNode sender;
|
||||
final ResolvedTraceNode recipient;
|
||||
final List<String> routeHashes;
|
||||
final List<ResolvedTraceNode> matchedRelayNodes;
|
||||
|
||||
const _ContactTraceResult({
|
||||
required this.sender,
|
||||
required this.recipient,
|
||||
required this.routeHashes,
|
||||
required this.matchedRelayNodes,
|
||||
});
|
||||
}
|
||||
|
||||
class _RouteDisplayEntry {
|
||||
final ResolvedTraceNode resolved;
|
||||
final String label;
|
||||
final String? keyLabel;
|
||||
final String? matchSummary;
|
||||
|
||||
const _RouteDisplayEntry({
|
||||
required this.resolved,
|
||||
required this.label,
|
||||
required this.keyLabel,
|
||||
required this.matchSummary,
|
||||
});
|
||||
|
||||
MeshMapNode? get node => resolved.node;
|
||||
|
||||
factory _RouteDisplayEntry.fromResolved(ResolvedTraceNode resolved) {
|
||||
final node = resolved.node!;
|
||||
return _RouteDisplayEntry(
|
||||
resolved: resolved,
|
||||
label: node.name,
|
||||
keyLabel: node.publicKey.substring(
|
||||
0,
|
||||
math.min(12, node.publicKey.length),
|
||||
),
|
||||
matchSummary: resolved.matchSummary,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user