Add path tracing functionality (#165)

- Implemented path tracing feature in the map screen, allowing users to add nodes to a path and visualize it on the map.
- Added buttons for starting path tracing, removing the last node, and running the path trace.
- Introduced a new overlay to display current path information and distance.
- Updated localization files for multiple languages to include new strings related to path tracing.
- Refactored map rendering logic to accommodate path tracing visuals.
This commit is contained in:
Winston Lowe
2026-02-14 00:10:34 -08:00
committed by GitHub
parent fac062a100
commit 73081862ad
33 changed files with 599 additions and 126 deletions
+147 -3
View File
@@ -1,8 +1,10 @@
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';
@@ -48,9 +50,13 @@ class _MapScreenState extends State<MapScreen> {
final MapMarkerService _markerService = MapMarkerService();
final Set<String> _hiddenMarkerIds = {};
Set<String> _removedMarkerIds = {};
bool _isBuildingPathTrace = false;
bool _isSelectingPoi = false;
bool _hasInitializedMap = false;
bool _removedMarkersLoaded = false;
final List<int> _pathTrace = [];
final List<LatLng> _points = [];
final List<Polyline> _polylines = [];
bool _legendExpanded = false;
@override
@@ -148,6 +154,19 @@ class _MapScreenState extends State<MapScreen> {
.where((c) => c.hasLocation)
.toList();
_polylines.clear();
_polylines.addAll(
_points.length > 1
? [
Polyline(
points: _points,
strokeWidth: 4,
color: Colors.blueAccent,
),
]
: <Polyline>[],
);
// Calculate center and zoom of all nodes, or default to (0, 0)
LatLng center = const LatLng(0, 0);
double initialZoom = 10.0;
@@ -248,6 +267,12 @@ class _MapScreenState extends State<MapScreen> {
centerTitle: true,
automaticallyImplyLeading: false,
actions: [
if (!_isBuildingPathTrace)
IconButton(
icon: const Icon(Icons.radar),
onPressed: () => _startPath(),
tooltip: context.l10n.contacts_pathTrace,
),
PopupMenuButton(
itemBuilder: (context) => [
PopupMenuItem(
@@ -335,6 +360,8 @@ class _MapScreenState extends State<MapScreen> {
MapTileCacheService.userAgentPackageName,
maxZoom: 19,
),
if (_polylines.isNotEmpty && _isBuildingPathTrace)
PolylineLayer(polylines: _polylines),
MarkerLayer(
markers: [
if (highlightPosition != null)
@@ -391,7 +418,12 @@ class _MapScreenState extends State<MapScreen> {
),
],
),
_buildLegend(contactsWithLocation.length, sharedMarkers.length),
if (!_isBuildingPathTrace)
_buildLegend(
contactsWithLocation.length,
sharedMarkers.length,
),
if (_isBuildingPathTrace) _buildPathTraceOverlay(),
],
),
bottomNavigationBar: SafeArea(
@@ -435,7 +467,11 @@ class _MapScreenState extends State<MapScreen> {
width: 35,
height: 35,
child: GestureDetector(
onTap: () => _showNodeInfo(context, contact),
onLongPress: () =>
_isBuildingPathTrace ? _showNodeInfo(context, contact) : null,
onTap: () => _isBuildingPathTrace
? _addToPath(context, contact)
: _showNodeInfo(context, contact),
child: Column(
children: [
Container(
@@ -607,7 +643,7 @@ class _MapScreenState extends State<MapScreen> {
Widget _buildLegendItem(IconData icon, String label, Color color) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
padding: const EdgeInsets.symmetric(vertical: 1.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
@@ -1455,6 +1491,114 @@ class _MapScreenState extends State<MapScreen> {
return context.l10n.time_allTime;
}
}
void _addToPath(BuildContext context, Contact contact) {
setState(() {
_pathTrace.add(
contact.publicKey[0],
); // Add first 16 bytes of public key to path trace
_points.add(LatLng(contact.latitude!, contact.longitude!));
});
}
void _startPath() {
setState(() {
_isBuildingPathTrace = true;
_pathTrace.clear();
_points.clear();
_polylines.clear();
});
}
void _removePath() {
setState(() {
_pathTrace.removeLast(); // Remove last node from path trace
_points.removeLast(); // Remove last point from points list
_polylines.clear(); // Clear polylines
});
}
Widget _buildPathTraceOverlay() {
final l10n = context.l10n;
return Positioned(
top: 16,
left: 16,
right: 16,
child: Card(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
l10n.contacts_pathTrace,
style: TextStyle(fontWeight: FontWeight.bold),
),
if (_pathTrace.isEmpty) const SizedBox(height: 8),
if (_pathTrace.isEmpty)
Text(l10n.map_tapToAdd, style: TextStyle(fontSize: 12)),
const SizedBox(height: 6),
if (_pathTrace.isNotEmpty)
Text(
"${l10n.path_currentPathLabel} ${formatDistance(getPathDistanceMeters(_points))}",
style: TextStyle(fontSize: 12, color: Colors.grey[700]),
),
SelectableText(
_pathTrace
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(','),
style: TextStyle(fontSize: 18),
),
const SizedBox(height: 6),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_pathTrace.isNotEmpty)
ElevatedButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PathTraceMapScreen(
title: l10n.contacts_pathTrace,
path: Uint8List.fromList(_pathTrace),
),
),
);
setState(() {
_isBuildingPathTrace = false;
});
},
child: Text(l10n.map_runTrace),
),
if (_pathTrace.isNotEmpty)
ElevatedButton(
onPressed: _removePath,
child: Text(l10n.map_removeLast),
),
if (_pathTrace.isEmpty)
ElevatedButton(
onPressed: () {
setState(() {
_isBuildingPathTrace = false;
_pathTrace.clear();
_points.clear();
_polylines.clear();
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.map_pathTraceCancelled)),
);
},
child: Text(l10n.common_cancel),
),
],
),
],
),
),
),
);
}
}
class _MarkerPayload {
+80 -73
View File
@@ -13,6 +13,23 @@ import 'package:meshcore_open/services/map_tile_cache_service.dart';
import 'package:meshcore_open/widgets/snr_indicator.dart';
import 'package:provider/provider.dart';
double getPathDistanceMeters(List<LatLng> points) {
if (points.length <= 1) return 0.0;
double distanceMeters = 0.0;
final distanceCalculator = Distance();
for (int i = 0; i < points.length - 1; i++) {
distanceMeters += distanceCalculator(points[i], points[i + 1]);
}
return distanceMeters;
}
String formatDistance(double distanceMeters) {
return '(${(distanceMeters / 1609.34).toStringAsFixed(2)} Miles / ${(distanceMeters / 1000).toStringAsFixed(2)} Km)';
}
class PathTraceData {
final Uint8List pathData;
final Uint8List snrData;
@@ -50,7 +67,6 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
bool _isLoading = false;
bool _failed2Loaded = false;
bool _hasData = false;
bool _noLocationErr = false;
PathTraceData? _traceData;
List<LatLng> _points = <LatLng>[];
List<Polyline> _polylines = [];
@@ -58,7 +74,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
double _initialZoom = 2.0;
LatLngBounds? _bounds;
ValueKey<String> _mapKey = const ValueKey('initial');
double _pathDistance = 0.0;
double _pathDistanceMeters = 0.0;
String _formatPathPrefixes(Uint8List pathBytes) {
return pathBytes
@@ -93,23 +109,11 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
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<void> _doPathTrace() async {
if (mounted) {
setState(() {
_isLoading = true;
_failed2Loaded = false;
_noLocationErr = false;
});
}
@@ -160,6 +164,14 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
});
}
if (code == respCodeErr) {
_timeoutTimer?.cancel();
if (!mounted) return;
setState(() {
_isLoading = false;
_failed2Loaded = true;
});
}
// Check if it's a binary response
if (frame.length > 8 &&
code == pushCodeTraceData &&
@@ -215,8 +227,6 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
contact.latitude != null &&
contact.longitude != null) {
_points.add(LatLng(contact.latitude!, contact.longitude!));
} else {
_noLocationErr = true;
}
}
_polylines = _points.length > 1
@@ -235,7 +245,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
_mapKey = ValueKey(
'${context.l10n.pathTrace_you},${_formatPathPrefixes(_traceData!.pathData)}',
);
_pathDistance = getPathDistance();
_pathDistanceMeters = getPathDistanceMeters(_points);
});
}
@@ -279,20 +289,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
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)
if (!_hasData)
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -304,43 +301,11 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
],
),
),
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 (_hasData) _buildMapPathTrace(context, tileCache),
if (_points.isEmpty &&
!_hasData &&
!_isLoading &&
!_failed2Loaded &&
!_noLocationErr)
!_failed2Loaded)
Center(
child: Card(
color: Colors.white.withValues(alpha: 0.9),
@@ -352,8 +317,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
),
),
),
if (_hasData && !_noLocationErr)
_buildLegendCard(context, _traceData!),
if (_hasData) _buildLegendCard(context, _traceData!),
],
),
),
@@ -365,7 +329,8 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
List<Marker> _buildHopMarkers(List<int> pathData) {
return [
for (final hop in pathData)
if (_traceData!.pathContacts[hop]!.hasLocation)
if (_traceData!.pathContacts[hop] != null &&
_traceData!.pathContacts[hop]!.hasLocation)
Marker(
point: LatLng(
_traceData!.pathContacts[hop]!.latitude!,
@@ -453,7 +418,9 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
.toRadixString(16)
.padLeft(2, '0')
.toUpperCase();
return contactName != null ? "$hex: $contactName" : hex;
return contactName != null
? "$hex: $contactName"
: "$hex: ${context.l10n.channelPath_unknownRepeater}";
}
} else {
final contactName =
@@ -462,7 +429,9 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
.toRadixString(16)
.padLeft(2, '0')
.toUpperCase();
return contactName != null ? "$hex: $contactName" : hex;
return contactName != null
? "$hex: $contactName"
: "$hex: ${context.l10n.channelPath_unknownRepeater}";
}
}
@@ -475,7 +444,9 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
.toRadixString(16)
.padLeft(2, '0')
.toUpperCase();
return contactName != null ? "$hex: $contactName" : hex;
return contactName != null
? "$hex: $contactName"
: "$hex: ${context.l10n.channelPath_unknownRepeater}";
} else {
return context.l10n.pathTrace_you;
}
@@ -486,10 +457,46 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
.toRadixString(16)
.padLeft(2, '0')
.toUpperCase();
return contactName != null ? "$hex: $contactName" : hex;
return contactName != null
? "$hex: $contactName"
: "$hex: ${context.l10n.channelPath_unknownRepeater}";
}
}
Widget _buildMapPathTrace(
BuildContext context,
MapTileCacheService tileCache,
) {
return FlutterMap(
key: _mapKey,
options: MapOptions(
interactionOptions: InteractionOptions(flags: ~InteractiveFlag.rotate),
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)),
],
);
}
Widget _buildLegendCard(BuildContext context, PathTraceData pathTraceData) {
final l10n = context.l10n;
final maxHeight = MediaQuery.of(context).size.height * 0.35;
@@ -509,7 +516,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
Padding(
padding: const EdgeInsets.all(12),
child: Text(
'${l10n.channelPath_repeaterHops} (${(_pathDistance / 1609.34).toStringAsFixed(2)} Miles / ${(_pathDistance / 1000).toStringAsFixed(2)} Km)',
'${l10n.channelPath_repeaterHops} ${formatDistance(_pathDistanceMeters)}',
style: const TextStyle(fontWeight: FontWeight.w600),
),
),