import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import 'package:meshcore_open/helpers/path_helper.dart'; import 'package:meshcore_open/screens/path_trace_map.dart'; import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; import '../services/map_tile_cache_service.dart'; import '../services/app_settings_service.dart'; import '../connector/meshcore_protocol.dart'; import '../l10n/app_localizations.dart'; import '../l10n/l10n.dart'; import '../models/channel_message.dart'; import '../models/app_settings.dart'; import '../models/contact.dart'; import '../widgets/adaptive_app_bar_title.dart'; class ChannelMessagePathScreen extends StatelessWidget { final ChannelMessage message; final bool channelMessage; const ChannelMessagePathScreen({ super.key, required this.message, this.channelMessage = false, }); @override Widget build(BuildContext context) { return Consumer( builder: (context, connector, _) { final l10n = context.l10n; final primaryPath = _selectPrimaryPath( message.pathBytes, message.pathVariants, ); final hashByteWidth = (message.pathHashWidth ?? connector.pathHashByteWidth) .clamp(1, 4) .toInt(); final hops = _buildPathHops( primaryPath, connector, l10n, hashByteWidth, ); final hasHopDetails = primaryPath.isNotEmpty; // Convert observed path byte length to hop count using the packet width. // Legacy messages fall back to the current connector width. // Reported path length (V3+) is already stored as a hop count; preserve // the negative flood sentinel when present. final observedHopCount = _hopCountFromBytes( primaryPath.length, hashByteWidth, ); final reportedHopCount = message.pathLength; final effectiveHopCount = observedHopCount > 0 ? observedHopCount : reportedHopCount; final observedLabel = _formatObservedHops( observedHopCount, effectiveHopCount, l10n, ); final extraPaths = _otherPaths(primaryPath, message.pathVariants); return Scaffold( appBar: AppBar( title: AdaptiveAppBarTitle(l10n.channelPath_title), actions: [ IconButton( icon: const Icon(Icons.radar_outlined), tooltip: l10n.channelPath_viewMap, onPressed: () => Navigator.push( context, MaterialPageRoute( builder: (context) => PathTraceMapScreen( title: context.l10n.contacts_repeaterPathTrace, path: primaryPath, flipPathAround: true, reversePathAround: false, pathHashByteWidth: hashByteWidth, ), ), ), ), IconButton( icon: const Icon(Icons.map_outlined), tooltip: l10n.channelPath_viewMap, onPressed: hasHopDetails ? () { _openPathMap(context, channelMessage: channelMessage); } : null, ), ], ), body: SafeArea( top: false, child: ListView( padding: const EdgeInsets.all(16), children: [ _buildSummaryCard( context, observedLabel: observedLabel, effectiveHopCount: effectiveHopCount, ), const SizedBox(height: 16), if (extraPaths.isNotEmpty) ...[ Text( l10n.channelPath_otherObservedPaths, style: Theme.of(context).textTheme.titleSmall, ), const SizedBox(height: 8), _buildPathVariants(context, extraPaths, hashByteWidth), const SizedBox(height: 16), ], Text( l10n.channelPath_repeaterHops, style: Theme.of(context).textTheme.titleSmall, ), const SizedBox(height: 8), if (!hasHopDetails) Text( l10n.channelPath_noHopDetails, style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant, ), ) else ..._buildHopTiles(context, hops), ], ), ), ); }, ); } Widget _buildSummaryCard( BuildContext context, { String? observedLabel, required int? effectiveHopCount, }) { final l10n = context.l10n; return Card( child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.channelPath_messageDetails, style: Theme.of(context).textTheme.titleSmall, ), const SizedBox(height: 8), _buildDetailRow( context, l10n.channelPath_senderLabel, message.senderName, ), _buildDetailRow( context, l10n.channelPath_timeLabel, _formatTime(message.timestamp, l10n), ), if (message.repeatCount > 0) _buildDetailRow( context, l10n.channelPath_repeatsLabel, message.repeatCount.toString(), ), _buildDetailRow( context, l10n.channelPath_pathLabelTitle, _formatPathLabel(effectiveHopCount, l10n), ), if (observedLabel != null) _buildDetailRow( context, l10n.channelPath_observedLabel, observedLabel, ), ], ), ), ); } Widget _buildPathVariants( BuildContext context, List variants, int hashByteWidth, ) { final l10n = context.l10n; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ for (int i = 0; i < variants.length; i++) Card( margin: const EdgeInsets.symmetric(vertical: 4), child: ListTile( dense: true, title: Text( l10n.channelPath_observedPathTitle( i + 1, _formatHopCount(variants[i].length, hashByteWidth, l10n), ), ), subtitle: Text(_formatPathPrefixes(variants[i], hashByteWidth)), trailing: const Icon(Icons.map_outlined, size: 20), onTap: () => _openPathMap( context, initialPath: variants[i], channelMessage: channelMessage, ), ), ), ], ); } List _buildHopTiles(BuildContext context, List<_PathHop> hops) { final l10n = context.l10n; return [ for (final hop in hops) Card( margin: const EdgeInsets.symmetric(vertical: 4), child: ListTile( dense: true, leading: CircleAvatar( radius: 14, child: Text( hop.index.toString(), style: const TextStyle(fontSize: 12), ), ), title: Text(hop.displayLabel), subtitle: Text( hop.hasLocation ? '${hop.position!.latitude.toStringAsFixed(5)}, ' '${hop.position!.longitude.toStringAsFixed(5)}' : l10n.channelPath_noLocationData, ), ), ), ]; } String _formatTime(DateTime time, AppLocalizations l10n) { final now = DateTime.now(); final diff = now.difference(time); if (diff.inDays > 0) { final timeLabel = '${time.hour}:${time.minute.toString().padLeft(2, '0')}'; return l10n.channelPath_timeWithDate(time.day, time.month, timeLabel); } return l10n.channelPath_timeOnly( '${time.hour}:${time.minute.toString().padLeft(2, '0')}', ); } String _formatPathLabel(int? hopCount, AppLocalizations l10n) { if (hopCount == null) return l10n.channelPath_unknownPath; if (hopCount < 0) return l10n.channelPath_floodPath; if (hopCount == 0) return l10n.channelPath_directPath; return l10n.chat_hopsCount(hopCount); } String? _formatObservedHops( int observedCount, int? targetHopCount, AppLocalizations l10n, ) { if (observedCount <= 0 && (targetHopCount == null || targetHopCount <= 0)) { return null; } if (targetHopCount == null || targetHopCount < 0) { return observedCount > 0 ? l10n.chat_hopsCount(observedCount) : null; } if (observedCount == 0) { return l10n.channelPath_observedZeroOf(targetHopCount); } if (observedCount == targetHopCount) { return l10n.chat_hopsCount(observedCount); } return l10n.channelPath_observedSomeOf(observedCount, targetHopCount); } Widget _buildDetailRow(BuildContext context, String label, String value) { return Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 70, child: Text( label, style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ), Expanded(child: Text(value)), ], ), ); } void _openPathMap( BuildContext context, { Uint8List? initialPath, bool channelMessage = false, }) { Navigator.push( context, MaterialPageRoute( builder: (context) => ChannelMessagePathMapScreen( message: message, initialPath: initialPath, channelMessage: channelMessage, ), ), ); } } class ChannelMessagePathMapScreen extends StatefulWidget { final ChannelMessage message; final Uint8List? initialPath; final bool channelMessage; const ChannelMessagePathMapScreen({ super.key, required this.message, this.initialPath, this.channelMessage = false, }); @override State createState() => _ChannelMessagePathMapScreenState(); } class _ChannelMessagePathMapScreenState extends State { static const double _labelZoomThreshold = 8.5; static const double _mapMinZoom = 2.0; static const double _mapMaxZoom = 18.0; final MapController _mapController = MapController(); Uint8List? _selectedPath; double _pathDistance = 0.0; bool _showNodeLabels = true; bool _didReceivePositionUpdate = false; int? _focusedHopIndex; @override void initState() { super.initState(); _selectedPath = widget.initialPath; } @override void didUpdateWidget(ChannelMessagePathMapScreen oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.message != widget.message || !_pathsEqual( oldWidget.initialPath ?? Uint8List(0), widget.initialPath ?? Uint8List(0), )) { _selectedPath = widget.initialPath; } } @override void dispose() { _mapController.dispose(); super.dispose(); } bool _isDesktopPlatform(TargetPlatform platform) { return platform == TargetPlatform.linux || platform == TargetPlatform.windows || platform == TargetPlatform.macOS; } double _getPathDistance(List points) { double totalDistance = 0.0; final distanceCalculator = Distance(); for (int i = 0; i < points.length - 1; i++) { totalDistance += distanceCalculator(points[i], points[i + 1]); } return totalDistance; } void _focusHop(_PathHop hop) { if (!hop.hasLocation) return; final targetZoom = _didReceivePositionUpdate ? max(_mapController.camera.zoom, 10.0) : 12.0; _mapController.move(hop.position!, targetZoom); } void _onHopTapped(_PathHop hop) { _focusHop(hop); if (!mounted) return; setState(() { _focusedHopIndex = hop.index; }); } void _zoomMapBy(double delta) { final camera = _mapController.camera; final nextZoom = (camera.zoom + delta) .clamp(_mapMinZoom, _mapMaxZoom) .toDouble(); _mapController.move(camera.center, nextZoom); } void _resetMapView({ required LatLng initialCenter, required double initialZoom, required LatLngBounds? bounds, }) { if (bounds != null) { _mapController.fitCamera( CameraFit.bounds( bounds: bounds, padding: const EdgeInsets.all(64), maxZoom: 16, ), ); return; } _mapController.move(initialCenter, initialZoom); } Widget _buildDesktopMapControls({ required LatLng initialCenter, required double initialZoom, required LatLngBounds? bounds, }) { return Positioned( left: 16, top: 16, child: Card( elevation: 4, child: Column( mainAxisSize: MainAxisSize.min, children: [ IconButton( icon: const Icon(Icons.add), tooltip: context.l10n.map_zoomIn, onPressed: () => _zoomMapBy(1), ), IconButton( icon: const Icon(Icons.remove), tooltip: context.l10n.map_zoomOut, onPressed: () => _zoomMapBy(-1), ), IconButton( icon: const Icon(Icons.my_location), tooltip: context.l10n.map_centerMap, onPressed: () => _resetMapView( initialCenter: initialCenter, initialZoom: initialZoom, bounds: bounds, ), ), ], ), ), ); } @override Widget build(BuildContext context) { return Consumer( builder: (context, connector, _) { final settings = context.watch().settings; final isImperial = settings.unitSystem == UnitSystem.imperial; final tileCache = context.read(); final primaryPath = _selectPrimaryPath( widget.message.pathBytes, widget.message.pathVariants, ); final observedPaths = _buildObservedPaths( primaryPath, widget.message.pathVariants, ); final isDesktop = _isDesktopPlatform(defaultTargetPlatform); final selectedPath = _resolveSelectedPath( _selectedPath, observedPaths, primaryPath, ); final width = (widget.message.pathHashWidth ?? connector.pathHashByteWidth) .clamp(1, 4) .toInt(); final selectedIndex = _indexForPath(selectedPath, observedPaths); final hops = _buildPathHops( selectedPath, connector, context.l10n, width, ); final points = []; if ((widget.message.isOutgoing && !widget.channelMessage) || (widget.message.isOutgoing && widget.channelMessage)) { points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!)); } for (final hop in hops) { if (hop.hasLocation) { points.add(hop.position!); } } if ((!widget.message.isOutgoing && !widget.channelMessage) || (!widget.message.isOutgoing && widget.channelMessage)) { points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!)); } final polylines = points.length > 1 ? [ Polyline( points: points, strokeWidth: 4, color: Colors.blueAccent, ), ] : []; final initialCenter = points.isNotEmpty ? points.first : const LatLng(0, 0); final initialZoom = points.isNotEmpty ? 13.0 : 2.0; if (!_didReceivePositionUpdate) { _showNodeLabels = initialZoom >= _labelZoomThreshold; } final bounds = points.length > 1 ? LatLngBounds.fromPoints(points) : null; final mapKey = ValueKey( '${_formatPathPrefixes(selectedPath, width)},${context.l10n.pathTrace_you}', ); _pathDistance = _getPathDistance(points); return Scaffold( appBar: AppBar( title: AdaptiveAppBarTitle(context.l10n.channelPath_mapTitle), ), body: SafeArea( top: false, child: Stack( children: [ FlutterMap( key: mapKey, mapController: _mapController, options: MapOptions( initialCenter: initialCenter, initialZoom: initialZoom, initialCameraFit: bounds == null ? null : CameraFit.bounds( bounds: bounds, padding: const EdgeInsets.all(64), maxZoom: 16, ), minZoom: _mapMinZoom, maxZoom: _mapMaxZoom, interactionOptions: InteractionOptions( flags: ~InteractiveFlag.rotate, scrollWheelVelocity: isDesktop ? 0.012 : 0.005, cursorKeyboardRotationOptions: CursorKeyboardRotationOptions.disabled(), keyboardOptions: isDesktop ? const KeyboardOptions( enableArrowKeysPanning: true, enableWASDPanning: true, enableRFZooming: true, ) : const KeyboardOptions.disabled(), ), onPositionChanged: (camera, hasGesture) { final shouldShow = camera.zoom >= _labelZoomThreshold; if (!_didReceivePositionUpdate || shouldShow != _showNodeLabels) { if (!mounted) return; setState(() { _didReceivePositionUpdate = true; _showNodeLabels = shouldShow; }); } }, ), children: [ TileLayer( urlTemplate: kMapTileUrlTemplate, tileProvider: tileCache.tileProvider, userAgentPackageName: MapTileCacheService.userAgentPackageName, maxZoom: 19, ), if (polylines.isNotEmpty) PolylineLayer(polylines: polylines), MarkerLayer( markers: _buildHopMarkers( hops, showLabels: _showNodeLabels, ), ), ], ), if (isDesktop) _buildDesktopMapControls( initialCenter: initialCenter, initialZoom: initialZoom, bounds: bounds, ), if (observedPaths.length > 1) _buildPathSelector( context, observedPaths, selectedIndex, width, (index) { setState(() { _selectedPath = observedPaths[index].pathBytes; _focusedHopIndex = null; }); }, ), if (points.isEmpty) Center( child: Card( color: Theme.of( context, ).colorScheme.surface.withValues(alpha: 0.9), child: Padding( padding: EdgeInsets.all(12), child: Text( context.l10n.channelPath_noRepeaterLocations, ), ), ), ), _buildLegendCard(context, hops, isImperial), ], ), ), ); }, ); } Widget _buildPathSelector( BuildContext context, List<_ObservedPath> paths, int selectedIndex, int hashByteWidth, ValueChanged onSelected, ) { final l10n = context.l10n; final width = hashByteWidth.clamp(1, 4); final selectedPath = paths[selectedIndex]; final label = selectedPath.isPrimary ? l10n.channelPath_primaryPath(selectedIndex + 1) : l10n.channelPath_pathLabel(selectedIndex + 1); return Positioned( left: 16, right: 16, top: 16, child: SafeArea( child: Card( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.channelPath_observedPathHeader, style: const TextStyle(fontWeight: FontWeight.w600), ), const SizedBox(height: 4), DropdownButtonHideUnderline( child: DropdownButton( isExpanded: true, value: selectedIndex, items: [ for (int i = 0; i < paths.length; i++) DropdownMenuItem( value: i, child: Text( '${paths[i].isPrimary ? l10n.channelPath_primaryPath(i + 1) : l10n.channelPath_pathLabel(i + 1)}' ' • ${_formatHopCount(paths[i].pathBytes.length, width, l10n)}', ), ), ], onChanged: (value) { if (value == null) return; onSelected(value); }, ), ), const SizedBox(height: 4), Text( l10n.channelPath_selectedPathLabel( label, _formatPathPrefixes(selectedPath.pathBytes, width), ), style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12, ), ), ], ), ), ), ), ); } List _buildHopMarkers( List<_PathHop> hops, { required bool showLabels, }) { final markers = []; for (final hop in hops) { if (!hop.hasLocation) continue; final point = hop.position!; markers.add( Marker( point: point, width: 48, height: 48, child: Center( child: Container( width: 35, height: 35, decoration: BoxDecoration( color: Colors.green, shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.3), blurRadius: 4, offset: const Offset(0, 2), ), ], ), alignment: Alignment.center, child: Text( hop.index.toString(), style: const TextStyle( color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12, ), ), ), ), ), ); if (showLabels) { markers.add( _buildNodeLabelMarker( point: point, label: hop.contact?.name ?? _formatPrefix(hop.prefix), ), ); } } final selfLat = context.read().selfLatitude; final selfLon = context.read().selfLongitude; if (selfLat != null && selfLon != null) { final selfPoint = LatLng(selfLat, selfLon); markers.add( Marker( point: selfPoint, width: 48, height: 48, child: Center( child: Container( width: 35, height: 35, decoration: BoxDecoration( color: Colors.teal, shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.3), blurRadius: 4, offset: const Offset(0, 2), ), ], ), alignment: Alignment.center, child: Text( context.l10n.pathTrace_you, style: const TextStyle( color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12, ), ), ), ), ), ); if (showLabels) { markers.add( _buildNodeLabelMarker( point: selfPoint, label: context.l10n.pathTrace_you, ), ); } } return markers; } Marker _buildNodeLabelMarker({required LatLng point, required String label}) { return Marker( point: point, width: 120, height: 24, alignment: Alignment.topCenter, child: IgnorePointer( child: Transform.translate( offset: const Offset(0, -20), child: FittedBox( fit: BoxFit.contain, child: Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: Colors.black54, borderRadius: BorderRadius.circular(8), ), alignment: Alignment.center, child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: Colors.white, fontSize: 11, fontWeight: FontWeight.w500, ), ), ), ), ), ), ); } Widget _colorDot(Color color) => Container( width: 10, height: 10, decoration: BoxDecoration(color: color, shape: BoxShape.circle), ); Widget _buildLegendCard( BuildContext context, List<_PathHop> hops, bool isImperial, ) { final l10n = context.l10n; final maxHeight = MediaQuery.of(context).size.height * 0.35; final estimatedHeight = 72.0 + (hops.length * 56.0); final cardHeight = max(96.0, min(maxHeight, estimatedHeight)); return Positioned( left: 16, right: 16, bottom: 16, child: SizedBox( height: cardHeight, child: Card( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '${l10n.channelPath_repeaterHops} ${formatDistance(_pathDistance, isImperial: isImperial)}', style: const TextStyle(fontWeight: FontWeight.w600), ), const SizedBox(height: 6), Row( children: [ _colorDot(Colors.green), const SizedBox(width: 4), Text( l10n.pathTrace_legendGpsConfirmed, style: const TextStyle(fontSize: 11), ), ], ), ], ), ), const Divider(height: 1), Expanded( child: hops.isEmpty ? Center( child: Text(l10n.channelPath_noHopDetailsAvailable), ) : ListView.separated( padding: const EdgeInsets.symmetric(vertical: 4), itemCount: hops.length, separatorBuilder: (_, _) => const Divider(height: 1), itemBuilder: (context, index) { final hop = hops[index]; final isFocused = _focusedHopIndex == hop.index; return ListTile( dense: true, enabled: hop.hasLocation, selected: isFocused, selectedTileColor: Theme.of( context, ).colorScheme.primary.withValues(alpha: 0.12), onTap: hop.hasLocation ? () => _onHopTapped(hop) : null, leading: CircleAvatar( radius: 14, child: Text( hop.index.toString(), style: const TextStyle(fontSize: 12), ), ), title: Text(hop.displayLabel), subtitle: Text( hop.hasLocation ? '${hop.position!.latitude.toStringAsFixed(5)}, ' '${hop.position!.longitude.toStringAsFixed(5)}' : l10n.channelPath_noLocationData, ), ); }, ), ), ], ), ), ), ); } } class _PathHop { final int index; final int prefix; final Contact? contact; final LatLng? position; final AppLocalizations l10n; final Uint8List? hopBytes; const _PathHop({ required this.index, required this.prefix, required this.contact, required this.position, required this.l10n, this.hopBytes, }); bool get hasLocation => position != null; String get displayLabel { final prefixLabel = hopBytes != null && hopBytes!.isNotEmpty ? hopBytes! .map((b) => b.toRadixString(16).padLeft(2, '0')) .join('') .toUpperCase() : _formatPrefix(prefix); return '($prefixLabel) ${_resolveName(contact, l10n)}'; } } class _ObservedPath { final Uint8List pathBytes; final bool isPrimary; const _ObservedPath({required this.pathBytes, required this.isPrimary}); } List<_PathHop> _buildPathHops( Uint8List pathBytes, MeshCoreConnector connector, AppLocalizations l10n, int hashByteWidth, ) { if (pathBytes.isEmpty) return const []; final width = hashByteWidth.clamp(1, 4); final candidatesByHashBytes = >{}; final allContacts = connector.allContacts; // Build lookup map using hash byte sequences for (final contact in allContacts) { if (contact.publicKey.isEmpty) continue; if (contact.type != advTypeRepeater && contact.type != advTypeRoom) { continue; } // Extract the hash bytes that match the device width final keyBytes = contact.publicKey.sublist( 0, min(width, contact.publicKey.length), ); final keyHex = PathHelper.formatHopHex(keyBytes); candidatesByHashBytes.putIfAbsent(keyHex, () => []).add(contact); } // Sort candidates by last seen for (final candidates in candidatesByHashBytes.values) { candidates.sort((a, b) => b.lastSeen.compareTo(a.lastSeen)); } final startPoint = (connector.selfLatitude != null && connector.selfLongitude != null) ? LatLng(connector.selfLatitude!, connector.selfLongitude!) : null; var previousPosition = startPoint; final distance = Distance(); var bestDistance = 0.0; final hops = <_PathHop>[]; // Process path in hop-sized chunks for (var hopIdx = 0; hopIdx * width < pathBytes.length; hopIdx++) { final startByte = hopIdx * width; final endByte = min(startByte + width, pathBytes.length); final hopBytes = pathBytes.sublist(startByte, endByte); final hopKey = PathHelper.formatHopHex(hopBytes); final searchPoint = hopIdx == 0 ? startPoint : previousPosition; final candidates = candidatesByHashBytes[hopKey]; Contact? contact; if (candidates != null && candidates.isNotEmpty) { var bestIndex = 0; if (searchPoint != null) { bestDistance = double.infinity; for (var j = 0; j < candidates.length; j++) { final candidate = candidates[j]; if (!candidate.hasLocation || candidate.latitude == null || candidate.longitude == null) { continue; } final currentDistance = distance( searchPoint, LatLng(candidate.latitude!, candidate.longitude!), ); if (currentDistance < bestDistance) { bestDistance = currentDistance; bestIndex = j; } } } contact = candidates.removeAt(bestIndex); if (candidates.isEmpty) { candidatesByHashBytes.remove(hopKey); } } final resolvedPosition = _resolvePosition(contact); if (resolvedPosition != null) { previousPosition = resolvedPosition; } // NOTE: removed distance-based rejection filter. Accept the best candidate // even if the distance is large — historical filtering could drop valid // long-distance links and cause cascading mismatches. hops.add( _PathHop( index: hopIdx + 1, prefix: hopBytes.isNotEmpty ? hopBytes[0] : 0, contact: contact, position: resolvedPosition, l10n: l10n, hopBytes: hopBytes, ), ); } return hops; } LatLng? _resolvePosition(Contact? contact) { if (contact == null) return null; if (!contact.hasLocation) return null; final latitude = contact.latitude; final longitude = contact.longitude; if (latitude == null || longitude == null) return null; return LatLng(latitude, longitude); } String _formatPrefix(int prefix) { return prefix.toRadixString(16).padLeft(2, '0').toUpperCase(); } String _formatPathPrefixes(Uint8List pathBytes, int hashByteWidth) { return PathHelper.splitPathBytes( pathBytes, hashByteWidth, ).map(PathHelper.formatHopHex).join(','); } int _hopCountFromBytes(int byteCount, int hashByteWidth) { if (byteCount <= 0) return 0; final width = hashByteWidth.clamp(1, 4); return (byteCount + width - 1) ~/ width; } String _formatHopCount( int byteCount, int hashByteWidth, AppLocalizations l10n, ) { return l10n.chat_hopsCount(_hopCountFromBytes(byteCount, hashByteWidth)); } String _resolveName(Contact? contact, AppLocalizations l10n) { if (contact == null) return l10n.channelPath_unknownRepeater; final name = contact.name.trim(); if (name.isEmpty || name.toLowerCase() == 'unknown') { return l10n.channelPath_unknownRepeater; } return name; } Uint8List _selectPrimaryPath(Uint8List pathBytes, List variants) { Uint8List primary = pathBytes; for (final variant in variants) { if (variant.length > primary.length) { primary = variant; } } return primary; } List _otherPaths(Uint8List primary, List variants) { final others = []; for (final variant in variants) { if (variant.isEmpty) continue; if (!_pathsEqual(primary, variant)) { others.add(variant); } } return others; } List<_ObservedPath> _buildObservedPaths( Uint8List primary, List variants, ) { final observed = <_ObservedPath>[]; void addPath(Uint8List pathBytes, bool isPrimary) { if (pathBytes.isEmpty) return; for (final existing in observed) { if (_pathsEqual(existing.pathBytes, pathBytes)) return; } observed.add(_ObservedPath(pathBytes: pathBytes, isPrimary: isPrimary)); } addPath(primary, true); for (final variant in variants) { addPath(variant, false); } return observed; } Uint8List _resolveSelectedPath( Uint8List? selected, List<_ObservedPath> observedPaths, Uint8List fallback, ) { if (selected != null) { for (final path in observedPaths) { if (_pathsEqual(path.pathBytes, selected)) { return path.pathBytes; } } } if (observedPaths.isNotEmpty) { return observedPaths.first.pathBytes; } return fallback; } int _indexForPath(Uint8List selected, List<_ObservedPath> paths) { for (int i = 0; i < paths.length; i++) { if (_pathsEqual(paths[i].pathBytes, selected)) { return i; } } return 0; } bool _pathsEqual(Uint8List a, Uint8List b) { if (a.length != b.length) return false; for (var i = 0; i < a.length; i++) { if (a[i] != b[i]) return false; } return true; }