diff --git a/lib/screens/channel_message_path_screen.dart b/lib/screens/channel_message_path_screen.dart index 970c152..1646b73 100644 --- a/lib/screens/channel_message_path_screen.dart +++ b/lib/screens/channel_message_path_screen.dart @@ -4,6 +4,7 @@ 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'; @@ -41,6 +42,21 @@ class ChannelMessagePathScreen extends StatelessWidget { appBar: AppBar( title: Text(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: Uint8List.fromList(primaryPath), + flipPathRound: true, + reversePathRound: true, + ), + ), + ), + ), IconButton( icon: const Icon(Icons.map_outlined), tooltip: l10n.channelPath_viewMap, @@ -263,6 +279,7 @@ class ChannelMessagePathMapScreen extends StatefulWidget { class _ChannelMessagePathMapScreenState extends State { Uint8List? _selectedPath; + double _pathDistance = 0.0; @override void initState() { @@ -282,6 +299,17 @@ class _ChannelMessagePathMapScreenState } } + 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; + } + @override Widget build(BuildContext context) { return Consumer( @@ -306,10 +334,15 @@ class _ChannelMessagePathMapScreenState connector.contacts, context.l10n, ); - final points = hops - .where((hop) => hop.hasLocation) - .map((hop) => hop.position!) - .toList(); + + final points = []; + for (final hop in hops) { + if (hop.hasLocation) { + points.add(hop.position!); + } + } + points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!)); + final polylines = points.length > 1 ? [ Polyline( @@ -327,7 +360,10 @@ class _ChannelMessagePathMapScreenState final bounds = points.length > 1 ? LatLngBounds.fromPoints(points) : null; - final mapKey = ValueKey(_formatPathPrefixes(selectedPath)); + final mapKey = ValueKey( + '${_formatPathPrefixes(selectedPath)},${context.l10n.pathTrace_you}', + ); + _pathDistance = _getPathDistance(points); return Scaffold( appBar: AppBar(title: Text(context.l10n.channelPath_mapTitle)), @@ -487,6 +523,37 @@ class _ChannelMessagePathMapScreenState ), ), ), + Marker( + point: LatLng( + context.read().selfLatitude!, + context.read().selfLongitude!, + ), + width: 40, + height: 40, + child: Container( + 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, + ), + ), + ), + ), ]; } @@ -509,7 +576,7 @@ class _ChannelMessagePathMapScreenState Padding( padding: const EdgeInsets.all(12), child: Text( - l10n.channelPath_repeaterHops, + '${l10n.channelPath_repeaterHops} (${(_pathDistance / 1609.34).toStringAsFixed(2)} Miles / ${(_pathDistance / 1000).toStringAsFixed(2)} Km)', style: const TextStyle(fontWeight: FontWeight.w600), ), ), diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 3477361..f00f242 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:flutter_linkify/flutter_linkify.dart'; +import 'package:meshcore_open/screens/path_trace_map.dart'; import 'package:provider/provider.dart'; import 'package:latlong2/latlong.dart'; @@ -701,6 +702,19 @@ class _ChatScreenState extends State { title: Text(context.l10n.chat_fullPath), content: SelectableText(formattedPath), actions: [ + TextButton( + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PathTraceMapScreen( + title: context.l10n.contacts_repeaterPathTrace, + path: Uint8List.fromList(pathBytes), + flipPathRound: true, + ), + ), + ), + child: Text(context.l10n.contacts_pathTrace), + ), TextButton( onPressed: () => Navigator.pop(context), child: Text(context.l10n.common_close), diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index f04bc50..6799d69 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -1,8 +1,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:meshcore_open/widgets/path_trace_dialog.dart'; import 'package:flutter/services.dart'; +import 'package:meshcore_open/screens/path_trace_map.dart'; import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; @@ -982,16 +982,16 @@ class _ContactsScreenState extends State ? Text(context.l10n.contacts_pathTrace) : Text(context.l10n.contacts_ping), onTap: () { - showDialog( - context: context, - builder: (context) { - return PathTraceDialog( + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PathTraceMapScreen( title: contact.pathLength > 0 ? context.l10n.contacts_repeaterPathTrace : context.l10n.contacts_repeaterPing, path: contact.traceRouteBytes ?? Uint8List(0), - ); - }, + ), + ), ); }, ), @@ -1010,16 +1010,16 @@ class _ContactsScreenState extends State ? Text(context.l10n.contacts_pathTrace) : Text(context.l10n.contacts_ping), onTap: () { - showDialog( - context: context, - builder: (context) { - return PathTraceDialog( + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PathTraceMapScreen( title: contact.pathLength > 0 ? context.l10n.contacts_roomPathTrace : context.l10n.contacts_roomPing, path: contact.traceRouteBytes ?? Uint8List(0), - ); - }, + ), + ), ); }, ), @@ -1052,16 +1052,16 @@ class _ContactsScreenState extends State leading: const Icon(Icons.radar, color: Colors.green), title: Text(context.l10n.contacts_chatTraceRoute), onTap: () { - showDialog( - context: context, - builder: (context) { - return PathTraceDialog( + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PathTraceMapScreen( title: context.l10n.contacts_pathTraceTo( contact.name, ), path: contact.traceRouteBytes ?? Uint8List(0), - ); - }, + ), + ), ); }, ), diff --git a/lib/screens/path_trace_map.dart b/lib/screens/path_trace_map.dart new file mode 100644 index 0000000..b4b280d --- /dev/null +++ b/lib/screens/path_trace_map.dart @@ -0,0 +1,535 @@ +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; + 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; + }); + } + + 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 (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) { + _points.add(LatLng(contact.latitude!, contact.longitude!)); + } + } + _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 (!_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) + 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) + 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!), + ], + ), + ), + ); + }, + ); + } + + List _buildHopMarkers(List pathData) { + return [ + Marker( + point: LatLng( + context.read().selfLatitude!, + context.read().selfLongitude!, + ), + width: 40, + height: 40, + child: Container( + 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, + ), + ), + ), + ), + for (final hop in pathData) + if (_traceData!.pathContacts[hop]!.hasLocation) + Marker( + point: LatLng( + _traceData!.pathContacts[hop]!.latitude!, + _traceData!.pathContacts[hop]!.longitude!, + ), + width: 40, + height: 40, + 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( + _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, + ), + ), + ), + ), + ]; + } + + 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), + ) + : 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 + }, + ), + ], + ); + }, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/path_trace_dialog.dart b/lib/widgets/path_trace_dialog.dart deleted file mode 100644 index 7294c86..0000000 --- a/lib/widgets/path_trace_dialog.dart +++ /dev/null @@ -1,240 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import '../connector/meshcore_connector.dart'; -import '../connector/meshcore_protocol.dart'; -import '../models/contact.dart'; -import '../widgets/snr_indicator.dart'; -import '../l10n/l10n.dart'; - -class PathTraceDialog extends StatefulWidget { - const PathTraceDialog({super.key, required this.title, required this.path}); - - final String title; - final Uint8List path; - - @override - State createState() => _PathTraceDialogState(); -} - -class _PathTraceDialogState extends State { - StreamSubscription? _frameSubscription; - Timer? _timeoutTimer; - - bool _isLoading = false; - bool _failed2Loaded = false; - bool _hasData = false; - Uint8List _pathData = Uint8List(0); - Uint8List _snrData = Uint8List(0); - Map _pathContacts = {}; - - @override - void initState() { - super.initState(); - _setupFrameListener(); - _doPathTrace(); - } - - @override - void dispose() { - _frameSubscription?.cancel(); - _timeoutTimer?.cancel(); - super.dispose(); - } - - Future _doPathTrace() async { - if (mounted) { - setState(() { - _isLoading = true; - _failed2Loaded = false; - }); - } - - final connector = Provider.of(context, listen: false); - final frame = buildTraceReq( - DateTime.now().millisecondsSinceEpoch ~/ 1000, - 0, //flags - 0, //auth - payload: widget.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 (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 neighbourData in pathData) { - if (listEquals( - repeater.publicKey.sublist(0, 1), - Uint8List.fromList([neighbourData]), - )) { - pathContacts[neighbourData] = repeater; - } - } - }); - - setState(() { - _isLoading = false; - _hasData = true; - _pathData = pathData; - _snrData = snrData; - _pathContacts = pathContacts; - }); - } - - String formatDirectionText(int index) { - if (index == 0 || index == _snrData.length - 1) { - if (index == 0) { - return context.l10n.pathTrace_you; - } else { - return _pathContacts[_pathData[_pathData.length - 1]]?.name ?? - "0x${_pathData[_pathData.length - 1].toRadixString(16).toUpperCase()}"; - } - } else { - return _pathContacts[_pathData[index - 1]]?.name ?? - "0x${_pathData[index - 1].toRadixString(16).toUpperCase()}"; - } - } - - String formatDirectionSubText(int index) { - if (index == 0 || index == _snrData.length - 1) { - if (index == 0) { - return _pathContacts[_pathData[0]]?.name ?? - "0x${_pathData[0].toRadixString(16).toUpperCase()}"; - } else { - return context.l10n.pathTrace_you; - } - } else { - return _pathContacts[_pathData[index]]?.name ?? - "0x${_pathData[index].toRadixString(16).toUpperCase()}"; - } - } - - @override - Widget build(BuildContext context) { - final l10n = context.l10n; - return AlertDialog( - title: Column( - children: [ - FittedBox( - fit: BoxFit.scaleDown, - child: Text(widget.title, style: const TextStyle(fontSize: 24)), - ), - if (_failed2Loaded) - Text( - l10n.pathTrace_failed, - style: TextStyle( - fontSize: 14, - color: Theme.of(context).colorScheme.error, - ), - ), - ], - ), - content: SafeArea( - child: RefreshIndicator( - onRefresh: _doPathTrace, - child: !_hasData - ? Center(child: Text(l10n.pathTrace_notAvailable)) - : ListView.builder( - itemCount: _snrData.length, - itemBuilder: (context, index) { - return Column( - children: [ - ListTile( - leading: index >= _snrData.length / 2 - ? Icon(Icons.call_received) - : Icon(Icons.call_made), - title: Text( - formatDirectionText(index), - style: const TextStyle(fontSize: 14), - ), - subtitle: Text( - formatDirectionSubText(index), - style: const TextStyle(fontSize: 14), - ), - trailing: SNRIcon( - snr: _snrData[index].toSigned(8) / 4.0, - ), - onTap: () { - // Handle item tap - }, - ), - if (index < _snrData.length - 1) - const Divider(height: 0.0), - ], - ); - }, - ), - ), - ), - actions: [ - IconButton( - icon: _isLoading - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.refresh), - onPressed: _isLoading ? null : _doPathTrace, - tooltip: l10n.pathTrace_refreshTooltip, - ), - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text(l10n.common_close), - ), - ], - ); - } -}