mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-07-11 19:17:04 +10:00
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:
+147
-3
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user