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 Uint8List pathData; final List snrData; final Map pathContacts; /// Bytes per hop (1 << path_sz) parsed from the trace response flag. (#150) final int hopBytes; PathTraceData({ required this.pathData, required this.snrData, required this.pathContacts, this.hopBytes = 1, }); /// Path hops as packed big-endian prefixes of [hopBytes] each. (#150) List get hops => PathHelper.tracePathHops(pathData, hopBytes); } 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 byte. 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; @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) { final hopBytes = PathHelper.traceHopBytes(widget.pathHashByteWidth); final pk = widget.targetContact?.publicKey; // Per the firmware trace contract, the route must EXCLUDE our own origin // prefix (it is implicit at both ends). Drop a leading self hop if the // stored path includes it — otherwise route[0] matches no forwarding node // (we are the sender) and the packet never advances → silent timeout. (#150) var route = pathBytes; final self = context.read().selfPublicKey; if (self != null && self.length >= hopBytes && route.length >= hopBytes) { var isSelf = true; for (var i = 0; i < hopBytes; i++) { if (route[i] != self[i]) { isSelf = false; break; } } if (isSelf) route = Uint8List.fromList(route.sublist(hopBytes)); } // Empty path, or an all-zero "flood" marker, means no real route — trace // the target's own width prefix directly. Never return an empty payload: // the firmware rejects an empty trace with error 1. (#150) if (route.isEmpty || route.every((b) => b == 0)) { if (pk != null && pk.length >= hopBytes) { return Uint8List.fromList(pk.sublist(0, hopBytes)); } return Uint8List.fromList([pk != null && pk.isNotEmpty ? pk[0] : 0]); } final throughTarget = widget.targetContact?.type == advTypeRepeater || widget.targetContact?.type == advTypeRoom; final targetPrefix = throughTarget ? (pk != null && pk.length >= hopBytes ? pk.sublist(0, hopBytes) : [pk != null && pk.isNotEmpty ? pk[0] : 0]) : const []; return Uint8List.fromList( PathHelper.buildTraceRoundTrip( routingPath: route, routingWidth: widget.pathHashByteWidth < 1 ? 1 : widget.pathHashByteWidth, hopBytes: hopBytes, throughTarget: throughTarget, targetPrefix: targetPrefix, ), ); } Future _doPathTrace() async { if (mounted) { setState(() { _isLoading = true; _failed2Loaded = false; }); } final pathTmp = widget.reversePathAround ? Uint8List.fromList(widget.path.reversed.toList()) : widget.path; final path = widget.flipPathAround ? buildPath(pathTmp) : pathTmp; if (path.isEmpty) { // No route to trace — don't send an empty payload (firmware error 1); // surface "not available" instead. (#150) appLogger.info( 'Path trace skipped: no route to trace', tag: 'PathTraceMapScreen', noNotify: !mounted, ); if (mounted) { setState(() { _isLoading = false; _failed2Loaded = true; }); } return; } appLogger.info( 'Initiating path trace with path: ' '${PathHelper.formatPathHex(path, PathHelper.traceHopBytes(widget.pathHashByteWidth))}', tag: 'PathTraceMapScreen', noNotify: !mounted, ); final connector = Provider.of(context, listen: false); // path_sz tells the firmware the hop width; the payload hops are built at // the matching width (1 << path_sz bytes each). (#150) final pathSz = PathHelper.tracePathSz(widget.pathHashByteWidth); final frame = buildTraceReq( DateTime.now().millisecondsSinceEpoch ~/ 1000, 0, // auth pathSz, // flag payload: path, ); 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 > 8 && code == pushCodeTraceData && listEquals(frame.sublist(4, 8), tagData)) { _timeoutTimer?.cancel(); if (!mounted) return; frameBuffer.skipBytes(3); //reserved + path length + flag if (listEquals(frameBuffer.readBytes(4), tagData)) { _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 int pathLength = buffer.readUInt8(); final pathSz = buffer.readUInt8() & 0x03; // flag byte → trace hop width final hopBytes = 1 << pathSz; buffer.skipBytes(4); // tag data buffer.skipBytes(4); // auth code Uint8List pathData = buffer.readBytes(pathLength); final hopList = PathHelper.tracePathHops(pathData, hopBytes); // One SNR per hop, plus a trailing final SNR. 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) { pathContacts = { for (var c in widget.pathContacts!) if (c.publicKey.length >= hopBytes) PathHelper.packPrefix(c.publicKey, 0, hopBytes): 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 } if (repeater.publicKey.length < hopBytes) return; final repeaterHop = PathHelper.packPrefix( repeater.publicKey, 0, hopBytes, ); if (hopList.contains(repeaterHop)) { pathContacts[repeaterHop] = 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 hopList) { final contact = pathContacts[hop]; if (contact != null && contact.hasLocation) continue; final peers = connector.contacts .where( (c) => c.hasLocation && c.path.length >= hopBytes && PathHelper.packPrefix( c.path, c.path.length - hopBytes, hopBytes, ) == 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[hop] = LatLng(lat, lon); } } setState(() { _isLoading = false; _hasData = true; _inferredHopPositions = inferredPositions; _traceData = PathTraceData( pathData: pathData, snrData: snrData, pathContacts: pathContacts, hopBytes: hopBytes, ); // 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 (widget.path.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 hb = PathHelper.traceHopBytes(widget.pathHashByteWidth); final lastHop = widget.path.length < hb ? 0 : (widget.reversePathAround ? PathHelper.packPrefix(widget.path, 0, hb) : PathHelper.packPrefix( widget.path, widget.path.length - hb, hb, )); final peers = connector.allContacts .where( (c) => c.hasLocation && c.path.length >= hb && PathHelper.packPrefix(c.path, c.path.length - hb, hb) == 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(lastHop)) { final lat = inferredPositions[lastHop]!.latitude; final lon = inferredPositions[lastHop]!.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[lastHop]; 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!)); int hopLast = 0; int hopLastLast = 0; for (final hop in _traceData!.hops) { if (hop == hopLastLast && widget.flipPathAround) { break; //skip duplicate hops in round-trip paths } final contact = _traceData!.pathContacts[hop]; if (contact != null && contact.hasLocation) { _points.add(LatLng(contact.latitude!, contact.longitude!)); } else { final inferred = inferredPositions[hop]; if (inferred != null) _points.add(inferred); } hopLastLast = hopLast; hopLast = hop; } 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},${PathHelper.formatPathHex(_traceData!.pathData, _traceData!.hopBytes)}', ); _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 hops, { required bool showLabels, required Contact? target, }) { final markers = []; int hopLast = 0; int hopLastLast = 0; for (final hop in hops) { final contact = _traceData!.pathContacts[hop]; final inferred = _inferredHopPositions[hop]; final hasGps = contact != null && contact.hasLocation; if (hop == hopLastLast && widget.flipPathAround) { continue; //skip duplicate hops in round-trip paths } if (!hasGps && inferred == null) { hopLastLast = hopLast; hopLast = hop; continue; //skip hops with no GPS and no inferred position } final point = hasGps ? LatLng(contact.latitude!, contact.longitude!) : inferred!; final label = hop .toRadixString(16) .padLeft(_traceData!.hopBytes * 2, '0') .toUpperCase(); 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 ? label : '~$label', style: const TextStyle( color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12, ), ), ), ), ); if (showLabels) { markers.add( _buildNodeLabelMarker( point: point, label: contact?.name ?? '~$label', ), ); } hopLastLast = hopLast; hopLast = hop; } 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) { final hops = pathTraceData.hops; final hexLen = pathTraceData.hopBytes * 2; if (index == 0 || index == pathTraceData.snrData.length - 1) { if (index == 0) { return context.l10n.pathTrace_you; } else { final hop = hops.isNotEmpty ? hops.last : 0; final contactName = pathTraceData.pathContacts[hop]?.name; final hex = hop.toRadixString(16).padLeft(hexLen, '0').toUpperCase(); return contactName != null ? "$hex: $contactName" : "$hex: ${context.l10n.channelPath_unknownRepeater}"; } } else { final hop = index - 1 < hops.length ? hops[index - 1] : 0; final contactName = pathTraceData.pathContacts[hop]?.name; final hex = hop.toRadixString(16).padLeft(hexLen, '0').toUpperCase(); return contactName != null ? "$hex: $contactName" : "$hex: ${context.l10n.channelPath_unknownRepeater}"; } } String formatDirectionSubText(PathTraceData pathTraceData, int index) { final hops = pathTraceData.hops; final hexLen = pathTraceData.hopBytes * 2; if (index == 0 || index == pathTraceData.snrData.length - 1) { if (index == 0) { final hop = hops.isNotEmpty ? hops.first : 0; final contactName = pathTraceData.pathContacts[hop]?.name; final hex = hop.toRadixString(16).padLeft(hexLen, '0').toUpperCase(); return contactName != null ? "$hex: $contactName" : "$hex: ${context.l10n.channelPath_unknownRepeater}"; } else { return context.l10n.pathTrace_you; } } else { final hop = index < hops.length ? hops[index] : 0; final contactName = pathTraceData.pathContacts[hop]?.name; final hex = hop.toRadixString(16).padLeft(hexLen, '0').toUpperCase(); 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!.hops.isNotEmpty) MarkerLayer( markers: _buildHopMarkers( _traceData!.hops, 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.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: Text( '${l10n.channelPath_repeaterHops} ${formatDistance(_pathDistanceMeters, isImperial: isImperial)}', style: const TextStyle(fontWeight: FontWeight.w600), ), ), const Divider(height: 1), Expanded( child: pathTraceData.hops.isEmpty ? Center( child: Text(l10n.channelPath_noHopDetailsAvailable), ) : Scrollbar( child: ListView.separated( padding: const EdgeInsets.symmetric(vertical: 4), itemCount: pathTraceData.hops.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 }, ), ], ); }, ), ), ), ], ), ), ), ); } }