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/screens/path_trace_map.dart'; import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; import '../helpers/path_hop_resolver.dart'; import '../services/map_tile_cache_service.dart'; import '../services/app_settings_service.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 '../models/display_path.dart'; import '../models/path_playback.dart'; import '../theme/mesh_theme.dart'; import '../widgets/adaptive_app_bar_title.dart'; import '../widgets/mesh_ui.dart'; import '../widgets/path_map_ui.dart'; import '../widgets/themed_map_tile_layer.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 primaryPathTmp = _selectPrimaryPath( message.pathBytes, message.pathVariants, ); final primaryPath = !channelMessage && !message.isOutgoing ? Uint8List.fromList(primaryPathTmp.reversed.toList()) : primaryPathTmp; final hops = _buildPathHops( primaryPath, connector, l10n, resolveFromEnd: !message.isOutgoing, ); final hasHopDetails = primaryPath.isNotEmpty; final observedLabel = _formatObservedHops( primaryPath.length, message.pathLength, 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: !(!channelMessage && !message.isOutgoing), pathHashByteWidth: context .read() .pathHashByteWidth, ), ), ), ), 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.symmetric(vertical: 8), children: [ _buildSummaryCard(context, observedLabel: observedLabel), if (extraPaths.isNotEmpty) ...[ SectionHeader( l10n.channelPath_otherObservedPaths, padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), ), _buildPathVariants(context, extraPaths), ], SectionHeader( l10n.channelPath_repeaterHops, padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), ), if (!hasHopDetails) _buildNoHopCard(context, l10n) else _buildHopTimeline(context, hops, l10n), const SizedBox(height: 16), ], ), ), ); }, ); } Widget _buildSummaryCard(BuildContext context, {String? observedLabel}) { final l10n = context.l10n; final scheme = Theme.of(context).colorScheme; final routeChip = message.pathLength == null ? null : message.pathLength! < 0 ? const RouteChip(isDirect: false) : RouteChip(isDirect: true, hops: message.pathLength); return MeshCard( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), padding: const EdgeInsets.all(14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: SectionHeader( l10n.channelPath_messageDetails, padding: EdgeInsets.zero, ), ), ?routeChip, ], ), const SizedBox(height: 10), _buildDetailRow( context, l10n.channelPath_senderLabel, message.senderName, scheme: scheme, ), _buildDetailRow( context, l10n.channelPath_timeLabel, _formatTime(message.timestamp, l10n), scheme: scheme, ), if (message.repeatCount > 0) _buildDetailRow( context, l10n.channelPath_repeatsLabel, message.repeatCount.toString(), scheme: scheme, ), _buildDetailRow( context, l10n.channelPath_pathLabelTitle, _formatPathLabel(message.pathLength, l10n), scheme: scheme, ), if (observedLabel != null) _buildDetailRow( context, l10n.channelPath_observedLabel, observedLabel, scheme: scheme, ), ], ), ); } Widget _buildPathVariants(BuildContext context, List variants) { final l10n = context.l10n; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ for (int i = 0; i < variants.length; i++) MeshCard( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), onTap: () => _openPathMap( context, initialPath: variants[i], channelMessage: channelMessage, ), child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.channelPath_observedPathTitle( i + 1, _formatHopCount(variants[i].length, l10n), ), style: Theme.of(context).textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(height: 2), Text( _formatPathPrefixes(variants[i]), style: MeshTheme.mono( fontSize: 11, color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ], ), ), const SizedBox(width: 8), Icon( Icons.map_outlined, size: 20, color: Theme.of(context).colorScheme.onSurfaceVariant, ), ], ), ), ], ); } Widget _buildNoHopCard(BuildContext context, AppLocalizations l10n) { final scheme = Theme.of(context).colorScheme; return MeshCard( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), padding: const EdgeInsets.all(14), child: Row( children: [ Icon(Icons.route_outlined, size: 20, color: scheme.onSurfaceVariant), const SizedBox(width: 10), Expanded( child: Text( l10n.channelPath_noHopDetails, style: TextStyle(color: scheme.onSurfaceVariant), ), ), ], ), ); } Widget _buildHopTimeline( BuildContext context, List<_PathHop> hops, AppLocalizations l10n, ) { if (hops.isEmpty) return const SizedBox.shrink(); return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( children: [ for (int i = 0; i < hops.length; i++) ListEntrance( index: i, child: _buildTimelineNode( context, hops[i], l10n, isLast: i == hops.length - 1, ), ), ], ), ); } Widget _buildTimelineNode( BuildContext context, _PathHop hop, AppLocalizations l10n, { required bool isLast, }) { final scheme = Theme.of(context).colorScheme; final hexPrefix = _formatPrefix(hop.prefix); final locationText = hop.hasLocation ? '${hop.position!.latitude.toStringAsFixed(5)}, ' '${hop.position!.longitude.toStringAsFixed(5)}' : l10n.channelPath_noLocationData; return IntrinsicHeight( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SizedBox( width: 48, child: Column( children: [ Stack( clipBehavior: Clip.none, children: [ AvatarCircle(name: hop.displayLabel, size: 36), Positioned( right: -4, top: -4, child: Container( width: 18, height: 18, decoration: BoxDecoration( color: MeshPalette.blueDim, shape: BoxShape.circle, border: Border.all( color: scheme.surfaceContainerLow, width: 1.5, ), ), alignment: Alignment.center, child: Text( hop.index.toString(), style: const TextStyle( color: Colors.white, fontSize: 9, fontWeight: FontWeight.w700, ), ), ), ), ], ), if (!isLast) Expanded( child: Container( width: 2, margin: const EdgeInsets.symmetric(vertical: 4), color: MeshPalette.blueLine, ), ) else const SizedBox(height: 12), ], ), ), const SizedBox(width: 12), Expanded( child: Padding( padding: const EdgeInsets.only(bottom: 16, top: 4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( hop.displayLabel, style: Theme.of(context).textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(height: 2), Text( hexPrefix, style: MeshTheme.mono( fontSize: 11, color: scheme.onSurfaceVariant, ), ), const SizedBox(height: 2), Text( locationText, style: MeshTheme.mono( fontSize: 11, color: scheme.onSurfaceVariant, ), ), ], ), ), ), ], ), ); } 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? pathLength, AppLocalizations l10n) { if (pathLength == null) return l10n.channelPath_unknownPath; if (pathLength < 0) return l10n.channelPath_floodPath; if (pathLength == 0) return l10n.channelPath_directPath; return l10n.chat_hopsCount(pathLength); } String? _formatObservedHops( int observedCount, int? pathLength, AppLocalizations l10n, ) { if (observedCount <= 0 && (pathLength == null || pathLength <= 0)) { return null; } if (pathLength == null || pathLength < 0) { return observedCount > 0 ? l10n.chat_hopsCount(observedCount) : null; } if (observedCount == 0) { return l10n.channelPath_observedZeroOf(pathLength); } if (observedCount == pathLength) { return l10n.chat_hopsCount(observedCount); } return l10n.channelPath_observedSomeOf(observedCount, pathLength); } Widget _buildDetailRow( BuildContext context, String label, String value, { required ColorScheme scheme, }) { return Padding( padding: const EdgeInsets.symmetric(vertical: 3), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 72, child: Text( label.toUpperCase(), style: MeshTheme.accentLabel(color: scheme.onSurfaceVariant), ), ), const SizedBox(width: 8), 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 with SingleTickerProviderStateMixin { 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; // Packet-flow animation + multi-path view state. late final PathPlaybackController _playback; PathViewMode _viewMode = PathViewMode.single; final Set _hiddenPathIds = {}; bool _panelCollapsed = false; bool _animationEnabled = true; bool _followPacket = false; @override void initState() { super.initState(); _selectedPath = widget.initialPath; _playback = PathPlaybackController(this); _playback.addListener(_followPacketCamera); } /// Keeps the camera centered on the packet while the follow lock is on. void _followPacketCamera() { if (!_followPacket || !_animationEnabled || !_playback.started || !_playback.hasPath || !mounted) { return; } _mapController.move(_playback.position, _mapController.camera.zoom); } void _toggleFollowPacket() { setState(() { _followPacket = !_followPacket; }); _followPacketCamera(); } @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() { _playback.dispose(); _mapController.dispose(); super.dispose(); } /// Builds a renderable [DisplayPath] for one observed route, oriented in /// the direction the packet traveled (sender first, receiver last). DisplayPath? _buildDisplayPath({ required int index, required bool isPrimary, required Uint8List orientedBytes, required List<_PathHop> hops, required MeshCoreConnector connector, }) { final l10n = context.l10n; final selfLat = connector.selfLatitude; final selfLon = connector.selfLongitude; final points = []; final labels = []; final confirmed = []; final rowIdx = []; final gapBefore = []; var pendingGap = false; var locatedHops = 0; void addSelf() { if (selfLat == null || selfLon == null) return; points.add(LatLng(selfLat, selfLon)); labels.add(l10n.pathTrace_you); confirmed.add(true); rowIdx.add(-1); gapBefore.add(pendingGap); pendingGap = false; } final selfFirst = widget.message.isOutgoing; if (selfFirst) addSelf(); for (var i = 0; i < hops.length; i++) { final hop = hops[i]; if (!hop.hasLocation) { pendingGap = true; continue; } locatedHops++; points.add(hop.position!); labels.add(hop.contact?.name ?? _formatPrefix(hop.prefix)); confirmed.add(true); rowIdx.add(i); gapBefore.add(pendingGap); pendingGap = false; } if (!selfFirst) addSelf(); if (points.length < 2) return null; final segmentEstimated = []; final rowForSegment = []; for (var i = 0; i < points.length - 1; i++) { segmentEstimated.add(gapBefore[i + 1]); final dest = rowIdx[i + 1]; rowForSegment.add(dest >= 0 ? dest : (rowIdx[i] >= 0 ? rowIdx[i] : 0)); } return DisplayPath( id: 'op-$index', label: isPrimary ? l10n.pathMap_primary : l10n.pathMap_alternate(index), color: isPrimary ? kPrimaryPathColor : kAlternatePathColors[(index - 1) % kAlternatePathColors.length], isPrimary: isPrimary, hopBytes: List.from(orientedBytes), points: points, pointLabels: labels, pointConfirmed: confirmed, segmentEstimated: segmentEstimated, rowForSegment: rowForSegment, totalTransmissions: hops.length, hasTargetEndpoint: false, gpsConfirmedHops: locatedHops, unresolvedHops: hops.length - locatedHops, distanceMeters: getPathDistanceMeters(points), record: null, ); } /// Updates the playback path after this frame, but only when the selected /// path's geometry actually changed, so rebuilds don't reset a running /// animation. void _schedulePlaybackSync(DisplayPath? selected) { final points = selected?.points ?? const []; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; if (points.length == _playback.points.length) { var same = true; for (var i = 0; i < points.length; i++) { if (points[i] != _playback.points[i]) { same = false; break; } } if (same) return; } _playback.setPath(points); }); } void _selectEntry(_ObservedPathEntry entry) { setState(() { _selectedPath = entry.observedBytes; _hiddenPathIds.remove(entry.display.id); _focusedHopIndex = null; }); } void _togglePathVisibility( DisplayPath path, List<_ObservedPathEntry> entries, DisplayPath? selected, ) { setState(() { if (!_hiddenPathIds.remove(path.id)) { _hiddenPathIds.add(path.id); if (path.id == selected?.id) { final visible = entries.where( (e) => !_hiddenPathIds.contains(e.display.id), ); if (visible.isNotEmpty) { _selectedPath = visible.first.observedBytes; _focusedHopIndex = null; } } } }); } 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 mapScheme = Theme.of(context).colorScheme; final primaryPath = _selectPrimaryPath( widget.message.pathBytes, widget.message.pathVariants, ); final observedPaths = _buildObservedPaths( primaryPath, widget.message.pathVariants, ); final isDesktop = _isDesktopPlatform(defaultTargetPlatform); final selectedPathTmp = _resolveSelectedPath( _selectedPath, observedPaths, primaryPath, ); final selectedPath = _orientPath(selectedPathTmp); // Match on the unoriented bytes — observedPaths stores them as // recorded, while selectedPath may be reversed for display. final selectedIndex = _indexForPath(selectedPathTmp, observedPaths); final hops = _buildPathHops( selectedPath, connector, context.l10n, resolveFromEnd: !widget.message.isOutgoing, ); // Renderable paths for the animation and combined view. final entries = <_ObservedPathEntry>[]; for (var i = 0; i < observedPaths.length; i++) { final oriented = _orientPath(observedPaths[i].pathBytes); final pathHops = i == selectedIndex ? hops : _buildPathHops( oriented, connector, context.l10n, resolveFromEnd: !widget.message.isOutgoing, ); final display = _buildDisplayPath( index: i, isPrimary: observedPaths[i].isPrimary, orientedBytes: oriented, hops: pathHops, connector: connector, ); if (display != null) { entries.add( _ObservedPathEntry( index: i, observedBytes: observedPaths[i].pathBytes, display: display, hops: pathHops, ), ); } } final effectiveMode = entries.length > 1 ? _viewMode : PathViewMode.single; _ObservedPathEntry? selectedEntry; for (final entry in entries) { if (entry.index == selectedIndex) { selectedEntry = entry; break; } } final selectedDisplay = selectedEntry?.display; final visibleEntries = effectiveMode == PathViewMode.single ? [?selectedEntry] : entries .where((e) => !_hiddenPathIds.contains(e.display.id)) .toList(); final visibleDisplays = visibleEntries.map((e) => e.display).toList(); _schedulePlaybackSync(selectedDisplay); 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: MeshPalette.blue, ), ] : []; 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)},${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) { if (!mounted) return; // A manual pan/zoom releases the follow lock. if (hasGesture && _followPacket) { setState(() { _followPacket = false; }); } final shouldShow = camera.zoom >= _labelZoomThreshold; if (!_didReceivePositionUpdate || shouldShow != _showNodeLabels) { setState(() { _didReceivePositionUpdate = true; _showNodeLabels = shouldShow; }); } }, ), children: [ ThemedMapTileLayer(tileCache: tileCache), AnimatedBuilder( animation: _playback, builder: (context, _) { List lines; if (visibleDisplays.isEmpty) { lines = polylines; } else { final animating = _animationEnabled && _playback.started && _playback.hasPath; lines = buildMultiPathPolylines( visible: visibleDisplays, selected: selectedDisplay, combined: effectiveMode == PathViewMode.combined, animating: animating, ); if (animating && selectedDisplay != null) { lines.addAll( buildPacketTrailPolylines( _playback, selectedDisplay.color, ), ); } } if (lines.isEmpty) return const SizedBox.shrink(); return PolylineLayer(polylines: lines); }, ), if (effectiveMode == PathViewMode.combined) MarkerLayer( markers: _buildCombinedHopMarkers( visibleEntries, showLabels: _showNodeLabels, ), ) else MarkerLayer( markers: _buildHopMarkers( hops, showLabels: _showNodeLabels, ), ), AnimatedBuilder( animation: _playback, builder: (context, _) { if (!_animationEnabled || selectedDisplay == null) { return const SizedBox.shrink(); } final markers = buildPacketMarkers( _playback, selectedDisplay.color, ); if (markers.isEmpty) return const SizedBox.shrink(); return MarkerLayer(markers: markers); }, ), ], ), if (isDesktop) _buildDesktopMapControls( initialCenter: initialCenter, initialZoom: initialZoom, bounds: bounds, ), if (entries.length > 1) PathViewModeToggle( mode: effectiveMode, onChanged: (mode) => setState(() => _viewMode = mode), ), if (observedPaths.length > 1 && effectiveMode == PathViewMode.single) _buildPathSelector( context, observedPaths, selectedIndex, (index) { setState(() { _selectedPath = observedPaths[index].pathBytes; _focusedHopIndex = null; }); }, topOffset: entries.length > 1 ? 60.0 : 16.0, ), if (points.isEmpty) Center( child: Container( margin: const EdgeInsets.all(24), padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: mapScheme.surfaceContainerLow, borderRadius: BorderRadius.circular(MeshRadii.md), border: Border.all(color: mapScheme.outlineVariant), ), child: Text(context.l10n.channelPath_noRepeaterLocations), ), ), _buildLegendCard( context, hops, isImperial, entries: entries, selectedEntry: selectedEntry, effectiveMode: effectiveMode, ), ], ), ), ); }, ); } Widget _buildPathSelector( BuildContext context, List<_ObservedPath> paths, int selectedIndex, ValueChanged onSelected, { double topOffset = 16, }) { final l10n = context.l10n; 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: topOffset, 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, l10n)}', ), ), ], onChanged: (value) { if (value == null) return; onSelected(value); }, ), ), const SizedBox(height: 4), Text( l10n.channelPath_selectedPathLabel( label, _formatPathPrefixes(selectedPath.pathBytes), ), 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: MeshPalette.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( 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), ), ); } } markers.addAll(_buildSelfMarkers(showLabels: showLabels)); return markers; } List _buildSelfMarkers({required bool showLabels}) { final selfLat = context.read().selfLatitude; final selfLon = context.read().selfLongitude; if (selfLat == null || selfLon == null) return const []; final markers = []; 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: MeshPalette.signal, 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; } /// Markers for the union of located hops across all visible paths, with a /// badge on repeaters used by more than one path. List _buildCombinedHopMarkers( List<_ObservedPathEntry> visibleEntries, { required bool showLabels, }) { final markers = []; final nodes = {}; for (final entry in visibleEntries) { final seenInPath = {}; for (final hop in entry.hops) { if (!hop.hasLocation) continue; final key = '${hop.prefix}|${hop.position!.latitude.toStringAsFixed(5)},' '${hop.position!.longitude.toStringAsFixed(5)}'; if (!seenInPath.add(key)) continue; nodes.putIfAbsent(key, () => _SharedNode(hop)).paths.add(entry.display); } } for (final node in nodes.values) { final hop = node.hop; final point = hop.position!; final label = _formatPrefix(hop.prefix); final shared = node.paths.length > 1; markers.add( Marker( point: point, width: 48, height: 48, child: GestureDetector( onTap: () => showSharedNodeSheet( context, title: '$label: ${_resolveName(hop.contact, context.l10n)}', paths: node.paths, onSelect: (display) { for (final entry in visibleEntries) { if (entry.display.id == display.id) { _selectEntry(entry); break; } } }, ), child: Stack( alignment: Alignment.center, children: [ Container( width: 35, height: 35, decoration: BoxDecoration( color: MeshPalette.blue, shape: BoxShape.circle, border: Border.all( color: Colors.white, width: shared ? 2.5 : 2, ), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.3), blurRadius: 4, offset: const Offset(0, 2), ), ], ), alignment: Alignment.center, child: Text( label, style: const TextStyle( color: Colors.white, fontWeight: FontWeight.bold, fontSize: 11, ), ), ), if (shared) Positioned( top: 0, right: 0, child: Container( width: 17, height: 17, decoration: BoxDecoration( shape: BoxShape.circle, color: MeshPalette.bg1, border: Border.all(color: MeshPalette.line3), ), alignment: Alignment.center, child: Text( '${node.paths.length}', style: MeshTheme.mono( fontSize: 9, fontWeight: FontWeight.w700, color: MeshPalette.ink, ), ), ), ), ], ), ), ), ); if (showLabels) { markers.add( _buildNodeLabelMarker( point: point, label: hop.contact?.name ?? label, ), ); } } markers.addAll(_buildSelfMarkers(showLabels: showLabels)); return markers; } /// Orients recorded path bytes in the direction the packet traveled. Uint8List _orientPath(Uint8List bytes) { final reverse = (!widget.message.isOutgoing && !widget.channelMessage) || (widget.message.isOutgoing && widget.channelMessage); return reverse ? Uint8List.fromList(bytes.reversed.toList()) : bytes; } 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 _buildLegendCard( BuildContext context, List<_PathHop> hops, bool isImperial, { required List<_ObservedPathEntry> entries, required _ObservedPathEntry? selectedEntry, required PathViewMode effectiveMode, }) { final l10n = context.l10n; final combined = effectiveMode == PathViewMode.combined; final selectedDisplay = selectedEntry?.display; final maxHeight = MediaQuery.of(context).size.height * (combined ? 0.45 : 0.35); double cardHeight; if (_panelCollapsed) { cardHeight = 128; } else { final summaryHeight = combined ? 34.0 + entries.length * 36.0 : 0; final estimatedHeight = 132.0 + summaryHeight + hops.length * 56.0; cardHeight = max(176.0, min(maxHeight, estimatedHeight)); } final hopUseCount = {}; if (combined) { for (final entry in entries) { if (_hiddenPathIds.contains(entry.display.id)) continue; for (final prefix in entry.hops.map((h) => h.prefix).toSet()) { hopUseCount.update(prefix, (v) => v + 1, ifAbsent: () => 1); } } } return Positioned( left: 16, right: 16, bottom: 16, child: SizedBox( height: cardHeight, child: Container( decoration: BoxDecoration( color: MeshPalette.bg1, borderRadius: BorderRadius.circular(MeshRadii.md), border: Border.all(color: MeshPalette.line2), ), clipBehavior: Clip.antiAlias, child: DefaultTextStyle( style: const TextStyle(color: MeshPalette.ink), child: IconTheme( data: const IconThemeData(color: MeshPalette.ink), child: TextButtonTheme( data: TextButtonThemeData( style: TextButton.styleFrom(foregroundColor: MeshPalette.ink), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.fromLTRB(12, 8, 4, 0), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text( l10n.channelPath_repeaterHops, style: const TextStyle( fontWeight: FontWeight.w600, ), ), ), Text( formatDistance( selectedDisplay?.distanceMeters ?? _pathDistance, isImperial: isImperial, ), style: MeshTheme.mono( fontSize: 12, color: MeshPalette.ink2, ), ), ], ), const SizedBox(height: 4), PathMiniLegend( combined: combined, showInferred: false, ), ], ), ), IconButton( visualDensity: VisualDensity.compact, icon: Icon( _panelCollapsed ? Icons.expand_less : Icons.expand_more, size: 20, ), tooltip: _panelCollapsed ? l10n.pathMap_expandPanel : l10n.pathMap_collapsePanel, onPressed: () => setState( () => _panelCollapsed = !_panelCollapsed, ), ), ], ), ), PathAnimationControls( playback: _playback, selected: selectedDisplay, animationEnabled: _animationEnabled, onToggleAnimation: () => setState(() { _animationEnabled = !_animationEnabled; if (!_animationEnabled) _playback.stop(); }), followEnabled: _followPacket, onToggleFollow: _toggleFollowPacket, ), if (!_panelCollapsed) ...[ if (selectedDisplay != null && selectedDisplay.unresolvedHops > 0) Padding( padding: const EdgeInsets.fromLTRB(12, 0, 12, 4), child: Text( l10n.pathMap_partialAnimation( selectedDisplay.unresolvedHops, ), style: TextStyle( fontSize: 10.5, color: MeshPalette.warn, ), ), ), if (combined) PathSummaryList( paths: entries.map((e) => e.display).toList(), selectedId: selectedDisplay?.id ?? '', hiddenIds: _hiddenPathIds, isImperial: isImperial, onSelect: (display) { for (final entry in entries) { if (entry.display.id == display.id) { _selectEntry(entry); break; } } }, onToggleVisibility: (display) => _togglePathVisibility( display, entries, selectedDisplay, ), onShowAll: () => setState(_hiddenPathIds.clear), ), const Divider(height: 1), Expanded( child: _buildHopListView( hops, selectedDisplay, hopUseCount, ), ), ], ], ), ), ), ), ), ), ); } Widget _buildHopListView( List<_PathHop> hops, DisplayPath? selectedDisplay, Map hopUseCount, ) { final l10n = context.l10n; if (hops.isEmpty) { return Center(child: Text(l10n.channelPath_noHopDetailsAvailable)); } return ValueListenableBuilder( valueListenable: _playback.activeSegment, builder: (context, activeSegment, _) { int highlightRow = -1; if (_animationEnabled && selectedDisplay != null && activeSegment >= 0 && activeSegment < selectedDisplay.rowForSegment.length) { highlightRow = selectedDisplay.rowForSegment[activeSegment]; } final highlightColor = (selectedDisplay?.color ?? MeshPalette.blue) .withValues(alpha: 0.14); return 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; final sharedCount = hopUseCount[hop.prefix] ?? 0; return InkWell( onTap: hop.hasLocation ? () => _onHopTapped(hop) : null, child: Container( color: index == highlightRow ? highlightColor : isFocused ? MeshPalette.blueBg : Colors.transparent, padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 8, ), child: Row( children: [ Container( width: 28, height: 28, decoration: BoxDecoration( color: MeshPalette.blueDim.withValues(alpha: 0.3), shape: BoxShape.circle, border: Border.all( color: MeshPalette.blueDim.withValues(alpha: 0.5), ), ), alignment: Alignment.center, child: Text( hop.index.toString(), style: const TextStyle( color: Colors.white, fontSize: 11, fontWeight: FontWeight.w700, ), ), ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( hop.displayLabel, style: const TextStyle( fontWeight: FontWeight.w600, fontSize: 13, ), overflow: TextOverflow.ellipsis, ), Text( [ hop.hasLocation ? '${hop.position!.latitude.toStringAsFixed(5)}, ' '${hop.position!.longitude.toStringAsFixed(5)}' : context.l10n.channelPath_noLocationData, if (sharedCount > 1) context.l10n.pathMap_sharedNodeCount( sharedCount, ), ].join(' · '), style: MeshTheme.mono( fontSize: 10, color: MeshPalette.ink3, ), overflow: TextOverflow.ellipsis, ), ], ), ), ], ), ), ); }, ); }, ); } } /// One observed route paired with its renderable form and resolved hops. class _ObservedPathEntry { final int index; final Uint8List observedBytes; final DisplayPath display; final List<_PathHop> hops; const _ObservedPathEntry({ required this.index, required this.observedBytes, required this.display, required this.hops, }); } /// A located hop shared across one or more visible paths. class _SharedNode { final _PathHop hop; final List paths = []; _SharedNode(this.hop); } class _PathHop { final int index; final int prefix; final Contact? contact; final LatLng? position; final AppLocalizations l10n; const _PathHop({ required this.index, required this.prefix, required this.contact, required this.position, required this.l10n, }); bool get hasLocation => position != null; String get displayLabel { final prefixLabel = _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, { bool resolveFromEnd = false, }) { if (pathBytes.isEmpty) return const []; final endpoint = (connector.selfLatitude != null && connector.selfLongitude != null) ? LatLng(connector.selfLatitude!, connector.selfLongitude!) : null; final resolvedContacts = PathHopResolver.resolve( pathBytes: pathBytes, contacts: connector.allContacts, endpoint: endpoint, resolveFromEnd: resolveFromEnd, ); final hops = <_PathHop>[]; for (var i = 0; i < pathBytes.length; i++) { final contact = resolvedContacts[i]; final resolvedPosition = _resolvePosition(contact); hops.add( _PathHop( index: i + 1, prefix: pathBytes[i], contact: contact, position: resolvedPosition, l10n: l10n, ), ); } 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) { return pathBytes .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) .join(','); } String _formatHopCount(int count, AppLocalizations l10n) { return l10n.chat_hopsCount(count); } 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; }