import 'dart:async'; 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/connector/meshcore_connector.dart'; import 'package:meshcore_open/connector/meshcore_protocol.dart'; import 'package:meshcore_open/helpers/path_helper.dart'; import 'package:meshcore_open/l10n/l10n.dart'; import 'package:meshcore_open/models/app_settings.dart'; import 'package:meshcore_open/models/contact.dart'; import 'package:meshcore_open/services/app_settings_service.dart'; import 'package:meshcore_open/services/map_tile_cache_service.dart'; import 'package:meshcore_open/utils/app_logger.dart'; import 'package:meshcore_open/widgets/snr_indicator.dart'; import 'package:provider/provider.dart'; double getPathDistanceMeters(List points) { if (points.length <= 1) return 0.0; double distanceMeters = 0.0; final distanceCalculator = Distance(); for (int i = 0; i < points.length - 1; i++) { distanceMeters += distanceCalculator(points[i], points[i + 1]); } return distanceMeters; } String formatDistance(double distanceMeters, {required bool isImperial}) { if (isImperial) { return '(${(distanceMeters / 1609.34).toStringAsFixed(2)} mi)'; } return '(${(distanceMeters / 1000).toStringAsFixed(2)} km)'; } class PathTraceData { final List pathData; final List snrData; final Map pathContacts; PathTraceData({ required this.pathData, required this.snrData, required this.pathContacts, }); } String _hopKey(Uint8List hopBytes) => PathHelper.formatHopHex(hopBytes); Uint8List _lastHopChunk(Uint8List path, int hopWidth) { if (path.isEmpty) return Uint8List(0); final width = hopWidth.clamp(1, path.length); return Uint8List.fromList(path.sublist(path.length - width)); } bool _matchesHopPrefix(Uint8List a, Uint8List b) { if (a.isEmpty || b.isEmpty) return false; final width = min(a.length, b.length); return listEquals(a.sublist(0, width), b.sublist(0, width)); } class PathTraceMapScreen extends StatefulWidget { final String title; final Uint8List path; final int? repeaterId; final bool flipPathAround; final bool reversePathAround; final Contact? targetContact; final int pathHashByteWidth; final List? pathContacts; const PathTraceMapScreen({ super.key, required this.title, required this.path, this.repeaterId, this.flipPathAround = false, this.reversePathAround = false, this.targetContact, this.pathHashByteWidth = pathHashSize, this.pathContacts, }); @override State createState() => _PathTraceMapScreenState(); } class _PathTraceMapScreenState extends State { static const double _labelZoomThreshold = 8.5; static const double _mapMinZoom = 2.0; static const double _mapMaxZoom = 18.0; //miles to meters conversion for filtering out repeaters that are too far from the last known GPS hop to be a likely match, to avoid false matches that throw off the inferred positions of other hops in the path static const double _maxRepeaterMatchDistanceMeters = 40 * 1609.344; final MapController _mapController = MapController(); StreamSubscription? _frameSubscription; Timer? _timeoutTimer; bool _isLoading = false; bool _failed2Loaded = false; bool _hasData = false; PathTraceData? _traceData; // Inferred positions for hops that have no GPS location, keyed by hop prefix. Map _inferredHopPositions = {}; // Endpoint position for the target contact (GPS or guessed). LatLng? _targetContactPosition; bool _targetContactIsGuessed = false; List _points = []; List _polylines = []; LatLng? _initialCenter = LatLng(0, 0); double _initialZoom = 2.0; LatLngBounds? _bounds; ValueKey _mapKey = const ValueKey('initial'); double _pathDistanceMeters = 0.0; bool _showNodeLabels = true; Contact? _targetContact; Uint8List? _sentTagBytes; String _formatPathPrefixes(Uint8List pathBytes, [int? hashByteWidth]) { return PathHelper.splitPathBytes( pathBytes, hashByteWidth ?? widget.pathHashByteWidth, ).map(PathHelper.formatHopHex).join(','); } int _traceHashByteWidth(int pathHashByteWidth) { final width = pathHashByteWidth.clamp(1, pubKeySize).toInt(); if (width <= 1) return 1; if (width == 2) return 2; // Trace packets encode hash width as 1 << flags, so 3-byte path hashes // must be traced with a 4-byte public-key prefix. return 4; } int _traceFlagsForHashWidth(int traceHashByteWidth) { if (traceHashByteWidth <= 1) return 0; if (traceHashByteWidth == 2) return 1; return 2; } Uint8List _reversePathByHop(Uint8List pathBytes, int hopWidth) { final reversedHops = PathHelper.splitPathBytes( pathBytes, hopWidth, ).reversed; final bytes = []; for (final hop in reversedHops) { bytes.addAll(hop); } return Uint8List.fromList(bytes); } Uint8List? _expandHopForTrace( Uint8List hop, int traceHashByteWidth, MeshCoreConnector connector, ) { if (hop.length == traceHashByteWidth) return hop; final candidates = [ ...?widget.pathContacts, if (widget.targetContact != null) widget.targetContact!, ...connector.allContactsUnfiltered, ]; for (final contact in candidates) { if (contact.publicKey.length < traceHashByteWidth) continue; if (!listEquals(contact.publicKey.sublist(0, hop.length), hop)) continue; return Uint8List.fromList( contact.publicKey.sublist(0, traceHashByteWidth), ); } return null; } Uint8List? _tracePathFromBytes( Uint8List pathBytes, int traceHashByteWidth, MeshCoreConnector connector, ) { final hops = PathHelper.splitPathBytes(pathBytes, widget.pathHashByteWidth); final traceBytes = []; for (final hop in hops) { final traceHop = _expandHopForTrace(hop, traceHashByteWidth, connector); if (traceHop == null) return null; traceBytes.addAll(traceHop); } return Uint8List.fromList(traceBytes); } @override void initState() { super.initState(); _setupFrameListener(); _doPathTrace(); } @override void dispose() { _mapController.dispose(); _frameSubscription?.cancel(); _timeoutTimer?.cancel(); super.dispose(); } bool _isDesktopPlatform(TargetPlatform platform) { return platform == TargetPlatform.linux || platform == TargetPlatform.windows || platform == TargetPlatform.macOS; } void _zoomMapBy(double delta) { final camera = _mapController.camera; final nextZoom = (camera.zoom + delta) .clamp(_mapMinZoom, _mapMaxZoom) .toDouble(); _mapController.move(camera.center, nextZoom); } void _resetMapView() { final bounds = _bounds; if (bounds != null) { _mapController.fitCamera( CameraFit.bounds( bounds: bounds, padding: const EdgeInsets.all(64), maxZoom: 16, ), ); return; } final center = _initialCenter; if (center != null) { _mapController.move(center, _initialZoom); } } Widget _buildDesktopMapControls() { return Positioned( top: 16, left: 16, child: Card( elevation: 4, child: Column( mainAxisSize: MainAxisSize.min, children: [ IconButton( icon: const Icon(Icons.add), tooltip: 'Zoom in', onPressed: () => _zoomMapBy(1), ), IconButton( icon: const Icon(Icons.remove), tooltip: 'Zoom out', onPressed: () => _zoomMapBy(-1), ), IconButton( icon: const Icon(Icons.my_location), tooltip: 'Center map', onPressed: _resetMapView, ), ], ), ), ); } Uint8List? buildPath( Uint8List pathBytes, int traceHashByteWidth, MeshCoreConnector connector, ) { final pathHops = PathHelper.splitPathBytes( pathBytes, widget.pathHashByteWidth, ); final hopWidth = traceHashByteWidth.clamp(1, pubKeySize).toInt(); // Compute targetPrefix if targetContact is provided Uint8List? targetPrefix; if (widget.targetContact != null) { final pk = widget.targetContact!.publicKey; if (pk.isNotEmpty) { final len = pk.length >= hopWidth ? hopWidth : pk.length; targetPrefix = Uint8List.fromList(pk.sublist(0, len)); } } final outboundHops = []; for (final hop in pathHops) { final traceHop = _expandHopForTrace(hop, traceHashByteWidth, connector); if (traceHop == null) return null; outboundHops.add(traceHop); } if (targetPrefix != null) { // Check if targetPrefix is already the last hop in pathHops to avoid duplication bool alreadyEndedWithTarget = false; if (outboundHops.isNotEmpty) { if (listEquals(outboundHops.last, targetPrefix)) { alreadyEndedWithTarget = true; } } if (!alreadyEndedWithTarget) { outboundHops.add(targetPrefix); } } if (outboundHops.isEmpty) { return Uint8List(0); } final mirroredHops = [...outboundHops]; if (outboundHops.length > 1) { mirroredHops.addAll( outboundHops.sublist(0, outboundHops.length - 1).reversed, ); } final traceBytes = []; for (final hop in mirroredHops) { traceBytes.addAll(hop); } return Uint8List.fromList(traceBytes); } Future _doPathTrace() async { if (mounted) { setState(() { _isLoading = true; _failed2Loaded = false; }); } final connector = Provider.of(context, listen: false); final traceHashByteWidth = _traceHashByteWidth(widget.pathHashByteWidth); final pathTmp = widget.reversePathAround ? _reversePathByHop(widget.path, widget.pathHashByteWidth) : widget.path; final path = widget.flipPathAround ? buildPath(pathTmp, traceHashByteWidth, connector) : _tracePathFromBytes(pathTmp, traceHashByteWidth, connector); if (path == null) { if (!mounted) return; setState(() { _isLoading = false; _failed2Loaded = true; }); return; } appLogger.info( 'Initiating path trace with path: ' '${_formatPathPrefixes(path, traceHashByteWidth)}', tag: 'PathTraceMapScreen', noNotify: !mounted, ); final sentTag = DateTime.now().millisecondsSinceEpoch ~/ 1000; _sentTagBytes = Uint8List(4) ..[0] = sentTag & 0xFF ..[1] = (sentTag >> 8) & 0xFF ..[2] = (sentTag >> 16) & 0xFF ..[3] = (sentTag >> 24) & 0xFF; final flags = _traceFlagsForHashWidth(traceHashByteWidth); final tracePayload = path.isEmpty ? Uint8List.fromList([0x00]) : path; final frame = buildTraceReq( sentTag, 0, // auth flags, // flag payload: tracePayload, ); connector.sendFrame(frame); } void _setupFrameListener() { final connector = Provider.of(context, listen: false); Uint8List tagData = Uint8List(4); // Listen for incoming text messages from the repeater _frameSubscription = connector.receivedFrames.listen((frame) { if (frame.isEmpty) return; final frameBuffer = BufferReader(frame); try { final code = frameBuffer.readUInt8(); if (code == respCodeSent) { frameBuffer.skipBytes(1); //reserved tagData = frameBuffer.readBytes(4); final timeoutMilliseconds = frameBuffer.readUInt32LE(); // Start timeout timer for trace response _timeoutTimer?.cancel(); _timeoutTimer = Timer( Duration(milliseconds: timeoutMilliseconds), () { if (!mounted) return; setState(() { _isLoading = false; _failed2Loaded = true; }); }, ); } if (code == respCodeErr) { _timeoutTimer?.cancel(); if (!mounted) return; setState(() { _isLoading = false; _failed2Loaded = true; }); } // Check if it's a binary response if (frame.length >= 12 && code == pushCodeTraceData && (listEquals(frame.sublist(4, 8), _sentTagBytes) || listEquals(frame.sublist(4, 8), tagData))) { _timeoutTimer?.cancel(); if (!mounted) return; _handleTraceResponse(frame); } } catch (e) { _timeoutTimer?.cancel(); if (!mounted) return; setState(() { _isLoading = false; _failed2Loaded = true; }); // Handle any parsing errors gracefully appLogger.error('Error parsing frame: $e', tag: 'PathTraceMapScreen'); } }); } Future _handleTraceResponse(Uint8List frame) async { final connector = Provider.of(context, listen: false); final buffer = BufferReader(frame); try { buffer.skipBytes(2); // Skip push code and reserved byte final pathLenByte = buffer.readUInt8(); final flags = buffer.readUInt8(); buffer.skipBytes(4); // Skip tag data buffer.skipBytes(4); // Skip auth code var width = _traceHashByteWidth(1 << (flags & 0x03)); var pathLength = pathLenByte == 0xFF ? 0 : pathLenByte; if (pathLength > buffer.remaining && (pathLenByte & 0xC0) != 0) { final packedWidth = ((pathLenByte & 0xC0) >> 6) + 1; final packedLength = (pathLenByte & 0x3F) * packedWidth; if (packedLength <= buffer.remaining) { width = packedWidth; pathLength = packedLength; } } final pathBytes = buffer.readBytes(pathLength); final pathData = PathHelper.splitPathBytes(pathBytes, width); List snrData = buffer .readRemainingBytes() .map((snr) => snr.toSigned(8).toDouble() / 4) .toList(); Map pathContacts = {}; Contact lastContact = Contact( path: Uint8List(0), pathLength: 0, publicKey: connector.selfPublicKey ?? Uint8List(0), name: context.l10n.pathTrace_you, type: advTypeChat, latitude: connector.selfLatitude, longitude: connector.selfLongitude, lastSeen: DateTime.now(), ); if (widget.pathContacts != null) { final hopWidth = width.clamp(1, pubKeySize).toInt(); pathContacts = { for (var c in widget.pathContacts!) if (c.publicKey.length >= hopWidth) _hopKey(Uint8List.fromList(c.publicKey.sublist(0, hopWidth))): c, }; } else { final contacts = connector.allContactsUnfiltered; contacts.where((c) => c.type != advTypeChat).forEach((repeater) { if (lastContact.latitude != null && lastContact.longitude != null && repeater.hasLocation && lastContact.hasLocation && Distance().distance( LatLng(lastContact.latitude!, lastContact.longitude!), LatLng(repeater.latitude!, repeater.longitude!), ) > _maxRepeaterMatchDistanceMeters) { return; //skip reapeaters that are far away from the last one with known GPS, to avoid false matches } for (final repeaterData in pathData) { final hopWidth = repeaterData.length; if (repeater.publicKey.length < hopWidth) continue; if (listEquals( repeater.publicKey.sublist(0, hopWidth), repeaterData, )) { pathContacts[_hopKey(repeaterData)] = repeater; lastContact = repeater; } } }); } // For hops with no GPS contact, infer position from other contacts // with known GPS that share the same last-hop byte. final Map inferredPositions = {}; for (final hop in pathData) { final hopKey = _hopKey(hop); final contact = pathContacts[hopKey]; if (contact != null && contact.hasLocation) continue; final peers = connector.contacts .where( (c) => c.hasLocation && c.path.isNotEmpty && _matchesHopPrefix( _lastHopChunk(c.path, widget.pathHashByteWidth), hop, ), ) .toList(); if (peers.isNotEmpty) { final lat = peers.map((c) => c.latitude!).reduce((a, b) => a + b) / peers.length; final lon = peers.map((c) => c.longitude!).reduce((a, b) => a + b) / peers.length; inferredPositions[hopKey] = LatLng(lat, lon); } } setState(() { _isLoading = false; _hasData = true; _inferredHopPositions = inferredPositions; _traceData = PathTraceData( pathData: pathData, snrData: snrData, pathContacts: pathContacts, ); // Compute endpoint position for the target contact. LatLng? targetPos; bool targetGuessed = false; _targetContact = widget.targetContact; if (_targetContact != null) { final tc = _targetContact!; if (tc.hasLocation) { targetPos = LatLng(tc.latitude!, tc.longitude!); } else if (pathData.length > 1) { // Infer from the last hop: average GPS contacts sharing that hop. // For a round-trip path (flipPathAround/reversePathAround), the target-side hop // sits in the middle of the symmetric sequence; .last is the local side. final lastHop = widget.reversePathAround ? pathData.first : pathData.last; final lastHopKey = _hopKey(lastHop); final peers = connector.allContacts .where( (c) => c.hasLocation && c.path.isNotEmpty && _matchesHopPrefix( _lastHopChunk(c.path, widget.pathHashByteWidth), lastHop, ), ) .toList(); if (peers.isNotEmpty) { final lat = peers.map((c) => c.latitude!).reduce((a, b) => a + b) / peers.length; final lon = peers.map((c) => c.longitude!).reduce((a, b) => a + b) / peers.length; const offsetDeg = 0.003; final angle = (tc.publicKey[1] / 255.0) * 2 * pi; targetPos = LatLng( lat + offsetDeg * cos(angle), lon + offsetDeg * sin(angle), ); targetGuessed = true; } else if (inferredPositions.containsKey(lastHopKey)) { final lat = inferredPositions[lastHopKey]!.latitude; final lon = inferredPositions[lastHopKey]!.longitude; const offsetDeg = 0.003; final angle = (tc.publicKey[1] / 255.0) * 2 * pi; targetPos = LatLng( lat + offsetDeg * cos(angle), lon + offsetDeg * sin(angle), ); targetGuessed = true; } else { // As a last resort, just place it at the same position as the last hop. final contact = pathContacts[lastHopKey]; if (contact != null && contact.hasLocation) { const offsetDeg = 0.003; final angle = (tc.publicKey[1] / 255.0) * 2 * pi; targetPos = LatLng( contact.latitude! + offsetDeg * cos(angle), contact.longitude! + offsetDeg * sin(angle), ); targetGuessed = true; } } } } _targetContactPosition = targetPos; _targetContactIsGuessed = targetGuessed; _points = []; _points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!)); String hopLast = ''; String hopLastLast = ''; for (final hop in _traceData!.pathData) { final hopKey = _hopKey(hop); if (hopKey == hopLastLast && widget.flipPathAround) { break; //skip duplicate hops in round-trip paths } final contact = _traceData!.pathContacts[hopKey]; if (contact != null && contact.hasLocation) { _points.add(LatLng(contact.latitude!, contact.longitude!)); } else { final inferred = inferredPositions[hopKey]; if (inferred != null) _points.add(inferred); } hopLastLast = hopLast; hopLast = hopKey; } if (targetPos != null) { if (_targetContact != null && _targetContact!.type == advTypeChat) { _points.add(targetPos); } } _polylines = _points.length > 1 ? [ Polyline( points: _points, strokeWidth: 4, color: Colors.blueAccent, ), ] : []; _initialCenter = _points.isNotEmpty ? _points.first : const LatLng(0, 0); _initialZoom = _points.isNotEmpty ? 13.0 : 2.0; _bounds = _points.length > 1 ? LatLngBounds.fromPoints(_points) : null; _mapKey = ValueKey( '${context.l10n.pathTrace_you},${_traceData!.pathData.map(PathHelper.formatHopHex).join(',')}', ); _pathDistanceMeters = getPathDistanceMeters(_points); }); } catch (e) { appLogger.error( 'Error handling trace response: $e', tag: 'PathTraceMapScreen', ); if (mounted) { setState(() { _isLoading = false; _failed2Loaded = true; }); } } } @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(); return Scaffold( appBar: AppBar( title: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ FittedBox( fit: BoxFit.scaleDown, child: Text( widget.title, style: const TextStyle(fontSize: 24), ), ), ], ), centerTitle: false, actions: [ IconButton( icon: _isLoading ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.refresh), onPressed: _isLoading ? null : _doPathTrace, tooltip: context.l10n.pathTrace_refreshTooltip, ), ], ), body: SafeArea( top: false, child: Stack( children: [ if (!_hasData) Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ if (_isLoading) const CircularProgressIndicator(), const SizedBox(height: 16), if (!_isLoading && _failed2Loaded) Text(context.l10n.pathTrace_notAvailable), ], ), ), if (_hasData) _buildMapPathTrace(context, tileCache, _targetContact), if (_hasData && _isDesktopPlatform(defaultTargetPlatform)) _buildDesktopMapControls(), if (_points.isEmpty && !_hasData && !_isLoading && !_failed2Loaded) Center( child: Card( color: Colors.white.withValues(alpha: 0.9), child: Padding( padding: EdgeInsets.all(12), child: Text( context.l10n.channelPath_noRepeaterLocations, ), ), ), ), if (_hasData) _buildLegendCard(context, _traceData!, isImperial), ], ), ), ); }, ); } List _buildHopMarkers( List pathData, { required bool showLabels, required Contact? target, }) { final markers = []; String hopLast = ''; String hopLastLast = ''; for (final hop in pathData) { final hopKey = _hopKey(hop); final contact = _traceData!.pathContacts[hopKey]; final inferred = _inferredHopPositions[hopKey]; final hasGps = contact != null && contact.hasLocation; if (hopKey == hopLastLast && widget.flipPathAround) { continue; //skip duplicate hops in round-trip paths } if (!hasGps && inferred == null) { hopLastLast = hopLast; hopLast = hopKey; continue; //skip hops with no GPS and no inferred position } final point = hasGps ? LatLng(contact.latitude!, contact.longitude!) : inferred!; final label = PathHelper.formatHopHex(hop); final shortLabel = label.length > 2 ? label.substring(0, 2) : label; final fullLabel = label.length > 2 ? (contact?.name != null ? '$label: ${contact!.name}' : label) : (contact?.name ?? label); markers.add( Marker( point: point, width: 35, height: 35, child: Container( padding: const EdgeInsets.all(4), decoration: BoxDecoration( color: hasGps ? Colors.green : Colors.orange.withValues(alpha: 0.75), 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( hasGps ? shortLabel : '~$shortLabel', style: const TextStyle( color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12, ), ), ), ), ); if (showLabels) { markers.add(_buildNodeLabelMarker(point: point, label: fullLabel)); } hopLastLast = hopLast; hopLast = hopKey; } 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: 35, height: 35, child: Container( padding: const EdgeInsets.all(4), decoration: BoxDecoration( color: Colors.blue, 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, ), ); } } // Add target contact endpoint marker. final targetPos = _targetContactPosition; if (targetPos != null && target != null && target.type == advTypeChat) { final isGuessed = _targetContactIsGuessed; final targetName = target.name; markers.add( Marker( point: targetPos, width: 35, height: 35, child: Container( padding: const EdgeInsets.all(4), decoration: BoxDecoration( color: isGuessed ? Colors.purple.withValues(alpha: 0.55) : Colors.red, 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: const Icon(Icons.person, color: Colors.white, size: 18), ), ), ); if (showLabels) { markers.add( _buildNodeLabelMarker( point: targetPos, label: isGuessed ? '~$targetName' : targetName, ), ); } } 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, ), ), ), ), ), ), ); } String formatDirectionText(PathTraceData pathTraceData, int index) { if (pathTraceData.pathData.isEmpty) { return context.l10n.pathTrace_you; } if (index < 0 || index > pathTraceData.pathData.length) { return context.l10n.pathTrace_you; } if (index == 0 || index == pathTraceData.pathData.length) { if (index == 0) { return context.l10n.pathTrace_you; } else { final hop = pathTraceData.pathData.last; final contactName = pathTraceData.pathContacts[_hopKey(hop)]?.name; final hex = PathHelper.formatHopHex(hop); return contactName != null ? "$hex: $contactName" : "$hex: ${context.l10n.channelPath_unknownRepeater}"; } } else { final hop = pathTraceData.pathData[index - 1]; final contactName = pathTraceData.pathContacts[_hopKey(hop)]?.name; final hex = PathHelper.formatHopHex(hop); return contactName != null ? "$hex: $contactName" : "$hex: ${context.l10n.channelPath_unknownRepeater}"; } } String formatDirectionSubText(PathTraceData pathTraceData, int index) { if (pathTraceData.pathData.isEmpty) { return context.l10n.pathTrace_you; } if (index < 0 || index > pathTraceData.pathData.length) { return context.l10n.pathTrace_you; } if (index == 0 || index == pathTraceData.pathData.length) { if (index == 0) { final hop = pathTraceData.pathData.first; final contactName = pathTraceData.pathContacts[_hopKey(hop)]?.name; final hex = PathHelper.formatHopHex(hop); return contactName != null ? "$hex: $contactName" : "$hex: ${context.l10n.channelPath_unknownRepeater}"; } else { return context.l10n.pathTrace_you; } } else { final hop = pathTraceData.pathData[index]; final contactName = pathTraceData.pathContacts[_hopKey(hop)]?.name; final hex = PathHelper.formatHopHex(hop); return contactName != null ? "$hex: $contactName" : "$hex: ${context.l10n.channelPath_unknownRepeater}"; } } Widget _buildMapPathTrace( BuildContext context, MapTileCacheService tileCache, Contact? target, ) { final isDesktop = _isDesktopPlatform(defaultTargetPlatform); return FlutterMap( key: _mapKey, mapController: _mapController, options: MapOptions( 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(), ), initialCenter: _initialCenter!, initialZoom: _initialZoom, initialCameraFit: _bounds == null ? null : CameraFit.bounds( bounds: _bounds!, padding: const EdgeInsets.all(64), maxZoom: 16, ), minZoom: _mapMinZoom, maxZoom: _mapMaxZoom, onPositionChanged: (camera, hasGesture) { final shouldShow = camera.zoom >= _labelZoomThreshold; if (shouldShow != _showNodeLabels && mounted) { setState(() { _showNodeLabels = shouldShow; }); } }, ), children: [ TileLayer( urlTemplate: kMapTileUrlTemplate, tileProvider: tileCache.tileProvider, userAgentPackageName: MapTileCacheService.userAgentPackageName, maxZoom: 19, ), if (_polylines.isNotEmpty) PolylineLayer(polylines: _polylines), if (_traceData!.pathData.isNotEmpty) MarkerLayer( markers: _buildHopMarkers( _traceData!.pathData, showLabels: _showNodeLabels, target: target, ), ), ], ); } Widget _buildLegendCard( BuildContext context, PathTraceData pathTraceData, bool isImperial, ) { final l10n = context.l10n; final maxHeight = MediaQuery.of(context).size.height * 0.35; final estimatedHeight = 72.0 + (pathTraceData.pathData.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: Text( '${l10n.channelPath_repeaterHops} ${formatDistance(_pathDistanceMeters, isImperial: isImperial)}', style: const TextStyle(fontWeight: FontWeight.w600), ), ), const Divider(height: 1), Expanded( child: pathTraceData.pathData.isEmpty ? Center( child: Text(l10n.channelPath_noHopDetailsAvailable), ) : Scrollbar( child: ListView.separated( padding: const EdgeInsets.symmetric(vertical: 4), itemCount: pathTraceData.pathData.length + 1, separatorBuilder: (_, _) => const Divider(height: 1), itemBuilder: (context, index) { final snrUi = snrUiFromSNR( index < pathTraceData.snrData.length ? pathTraceData.snrData[index] : null, context.read().currentSf, ); return Column( children: [ ListTile( leading: index >= pathTraceData.snrData.length / 2 ? Icon(Icons.call_received) : Icon(Icons.call_made), title: Text( formatDirectionText(pathTraceData, index), style: const TextStyle(fontSize: 14), ), subtitle: Text( formatDirectionSubText( pathTraceData, index, ), style: const TextStyle(fontSize: 14), ), trailing: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( snrUi.icon, color: snrUi.color, size: 18.0, ), Text( snrUi.text, style: TextStyle( fontSize: 10, color: snrUi.color, ), ), ], ), onTap: () { // Handle item tap }, ), ], ); }, ), ), ), ], ), ), ), ); } }