import 'dart:math'; import 'dart:typed_data'; 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 '../services/map_tile_cache_service.dart'; import '../services/app_settings_service.dart'; import '../connector/meshcore_protocol.dart'; import '../l10n/app_localizations.dart'; import '../l10n/l10n.dart'; import '../models/channel_message.dart'; import '../models/app_settings.dart'; import '../models/contact.dart'; import '../widgets/adaptive_app_bar_title.dart'; class ChannelMessagePathScreen extends StatelessWidget { final ChannelMessage message; final bool channelMessage; const ChannelMessagePathScreen({ super.key, required this.message, this.channelMessage = false, }); @override Widget build(BuildContext context) { return Consumer( builder: (context, connector, _) { final l10n = context.l10n; final primaryPathTmp = _selectPrimaryPath( message.pathBytes, message.pathVariants, ); final primaryPath = !channelMessage && !message.isOutgoing ? Uint8List.fromList(primaryPathTmp.reversed.toList()) : primaryPathTmp; final hops = _buildPathHops(primaryPath, connector, l10n); 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), ), ), ), ), IconButton( icon: const Icon(Icons.map_outlined), tooltip: l10n.channelPath_viewMap, onPressed: hasHopDetails ? () { _openPathMap(context, channelMessage: channelMessage); } : null, ), ], ), body: SafeArea( top: false, child: ListView( padding: const EdgeInsets.all(16), children: [ _buildSummaryCard(context, observedLabel: observedLabel), const SizedBox(height: 16), if (extraPaths.isNotEmpty) ...[ Text( l10n.channelPath_otherObservedPaths, style: Theme.of(context).textTheme.titleSmall, ), const SizedBox(height: 8), _buildPathVariants(context, extraPaths), const SizedBox(height: 16), ], Text( l10n.channelPath_repeaterHops, style: Theme.of(context).textTheme.titleSmall, ), const SizedBox(height: 8), if (!hasHopDetails) Text( l10n.channelPath_noHopDetails, style: const TextStyle(color: Colors.grey), ) else ..._buildHopTiles(context, hops), ], ), ), ); }, ); } Widget _buildSummaryCard(BuildContext context, {String? observedLabel}) { final l10n = context.l10n; return Card( child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.channelPath_messageDetails, style: Theme.of(context).textTheme.titleSmall, ), const SizedBox(height: 8), _buildDetailRow(l10n.channelPath_senderLabel, message.senderName), _buildDetailRow( l10n.channelPath_timeLabel, _formatTime(message.timestamp, l10n), ), if (message.repeatCount > 0) _buildDetailRow( l10n.channelPath_repeatsLabel, message.repeatCount.toString(), ), _buildDetailRow( l10n.channelPath_pathLabelTitle, _formatPathLabel(message.pathLength, l10n), ), if (observedLabel != null) _buildDetailRow(l10n.channelPath_observedLabel, observedLabel), ], ), ), ); } Widget _buildPathVariants(BuildContext context, List variants) { final l10n = context.l10n; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ for (int i = 0; i < variants.length; i++) Card( margin: const EdgeInsets.symmetric(vertical: 4), child: ListTile( dense: true, title: Text( l10n.channelPath_observedPathTitle( i + 1, _formatHopCount(variants[i].length, l10n), ), ), subtitle: Text(_formatPathPrefixes(variants[i])), trailing: const Icon(Icons.map_outlined, size: 20), onTap: () => _openPathMap( context, initialPath: variants[i], channelMessage: channelMessage, ), ), ), ], ); } List _buildHopTiles(BuildContext context, List<_PathHop> hops) { final l10n = context.l10n; return [ for (final hop in hops) Card( margin: const EdgeInsets.symmetric(vertical: 4), child: ListTile( dense: true, leading: CircleAvatar( radius: 14, child: Text( hop.index.toString(), style: const TextStyle(fontSize: 12), ), ), title: Text(hop.displayLabel), subtitle: Text( hop.hasLocation ? '${hop.position!.latitude.toStringAsFixed(5)}, ' '${hop.position!.longitude.toStringAsFixed(5)}' : l10n.channelPath_noLocationData, ), ), ), ]; } String _formatTime(DateTime time, AppLocalizations l10n) { final now = DateTime.now(); final diff = now.difference(time); if (diff.inDays > 0) { final timeLabel = '${time.hour}:${time.minute.toString().padLeft(2, '0')}'; return l10n.channelPath_timeWithDate(time.day, time.month, timeLabel); } return l10n.channelPath_timeOnly( '${time.hour}:${time.minute.toString().padLeft(2, '0')}', ); } String _formatPathLabel(int? 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(String label, String value) { return Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 70, child: Text(label, style: TextStyle(color: Colors.grey[600])), ), Expanded(child: Text(value)), ], ), ); } void _openPathMap( BuildContext context, { Uint8List? initialPath, bool channelMessage = false, }) { Navigator.push( context, MaterialPageRoute( builder: (context) => ChannelMessagePathMapScreen( message: message, initialPath: initialPath, channelMessage: channelMessage, ), ), ); } } class ChannelMessagePathMapScreen extends StatefulWidget { final ChannelMessage message; final Uint8List? initialPath; final bool channelMessage; const ChannelMessagePathMapScreen({ super.key, required this.message, this.initialPath, this.channelMessage = false, }); @override State createState() => _ChannelMessagePathMapScreenState(); } class _ChannelMessagePathMapScreenState extends State { static const double _labelZoomThreshold = 8.5; final MapController _mapController = MapController(); Uint8List? _selectedPath; double _pathDistance = 0.0; bool _showNodeLabels = true; bool _didReceivePositionUpdate = false; int? _focusedHopIndex; @override void initState() { super.initState(); _selectedPath = widget.initialPath; } @override void didUpdateWidget(ChannelMessagePathMapScreen oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.message != widget.message || !_pathsEqual( oldWidget.initialPath ?? Uint8List(0), widget.initialPath ?? Uint8List(0), )) { _selectedPath = widget.initialPath; } } 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; }); } @override Widget build(BuildContext context) { return Consumer( builder: (context, connector, _) { final settings = context.watch().settings; final isImperial = settings.unitSystem == UnitSystem.imperial; final tileCache = context.read(); final primaryPath = _selectPrimaryPath( widget.message.pathBytes, widget.message.pathVariants, ); final observedPaths = _buildObservedPaths( primaryPath, widget.message.pathVariants, ); final selectedPathTmp = _resolveSelectedPath( _selectedPath, observedPaths, primaryPath, ); final selectedPath = ((!widget.message.isOutgoing && !widget.channelMessage) || (widget.message.isOutgoing && widget.channelMessage)) ? Uint8List.fromList(selectedPathTmp.reversed.toList()) : selectedPathTmp; final selectedIndex = _indexForPath(selectedPath, observedPaths); final hops = _buildPathHops(selectedPath, connector, context.l10n); final points = []; if ((widget.message.isOutgoing && !widget.channelMessage) || (widget.message.isOutgoing && widget.channelMessage)) { points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!)); } for (final hop in hops) { if (hop.hasLocation) { points.add(hop.position!); } } if ((!widget.message.isOutgoing && !widget.channelMessage) || (!widget.message.isOutgoing && widget.channelMessage)) { points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!)); } final polylines = points.length > 1 ? [ Polyline( points: points, strokeWidth: 4, color: Colors.blueAccent, ), ] : []; final initialCenter = points.isNotEmpty ? points.first : const LatLng(0, 0); final initialZoom = points.isNotEmpty ? 13.0 : 2.0; if (!_didReceivePositionUpdate) { _showNodeLabels = initialZoom >= _labelZoomThreshold; } final bounds = points.length > 1 ? LatLngBounds.fromPoints(points) : null; final mapKey = ValueKey( '${_formatPathPrefixes(selectedPath)},${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: 2.0, maxZoom: 18.0, interactionOptions: InteractionOptions( flags: ~InteractiveFlag.rotate, ), onPositionChanged: (camera, hasGesture) { final shouldShow = camera.zoom >= _labelZoomThreshold; if (!_didReceivePositionUpdate || shouldShow != _showNodeLabels) { if (!mounted) return; setState(() { _didReceivePositionUpdate = true; _showNodeLabels = shouldShow; }); } }, ), children: [ TileLayer( urlTemplate: kMapTileUrlTemplate, tileProvider: tileCache.tileProvider, userAgentPackageName: MapTileCacheService.userAgentPackageName, maxZoom: 19, ), if (polylines.isNotEmpty) PolylineLayer(polylines: polylines), MarkerLayer( markers: _buildHopMarkers( hops, showLabels: _showNodeLabels, ), ), ], ), if (observedPaths.length > 1) _buildPathSelector(context, observedPaths, selectedIndex, ( index, ) { setState(() { _selectedPath = observedPaths[index].pathBytes; _focusedHopIndex = null; }); }), if (points.isEmpty) Center( child: Card( color: Colors.white.withValues(alpha: 0.9), child: Padding( padding: EdgeInsets.all(12), child: Text( context.l10n.channelPath_noRepeaterLocations, ), ), ), ), _buildLegendCard(context, hops, isImperial), ], ), ), ); }, ); } Widget _buildPathSelector( BuildContext context, List<_ObservedPath> paths, int selectedIndex, ValueChanged onSelected, ) { 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: 16, child: SafeArea( child: Card( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.channelPath_observedPathHeader, style: const TextStyle(fontWeight: FontWeight.w600), ), const SizedBox(height: 4), DropdownButtonHideUnderline( child: DropdownButton( isExpanded: true, value: selectedIndex, items: [ for (int i = 0; i < paths.length; i++) DropdownMenuItem( value: i, child: Text( '${paths[i].isPrimary ? l10n.channelPath_primaryPath(i + 1) : l10n.channelPath_pathLabel(i + 1)}' ' • ${_formatHopCount(paths[i].pathBytes.length, l10n)}', ), ), ], onChanged: (value) { if (value == null) return; onSelected(value); }, ), ), const SizedBox(height: 4), Text( l10n.channelPath_selectedPathLabel( label, _formatPathPrefixes(selectedPath.pathBytes), ), style: TextStyle(color: Colors.grey[700], 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: 35, height: 35, child: Container( decoration: BoxDecoration( color: Colors.green, shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.3), blurRadius: 4, offset: const Offset(0, 2), ), ], ), alignment: Alignment.center, child: Text( hop.index.toString(), style: const TextStyle( color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12, ), ), ), ), ); if (showLabels) { markers.add( _buildNodeLabelMarker( point: point, label: hop.contact?.name ?? _formatPrefix(hop.prefix), ), ); } } final selfLat = context.read().selfLatitude; final selfLon = context.read().selfLongitude; if (selfLat != null && selfLon != null) { final selfPoint = LatLng(selfLat, selfLon); markers.add( Marker( point: selfPoint, width: 35, height: 35, child: Container( decoration: BoxDecoration( color: Colors.teal, shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.3), blurRadius: 4, offset: const Offset(0, 2), ), ], ), alignment: Alignment.center, child: Text( context.l10n.pathTrace_you, style: const TextStyle( color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12, ), ), ), ), ); if (showLabels) { markers.add( _buildNodeLabelMarker( point: selfPoint, label: context.l10n.pathTrace_you, ), ); } } return markers; } Marker _buildNodeLabelMarker({required LatLng point, required String label}) { return Marker( point: point, width: 120, height: 24, alignment: Alignment.topCenter, child: IgnorePointer( child: Transform.translate( offset: const Offset(0, -20), child: FittedBox( fit: BoxFit.contain, child: Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: Colors.black54, borderRadius: BorderRadius.circular(8), ), alignment: Alignment.center, child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: Colors.white, fontSize: 11, fontWeight: FontWeight.w500, ), ), ), ), ), ), ); } Widget _buildLegendCard( BuildContext context, List<_PathHop> hops, bool isImperial, ) { final l10n = context.l10n; final maxHeight = MediaQuery.of(context).size.height * 0.35; final estimatedHeight = 72.0 + (hops.length * 56.0); final cardHeight = max(96.0, min(maxHeight, estimatedHeight)); return Positioned( left: 16, right: 16, bottom: 16, child: SizedBox( height: cardHeight, child: Card( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.all(12), child: Text( '${l10n.channelPath_repeaterHops} ${formatDistance(_pathDistance, isImperial: isImperial)}', style: const TextStyle(fontWeight: FontWeight.w600), ), ), const Divider(height: 1), Expanded( child: hops.isEmpty ? Center( child: Text(l10n.channelPath_noHopDetailsAvailable), ) : ListView.separated( padding: const EdgeInsets.symmetric(vertical: 4), itemCount: hops.length, separatorBuilder: (_, _) => const Divider(height: 1), itemBuilder: (context, index) { final hop = hops[index]; final isFocused = _focusedHopIndex == hop.index; return ListTile( dense: true, enabled: hop.hasLocation, selected: isFocused, selectedTileColor: Theme.of( context, ).colorScheme.primary.withValues(alpha: 0.12), onTap: hop.hasLocation ? () => _onHopTapped(hop) : null, leading: CircleAvatar( radius: 14, child: Text( hop.index.toString(), style: const TextStyle(fontSize: 12), ), ), title: Text(hop.displayLabel), subtitle: Text( hop.hasLocation ? '${hop.position!.latitude.toStringAsFixed(5)}, ' '${hop.position!.longitude.toStringAsFixed(5)}' : l10n.channelPath_noLocationData, ), ); }, ), ), ], ), ), ), ); } } class _PathHop { final int index; final int prefix; final Contact? contact; final LatLng? position; final AppLocalizations l10n; 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, ) { if (pathBytes.isEmpty) return const []; final candidatesByPrefix = >{}; for (final contact in connector.allContacts) { if (contact.publicKey.isEmpty) continue; if (contact.type != advTypeRepeater && contact.type != advTypeRoom) { continue; } final prefix = contact.publicKey.first; candidatesByPrefix.putIfAbsent(prefix, () => []).add(contact); } for (final candidates in candidatesByPrefix.values) { candidates.sort((a, b) => b.lastSeen.compareTo(a.lastSeen)); } final startPoint = (connector.selfLatitude != null && connector.selfLongitude != null) ? LatLng(connector.selfLatitude!, connector.selfLongitude!) : null; var previousPosition = startPoint; final distance = Distance(); final hops = <_PathHop>[]; for (var i = 0; i < pathBytes.length; i++) { final searchPoint = i == 0 ? startPoint : previousPosition; final candidates = candidatesByPrefix[pathBytes[i]]; Contact? contact; if (candidates != null && candidates.isNotEmpty) { var bestIndex = 0; if (searchPoint != null) { var bestDistance = double.infinity; for (var j = 0; j < candidates.length; j++) { final candidate = candidates[j]; if (!candidate.hasLocation || candidate.latitude == null || candidate.longitude == null) { continue; } final currentDistance = distance( searchPoint, LatLng(candidate.latitude!, candidate.longitude!), ); if (currentDistance < bestDistance) { bestDistance = currentDistance; bestIndex = j; } } } contact = candidates.removeAt(bestIndex); if (candidates.isEmpty) { candidatesByPrefix.remove(pathBytes[i]); } } final resolvedPosition = _resolvePosition(contact); if (resolvedPosition != null) { previousPosition = resolvedPosition; } 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; }