mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-04-20 22:13:48 +00:00
* reduce map marker size reduces map markers from 80 to 60 px to improve visibility with higher density areas * add flutter test to actions * Add GPX export functionality and related UI components * Refactor GPX export constants to use lowercase naming convention and improve export function error handling * ran formating * Enhance GPX export functionality with customizable parameters and improved metadata * Implement PathTraceMapScreen and refactor path tracing functionality across screens * Add localization for missing location error in path tracing * Updated GPX export functionality for contacts and repeaters in multiple languages. * Add scrollbar to path trace details list for improved navigation * Integrate SharePlus plugin for enhanced sharing functionality across platforms * reduce map marker size reduces map markers from 80 to 60 px to improve visibility with higher density areas * reduce marker size to improve map clarity and add path trace navigation to path management --------- Co-authored-by: Winston Lowe <wel97459@gmail.com>
569 lines
19 KiB
Dart
569 lines
19 KiB
Dart
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<int, Contact> 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<PathTraceMapScreen> createState() => _PathTraceMapScreenState();
|
|
}
|
|
|
|
class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
|
StreamSubscription<Uint8List>? _frameSubscription;
|
|
Timer? _timeoutTimer;
|
|
|
|
bool _isLoading = false;
|
|
bool _failed2Loaded = false;
|
|
bool _hasData = false;
|
|
bool _noLocationErr = false;
|
|
PathTraceData? _traceData;
|
|
List<LatLng> _points = <LatLng>[];
|
|
List<Polyline> _polylines = [];
|
|
LatLng? _initialCenter = LatLng(0, 0);
|
|
double _initialZoom = 2.0;
|
|
LatLngBounds? _bounds;
|
|
ValueKey<String> _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<void> _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<MeshCoreConnector>(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<MeshCoreConnector>(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<void> _handleTraceResponse(Uint8List frame) async {
|
|
final connector = Provider.of<MeshCoreConnector>(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<int, Contact> 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 = <LatLng>[];
|
|
_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,
|
|
),
|
|
]
|
|
: <Polyline>[];
|
|
|
|
_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<MeshCoreConnector>(
|
|
builder: (context, connector, _) {
|
|
final tileCache = context.read<MapTileCacheService>();
|
|
|
|
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<Marker> _buildHopMarkers(List<int> 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<MeshCoreConnector>().selfLatitude != null &&
|
|
context.read<MeshCoreConnector>().selfLongitude != null)
|
|
Marker(
|
|
point: LatLng(
|
|
context.read<MeshCoreConnector>().selfLatitude!,
|
|
context.read<MeshCoreConnector>().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
|
|
},
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|