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/l10n/l10n.dart'; import 'package:meshcore_open/models/contact.dart'; import 'package:meshcore_open/services/map_tile_cache_service.dart'; import 'package:meshcore_open/widgets/snr_indicator.dart'; import 'package:provider/provider.dart'; class PathTraceData { final Uint8List pathData; final Uint8List snrData; final Map pathContacts; PathTraceData({ required this.pathData, required this.snrData, required this.pathContacts, }); } class PathTraceMapScreen extends StatefulWidget { final String title; final Uint8List path; final bool flipPathRound; final bool reversePathRound; const PathTraceMapScreen({ super.key, required this.title, required this.path, this.flipPathRound = false, this.reversePathRound = false, }); @override State createState() => _PathTraceMapScreenState(); } class _PathTraceMapScreenState extends State { StreamSubscription? _frameSubscription; Timer? _timeoutTimer; bool _isLoading = false; bool _failed2Loaded = false; bool _hasData = false; bool _noLocationErr = false; PathTraceData? _traceData; List _points = []; List _polylines = []; LatLng? _initialCenter = LatLng(0, 0); double _initialZoom = 2.0; LatLngBounds? _bounds; ValueKey _mapKey = const ValueKey('initial'); double _pathDistance = 0.0; String _formatPathPrefixes(Uint8List pathBytes) { return pathBytes .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) .join(','); } @override void initState() { super.initState(); _setupFrameListener(); _doPathTrace(); } @override void dispose() { _frameSubscription?.cancel(); _timeoutTimer?.cancel(); super.dispose(); } Uint8List addReturnpath(Uint8List pathBytes) { Uint8List? traceBytes; final len = (pathBytes.length + pathBytes.length - 1); traceBytes = Uint8List(len); for (int i = 0; i < pathBytes.length; i++) { traceBytes[i] = pathBytes[i]; if (i < pathBytes.length - 1) { traceBytes[len - 1 - i] = pathBytes[i]; } } return traceBytes; } double getPathDistance() { 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; } Future _doPathTrace() async { if (mounted) { setState(() { _isLoading = true; _failed2Loaded = false; _noLocationErr = false; }); } final Uint8List path; Uint8List pathTmp = widget.reversePathRound ? Uint8List.fromList(widget.path.reversed.toList()) : widget.path; if (widget.flipPathRound) { path = addReturnpath(pathTmp); } else { path = pathTmp; } final connector = Provider.of(context, listen: false); final frame = buildTraceReq( DateTime.now().millisecondsSinceEpoch ~/ 1000, 0, //flags 0, //auth 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); final code = frameBuffer.readUInt8(); if (code == respCodeSent) { frameBuffer.skipBytes(1); //reserved tagData = frameBuffer.readBytes(4); final timeoutSeconds = frameBuffer.readUInt32LE(); // Start timeout timer for trace response _timeoutTimer?.cancel(); _timeoutTimer = Timer(Duration(milliseconds: timeoutSeconds), () { 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); } } }); } Future _handleTraceResponse(Uint8List frame) async { final connector = Provider.of(context, listen: false); final buffer = BufferReader(frame); buffer.skipBytes(2); // Skip push code and reserved byte int pathLength = buffer.readUInt8(); buffer.skipBytes(5); // Skip Flag byte and tag data buffer.skipBytes(4); // Skip auth code Uint8List pathData = buffer.readBytes(pathLength); Uint8List snrData = buffer.readRemainingBytes(); Map pathContacts = {}; connector.contacts.where((c) => c.type != advTypeChat).forEach((repeater) { for (var repeaterData in pathData) { if (listEquals( repeater.publicKey.sublist(0, 1), Uint8List.fromList([repeaterData]), )) { pathContacts[repeaterData] = repeater; } } }); setState(() { _isLoading = false; _hasData = true; _traceData = PathTraceData( pathData: pathData, snrData: snrData, pathContacts: pathContacts, ); _points = []; _points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!)); for (final hop in _traceData!.pathData) { final contact = _traceData!.pathContacts[hop]; if (contact != null && contact.hasLocation && contact.latitude != null && contact.longitude != null) { _points.add(LatLng(contact.latitude!, contact.longitude!)); } else { _noLocationErr = true; } } _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},${_formatPathPrefixes(_traceData!.pathData)}', ); _pathDistance = getPathDistance(); }); } @override Widget build(BuildContext context) { return Consumer( builder: (context, connector, _) { 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 (_noLocationErr) Center( child: Card( color: Colors.red, child: Padding( padding: EdgeInsets.all(12), child: Text( context.l10n.pathTrace_someHopsNoLocation, style: TextStyle(color: Colors.white), ), ), ), ), if (!_hasData && !_noLocationErr) 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 && !_noLocationErr) FlutterMap( key: _mapKey, 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, ), 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), ), ], ), if (_points.isEmpty && !_hasData && !_isLoading && !_failed2Loaded && !_noLocationErr) Center( child: Card( color: Colors.white.withValues(alpha: 0.9), child: Padding( padding: EdgeInsets.all(12), child: Text( context.l10n.channelPath_noRepeaterLocations, ), ), ), ), if (_hasData && !_noLocationErr) _buildLegendCard(context, _traceData!), ], ), ), ); }, ); } List _buildHopMarkers(List pathData) { return [ for (final hop in pathData) if (_traceData!.pathContacts[hop]!.hasLocation) Marker( point: LatLng( _traceData!.pathContacts[hop]!.latitude!, _traceData!.pathContacts[hop]!.longitude!, ), width: 35, height: 35, child: Container( padding: const EdgeInsets.all(4), 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( _traceData!.pathContacts[hop]!.publicKey .sublist(0, 1) .map( (b) => b.toRadixString(16).padLeft(2, '0').toUpperCase(), ) .join(), style: const TextStyle( color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12, ), ), ), ), if (context.read().selfLatitude != null && context.read().selfLongitude != null) Marker( point: LatLng( context.read().selfLatitude!, context.read().selfLongitude!, ), 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, ), ), ), ), ]; } String formatDirectionText(PathTraceData pathTraceData, int index) { if (index == 0 || index == pathTraceData.snrData.length - 1) { if (index == 0) { return context.l10n.pathTrace_you; } else { final contactName = pathTraceData .pathContacts[pathTraceData.pathData[pathTraceData.pathData.length - 1]] ?.name; final hex = pathTraceData.pathData[pathTraceData.pathData.length - 1] .toRadixString(16) .padLeft(2, '0') .toUpperCase(); return contactName != null ? "$hex: $contactName" : hex; } } else { final contactName = pathTraceData.pathContacts[pathTraceData.pathData[index - 1]]?.name; final hex = pathTraceData.pathData[index - 1] .toRadixString(16) .padLeft(2, '0') .toUpperCase(); return contactName != null ? "$hex: $contactName" : hex; } } String formatDirectionSubText(PathTraceData pathTraceData, int index) { if (index == 0 || index == pathTraceData.snrData.length - 1) { if (index == 0) { final contactName = pathTraceData.pathContacts[pathTraceData.pathData[0]]?.name; final hex = pathTraceData.pathData[0] .toRadixString(16) .padLeft(2, '0') .toUpperCase(); return contactName != null ? "$hex: $contactName" : hex; } else { return context.l10n.pathTrace_you; } } else { final contactName = pathTraceData.pathContacts[pathTraceData.pathData[index]]?.name; final hex = pathTraceData.pathData[index] .toRadixString(16) .padLeft(2, '0') .toUpperCase(); return contactName != null ? "$hex: $contactName" : hex; } } Widget _buildLegendCard(BuildContext context, PathTraceData pathTraceData) { 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} (${(_pathDistance / 1609.34).toStringAsFixed(2)} Miles / ${(_pathDistance / 1000).toStringAsFixed(2)} Km)', 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) { 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: SNRIcon( snr: pathTraceData.snrData[index].toSigned( 8, ) / 4.0, ), onTap: () { // Handle item tap }, ), ], ); }, ), ), ), ], ), ), ), ); } }