mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-07-12 03:27:06 +10:00
1177 lines
39 KiB
Dart
1177 lines
39 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/helpers/path_helper.dart';
|
|
import 'package:meshcore_open/l10n/l10n.dart';
|
|
import 'package:meshcore_open/models/app_settings.dart';
|
|
import 'package:meshcore_open/models/contact.dart';
|
|
import 'package:meshcore_open/services/app_settings_service.dart';
|
|
import 'package:meshcore_open/services/map_tile_cache_service.dart';
|
|
import 'package:meshcore_open/utils/app_logger.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, {required bool isImperial}) {
|
|
if (isImperial) {
|
|
return '(${(distanceMeters / 1609.34).toStringAsFixed(2)} mi)';
|
|
}
|
|
return '(${(distanceMeters / 1000).toStringAsFixed(2)} km)';
|
|
}
|
|
|
|
class PathTraceData {
|
|
final List<Uint8List> pathData;
|
|
final List<double> snrData;
|
|
final Map<String, Contact> pathContacts;
|
|
|
|
PathTraceData({
|
|
required this.pathData,
|
|
required this.snrData,
|
|
required this.pathContacts,
|
|
});
|
|
}
|
|
|
|
String _hopKey(Uint8List hopBytes) => PathHelper.formatHopHex(hopBytes);
|
|
|
|
Uint8List _lastHopChunk(Uint8List path, int hopWidth) {
|
|
if (path.isEmpty) return Uint8List(0);
|
|
final width = hopWidth.clamp(1, path.length);
|
|
return Uint8List.fromList(path.sublist(path.length - width));
|
|
}
|
|
|
|
bool _matchesHopPrefix(Uint8List a, Uint8List b) {
|
|
if (a.isEmpty || b.isEmpty) return false;
|
|
final width = min(a.length, b.length);
|
|
return listEquals(a.sublist(0, width), b.sublist(0, width));
|
|
}
|
|
|
|
class PathTraceMapScreen extends StatefulWidget {
|
|
final String title;
|
|
final Uint8List path;
|
|
final int? repeaterId;
|
|
final bool flipPathAround;
|
|
final bool reversePathAround;
|
|
final Contact? targetContact;
|
|
final int pathHashByteWidth;
|
|
final List<Contact>? pathContacts;
|
|
|
|
const PathTraceMapScreen({
|
|
super.key,
|
|
required this.title,
|
|
required this.path,
|
|
this.repeaterId,
|
|
this.flipPathAround = false,
|
|
this.reversePathAround = false,
|
|
this.targetContact,
|
|
this.pathHashByteWidth = pathHashSize,
|
|
this.pathContacts,
|
|
});
|
|
|
|
@override
|
|
State<PathTraceMapScreen> createState() => _PathTraceMapScreenState();
|
|
}
|
|
|
|
class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
|
static const double _labelZoomThreshold = 8.5;
|
|
static const double _mapMinZoom = 2.0;
|
|
static const double _mapMaxZoom = 18.0;
|
|
//miles to meters conversion for filtering out repeaters that are too far from the last known GPS hop to be a likely match, to avoid false matches that throw off the inferred positions of other hops in the path
|
|
static const double _maxRepeaterMatchDistanceMeters = 40 * 1609.344;
|
|
|
|
final MapController _mapController = MapController();
|
|
StreamSubscription<Uint8List>? _frameSubscription;
|
|
Timer? _timeoutTimer;
|
|
|
|
bool _isLoading = false;
|
|
bool _failed2Loaded = false;
|
|
bool _hasData = false;
|
|
PathTraceData? _traceData;
|
|
// Inferred positions for hops that have no GPS location, keyed by hop prefix.
|
|
Map<String, LatLng> _inferredHopPositions = {};
|
|
// Endpoint position for the target contact (GPS or guessed).
|
|
LatLng? _targetContactPosition;
|
|
bool _targetContactIsGuessed = false;
|
|
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 _pathDistanceMeters = 0.0;
|
|
bool _showNodeLabels = true;
|
|
Contact? _targetContact;
|
|
Uint8List? _sentTagBytes;
|
|
|
|
String _formatPathPrefixes(Uint8List pathBytes, [int? hashByteWidth]) {
|
|
return PathHelper.splitPathBytes(
|
|
pathBytes,
|
|
hashByteWidth ?? widget.pathHashByteWidth,
|
|
).map(PathHelper.formatHopHex).join(',');
|
|
}
|
|
|
|
int _traceHashByteWidth(int pathHashByteWidth) {
|
|
final width = pathHashByteWidth.clamp(1, pubKeySize).toInt();
|
|
if (width <= 1) return 1;
|
|
if (width == 2) return 2;
|
|
// Trace packets encode hash width as 1 << flags, so 3-byte path hashes
|
|
// must be traced with a 4-byte public-key prefix.
|
|
return 4;
|
|
}
|
|
|
|
int _traceFlagsForHashWidth(int traceHashByteWidth) {
|
|
if (traceHashByteWidth <= 1) return 0;
|
|
if (traceHashByteWidth == 2) return 1;
|
|
return 2;
|
|
}
|
|
|
|
Uint8List _reversePathByHop(Uint8List pathBytes, int hopWidth) {
|
|
final reversedHops = PathHelper.splitPathBytes(
|
|
pathBytes,
|
|
hopWidth,
|
|
).reversed;
|
|
final bytes = <int>[];
|
|
for (final hop in reversedHops) {
|
|
bytes.addAll(hop);
|
|
}
|
|
return Uint8List.fromList(bytes);
|
|
}
|
|
|
|
Uint8List? _expandHopForTrace(
|
|
Uint8List hop,
|
|
int traceHashByteWidth,
|
|
MeshCoreConnector connector,
|
|
) {
|
|
if (hop.length == traceHashByteWidth) return hop;
|
|
|
|
final candidates = <Contact>[
|
|
...?widget.pathContacts,
|
|
if (widget.targetContact != null) widget.targetContact!,
|
|
...connector.allContactsUnfiltered,
|
|
];
|
|
for (final contact in candidates) {
|
|
if (contact.publicKey.length < traceHashByteWidth) continue;
|
|
if (!listEquals(contact.publicKey.sublist(0, hop.length), hop)) continue;
|
|
return Uint8List.fromList(
|
|
contact.publicKey.sublist(0, traceHashByteWidth),
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
Uint8List? _tracePathFromBytes(
|
|
Uint8List pathBytes,
|
|
int traceHashByteWidth,
|
|
MeshCoreConnector connector,
|
|
) {
|
|
final hops = PathHelper.splitPathBytes(pathBytes, widget.pathHashByteWidth);
|
|
final traceBytes = <int>[];
|
|
for (final hop in hops) {
|
|
final traceHop = _expandHopForTrace(hop, traceHashByteWidth, connector);
|
|
if (traceHop == null) return null;
|
|
traceBytes.addAll(traceHop);
|
|
}
|
|
return Uint8List.fromList(traceBytes);
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_setupFrameListener();
|
|
_doPathTrace();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_mapController.dispose();
|
|
_frameSubscription?.cancel();
|
|
_timeoutTimer?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
bool _isDesktopPlatform(TargetPlatform platform) {
|
|
return platform == TargetPlatform.linux ||
|
|
platform == TargetPlatform.windows ||
|
|
platform == TargetPlatform.macOS;
|
|
}
|
|
|
|
void _zoomMapBy(double delta) {
|
|
final camera = _mapController.camera;
|
|
final nextZoom = (camera.zoom + delta)
|
|
.clamp(_mapMinZoom, _mapMaxZoom)
|
|
.toDouble();
|
|
_mapController.move(camera.center, nextZoom);
|
|
}
|
|
|
|
void _resetMapView() {
|
|
final bounds = _bounds;
|
|
if (bounds != null) {
|
|
_mapController.fitCamera(
|
|
CameraFit.bounds(
|
|
bounds: bounds,
|
|
padding: const EdgeInsets.all(64),
|
|
maxZoom: 16,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
final center = _initialCenter;
|
|
if (center != null) {
|
|
_mapController.move(center, _initialZoom);
|
|
}
|
|
}
|
|
|
|
Widget _buildDesktopMapControls() {
|
|
return Positioned(
|
|
top: 16,
|
|
left: 16,
|
|
child: Card(
|
|
elevation: 4,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
IconButton(
|
|
icon: const Icon(Icons.add),
|
|
tooltip: 'Zoom in',
|
|
onPressed: () => _zoomMapBy(1),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.remove),
|
|
tooltip: 'Zoom out',
|
|
onPressed: () => _zoomMapBy(-1),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.my_location),
|
|
tooltip: 'Center map',
|
|
onPressed: _resetMapView,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Uint8List? buildPath(
|
|
Uint8List pathBytes,
|
|
int traceHashByteWidth,
|
|
MeshCoreConnector connector,
|
|
) {
|
|
final pathHops = PathHelper.splitPathBytes(
|
|
pathBytes,
|
|
widget.pathHashByteWidth,
|
|
);
|
|
final hopWidth = traceHashByteWidth.clamp(1, pubKeySize).toInt();
|
|
|
|
// Compute targetPrefix if targetContact is provided
|
|
Uint8List? targetPrefix;
|
|
if (widget.targetContact != null) {
|
|
final pk = widget.targetContact!.publicKey;
|
|
if (pk.isNotEmpty) {
|
|
final len = pk.length >= hopWidth ? hopWidth : pk.length;
|
|
targetPrefix = Uint8List.fromList(pk.sublist(0, len));
|
|
}
|
|
}
|
|
|
|
final outboundHops = <Uint8List>[];
|
|
for (final hop in pathHops) {
|
|
final traceHop = _expandHopForTrace(hop, traceHashByteWidth, connector);
|
|
if (traceHop == null) return null;
|
|
outboundHops.add(traceHop);
|
|
}
|
|
if (targetPrefix != null) {
|
|
// Check if targetPrefix is already the last hop in pathHops to avoid duplication
|
|
bool alreadyEndedWithTarget = false;
|
|
if (outboundHops.isNotEmpty) {
|
|
if (listEquals(outboundHops.last, targetPrefix)) {
|
|
alreadyEndedWithTarget = true;
|
|
}
|
|
}
|
|
if (!alreadyEndedWithTarget) {
|
|
outboundHops.add(targetPrefix);
|
|
}
|
|
}
|
|
|
|
if (outboundHops.isEmpty) {
|
|
return Uint8List(0);
|
|
}
|
|
|
|
final mirroredHops = <Uint8List>[...outboundHops];
|
|
if (outboundHops.length > 1) {
|
|
mirroredHops.addAll(
|
|
outboundHops.sublist(0, outboundHops.length - 1).reversed,
|
|
);
|
|
}
|
|
|
|
final traceBytes = <int>[];
|
|
for (final hop in mirroredHops) {
|
|
traceBytes.addAll(hop);
|
|
}
|
|
return Uint8List.fromList(traceBytes);
|
|
}
|
|
|
|
Future<void> _doPathTrace() async {
|
|
if (mounted) {
|
|
setState(() {
|
|
_isLoading = true;
|
|
_failed2Loaded = false;
|
|
});
|
|
}
|
|
|
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
|
final traceHashByteWidth = _traceHashByteWidth(widget.pathHashByteWidth);
|
|
final pathTmp = widget.reversePathAround
|
|
? _reversePathByHop(widget.path, widget.pathHashByteWidth)
|
|
: widget.path;
|
|
|
|
final path = widget.flipPathAround
|
|
? buildPath(pathTmp, traceHashByteWidth, connector)
|
|
: _tracePathFromBytes(pathTmp, traceHashByteWidth, connector);
|
|
|
|
if (path == null) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_isLoading = false;
|
|
_failed2Loaded = true;
|
|
});
|
|
return;
|
|
}
|
|
|
|
appLogger.info(
|
|
'Initiating path trace with path: '
|
|
'${_formatPathPrefixes(path, traceHashByteWidth)}',
|
|
tag: 'PathTraceMapScreen',
|
|
noNotify: !mounted,
|
|
);
|
|
|
|
final sentTag = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
|
_sentTagBytes = Uint8List(4)
|
|
..[0] = sentTag & 0xFF
|
|
..[1] = (sentTag >> 8) & 0xFF
|
|
..[2] = (sentTag >> 16) & 0xFF
|
|
..[3] = (sentTag >> 24) & 0xFF;
|
|
|
|
final flags = _traceFlagsForHashWidth(traceHashByteWidth);
|
|
final tracePayload = path.isEmpty ? Uint8List.fromList([0x00]) : path;
|
|
|
|
final frame = buildTraceReq(
|
|
sentTag,
|
|
0, // auth
|
|
flags, // flag
|
|
payload: tracePayload,
|
|
);
|
|
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);
|
|
try {
|
|
final code = frameBuffer.readUInt8();
|
|
|
|
if (code == respCodeSent) {
|
|
frameBuffer.skipBytes(1); //reserved
|
|
tagData = frameBuffer.readBytes(4);
|
|
final timeoutMilliseconds = frameBuffer.readUInt32LE();
|
|
|
|
// Start timeout timer for trace response
|
|
_timeoutTimer?.cancel();
|
|
_timeoutTimer = Timer(
|
|
Duration(milliseconds: timeoutMilliseconds),
|
|
() {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_isLoading = false;
|
|
_failed2Loaded = true;
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
if (code == respCodeErr) {
|
|
_timeoutTimer?.cancel();
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_isLoading = false;
|
|
_failed2Loaded = true;
|
|
});
|
|
}
|
|
|
|
// Check if it's a binary response
|
|
if (frame.length >= 12 &&
|
|
code == pushCodeTraceData &&
|
|
(listEquals(frame.sublist(4, 8), _sentTagBytes) ||
|
|
listEquals(frame.sublist(4, 8), tagData))) {
|
|
_timeoutTimer?.cancel();
|
|
if (!mounted) return;
|
|
_handleTraceResponse(frame);
|
|
}
|
|
} catch (e) {
|
|
_timeoutTimer?.cancel();
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_isLoading = false;
|
|
_failed2Loaded = true;
|
|
});
|
|
// Handle any parsing errors gracefully
|
|
appLogger.error('Error parsing frame: $e', tag: 'PathTraceMapScreen');
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _handleTraceResponse(Uint8List frame) async {
|
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
|
|
|
final buffer = BufferReader(frame);
|
|
try {
|
|
buffer.skipBytes(2); // Skip push code and reserved byte
|
|
final pathLenByte = buffer.readUInt8();
|
|
final flags = buffer.readUInt8();
|
|
buffer.skipBytes(4); // Skip tag data
|
|
buffer.skipBytes(4); // Skip auth code
|
|
var width = _traceHashByteWidth(1 << (flags & 0x03));
|
|
var pathLength = pathLenByte == 0xFF ? 0 : pathLenByte;
|
|
if (pathLength > buffer.remaining && (pathLenByte & 0xC0) != 0) {
|
|
final packedWidth = ((pathLenByte & 0xC0) >> 6) + 1;
|
|
final packedLength = (pathLenByte & 0x3F) * packedWidth;
|
|
if (packedLength <= buffer.remaining) {
|
|
width = packedWidth;
|
|
pathLength = packedLength;
|
|
}
|
|
}
|
|
final pathBytes = buffer.readBytes(pathLength);
|
|
final pathData = PathHelper.splitPathBytes(pathBytes, width);
|
|
List<double> snrData = buffer
|
|
.readRemainingBytes()
|
|
.map((snr) => snr.toSigned(8).toDouble() / 4)
|
|
.toList();
|
|
|
|
Map<String, Contact> pathContacts = {};
|
|
Contact lastContact = Contact(
|
|
path: Uint8List(0),
|
|
pathLength: 0,
|
|
publicKey: connector.selfPublicKey ?? Uint8List(0),
|
|
name: context.l10n.pathTrace_you,
|
|
type: advTypeChat,
|
|
latitude: connector.selfLatitude,
|
|
longitude: connector.selfLongitude,
|
|
lastSeen: DateTime.now(),
|
|
);
|
|
if (widget.pathContacts != null) {
|
|
final hopWidth = width.clamp(1, pubKeySize).toInt();
|
|
pathContacts = {
|
|
for (var c in widget.pathContacts!)
|
|
if (c.publicKey.length >= hopWidth)
|
|
_hopKey(Uint8List.fromList(c.publicKey.sublist(0, hopWidth))): c,
|
|
};
|
|
} else {
|
|
final contacts = connector.allContactsUnfiltered;
|
|
contacts.where((c) => c.type != advTypeChat).forEach((repeater) {
|
|
if (lastContact.latitude != null &&
|
|
lastContact.longitude != null &&
|
|
repeater.hasLocation &&
|
|
lastContact.hasLocation &&
|
|
Distance().distance(
|
|
LatLng(lastContact.latitude!, lastContact.longitude!),
|
|
LatLng(repeater.latitude!, repeater.longitude!),
|
|
) >
|
|
_maxRepeaterMatchDistanceMeters) {
|
|
return; //skip reapeaters that are far away from the last one with known GPS, to avoid false matches
|
|
}
|
|
for (final repeaterData in pathData) {
|
|
final hopWidth = repeaterData.length;
|
|
if (repeater.publicKey.length < hopWidth) continue;
|
|
if (listEquals(
|
|
repeater.publicKey.sublist(0, hopWidth),
|
|
repeaterData,
|
|
)) {
|
|
pathContacts[_hopKey(repeaterData)] = repeater;
|
|
lastContact = repeater;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// For hops with no GPS contact, infer position from other contacts
|
|
// with known GPS that share the same last-hop byte.
|
|
final Map<String, LatLng> inferredPositions = {};
|
|
for (final hop in pathData) {
|
|
final hopKey = _hopKey(hop);
|
|
final contact = pathContacts[hopKey];
|
|
if (contact != null && contact.hasLocation) continue;
|
|
final peers = connector.contacts
|
|
.where(
|
|
(c) =>
|
|
c.hasLocation &&
|
|
c.path.isNotEmpty &&
|
|
_matchesHopPrefix(
|
|
_lastHopChunk(c.path, widget.pathHashByteWidth),
|
|
hop,
|
|
),
|
|
)
|
|
.toList();
|
|
if (peers.isNotEmpty) {
|
|
final lat =
|
|
peers.map((c) => c.latitude!).reduce((a, b) => a + b) /
|
|
peers.length;
|
|
final lon =
|
|
peers.map((c) => c.longitude!).reduce((a, b) => a + b) /
|
|
peers.length;
|
|
inferredPositions[hopKey] = LatLng(lat, lon);
|
|
}
|
|
}
|
|
|
|
setState(() {
|
|
_isLoading = false;
|
|
_hasData = true;
|
|
_inferredHopPositions = inferredPositions;
|
|
_traceData = PathTraceData(
|
|
pathData: pathData,
|
|
snrData: snrData,
|
|
pathContacts: pathContacts,
|
|
);
|
|
// Compute endpoint position for the target contact.
|
|
LatLng? targetPos;
|
|
bool targetGuessed = false;
|
|
_targetContact = widget.targetContact;
|
|
|
|
if (_targetContact != null) {
|
|
final tc = _targetContact!;
|
|
if (tc.hasLocation) {
|
|
targetPos = LatLng(tc.latitude!, tc.longitude!);
|
|
} else if (pathData.length > 1) {
|
|
// Infer from the last hop: average GPS contacts sharing that hop.
|
|
// For a round-trip path (flipPathAround/reversePathAround), the target-side hop
|
|
// sits in the middle of the symmetric sequence; .last is the local side.
|
|
final lastHop = widget.reversePathAround
|
|
? pathData.first
|
|
: pathData.last;
|
|
final lastHopKey = _hopKey(lastHop);
|
|
|
|
final peers = connector.allContacts
|
|
.where(
|
|
(c) =>
|
|
c.hasLocation &&
|
|
c.path.isNotEmpty &&
|
|
_matchesHopPrefix(
|
|
_lastHopChunk(c.path, widget.pathHashByteWidth),
|
|
lastHop,
|
|
),
|
|
)
|
|
.toList();
|
|
if (peers.isNotEmpty) {
|
|
final lat =
|
|
peers.map((c) => c.latitude!).reduce((a, b) => a + b) /
|
|
peers.length;
|
|
final lon =
|
|
peers.map((c) => c.longitude!).reduce((a, b) => a + b) /
|
|
peers.length;
|
|
const offsetDeg = 0.003;
|
|
final angle = (tc.publicKey[1] / 255.0) * 2 * pi;
|
|
targetPos = LatLng(
|
|
lat + offsetDeg * cos(angle),
|
|
lon + offsetDeg * sin(angle),
|
|
);
|
|
targetGuessed = true;
|
|
} else if (inferredPositions.containsKey(lastHopKey)) {
|
|
final lat = inferredPositions[lastHopKey]!.latitude;
|
|
final lon = inferredPositions[lastHopKey]!.longitude;
|
|
const offsetDeg = 0.003;
|
|
final angle = (tc.publicKey[1] / 255.0) * 2 * pi;
|
|
targetPos = LatLng(
|
|
lat + offsetDeg * cos(angle),
|
|
lon + offsetDeg * sin(angle),
|
|
);
|
|
targetGuessed = true;
|
|
} else {
|
|
// As a last resort, just place it at the same position as the last hop.
|
|
final contact = pathContacts[lastHopKey];
|
|
if (contact != null && contact.hasLocation) {
|
|
const offsetDeg = 0.003;
|
|
final angle = (tc.publicKey[1] / 255.0) * 2 * pi;
|
|
targetPos = LatLng(
|
|
contact.latitude! + offsetDeg * cos(angle),
|
|
contact.longitude! + offsetDeg * sin(angle),
|
|
);
|
|
targetGuessed = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
_targetContactPosition = targetPos;
|
|
_targetContactIsGuessed = targetGuessed;
|
|
|
|
_points = <LatLng>[];
|
|
_points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!));
|
|
String hopLast = '';
|
|
String hopLastLast = '';
|
|
for (final hop in _traceData!.pathData) {
|
|
final hopKey = _hopKey(hop);
|
|
if (hopKey == hopLastLast && widget.flipPathAround) {
|
|
break; //skip duplicate hops in round-trip paths
|
|
}
|
|
final contact = _traceData!.pathContacts[hopKey];
|
|
if (contact != null && contact.hasLocation) {
|
|
_points.add(LatLng(contact.latitude!, contact.longitude!));
|
|
} else {
|
|
final inferred = inferredPositions[hopKey];
|
|
if (inferred != null) _points.add(inferred);
|
|
}
|
|
hopLastLast = hopLast;
|
|
hopLast = hopKey;
|
|
}
|
|
if (targetPos != null) {
|
|
if (_targetContact != null && _targetContact!.type == advTypeChat) {
|
|
_points.add(targetPos);
|
|
}
|
|
}
|
|
_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},${_traceData!.pathData.map(PathHelper.formatHopHex).join(',')}',
|
|
);
|
|
_pathDistanceMeters = getPathDistanceMeters(_points);
|
|
});
|
|
} catch (e) {
|
|
appLogger.error(
|
|
'Error handling trace response: $e',
|
|
tag: 'PathTraceMapScreen',
|
|
);
|
|
if (mounted) {
|
|
setState(() {
|
|
_isLoading = false;
|
|
_failed2Loaded = true;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Consumer<MeshCoreConnector>(
|
|
builder: (context, connector, _) {
|
|
final settings = context.watch<AppSettingsService>().settings;
|
|
final isImperial = settings.unitSystem == UnitSystem.imperial;
|
|
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 (!_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)
|
|
_buildMapPathTrace(context, tileCache, _targetContact),
|
|
if (_hasData && _isDesktopPlatform(defaultTargetPlatform))
|
|
_buildDesktopMapControls(),
|
|
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!, isImperial),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
List<Marker> _buildHopMarkers(
|
|
List<Uint8List> pathData, {
|
|
required bool showLabels,
|
|
required Contact? target,
|
|
}) {
|
|
final markers = <Marker>[];
|
|
String hopLast = '';
|
|
String hopLastLast = '';
|
|
for (final hop in pathData) {
|
|
final hopKey = _hopKey(hop);
|
|
final contact = _traceData!.pathContacts[hopKey];
|
|
final inferred = _inferredHopPositions[hopKey];
|
|
final hasGps = contact != null && contact.hasLocation;
|
|
if (hopKey == hopLastLast && widget.flipPathAround) {
|
|
continue; //skip duplicate hops in round-trip paths
|
|
}
|
|
if (!hasGps && inferred == null) {
|
|
hopLastLast = hopLast;
|
|
hopLast = hopKey;
|
|
continue; //skip hops with no GPS and no inferred position
|
|
}
|
|
final point = hasGps
|
|
? LatLng(contact.latitude!, contact.longitude!)
|
|
: inferred!;
|
|
final label = PathHelper.formatHopHex(hop);
|
|
final shortLabel = label.length > 2 ? label.substring(0, 2) : label;
|
|
final fullLabel = label.length > 2
|
|
? (contact?.name != null ? '$label: ${contact!.name}' : label)
|
|
: (contact?.name ?? label);
|
|
|
|
markers.add(
|
|
Marker(
|
|
point: point,
|
|
width: 35,
|
|
height: 35,
|
|
child: Container(
|
|
padding: const EdgeInsets.all(4),
|
|
decoration: BoxDecoration(
|
|
color: hasGps
|
|
? Colors.green
|
|
: Colors.orange.withValues(alpha: 0.75),
|
|
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(
|
|
hasGps ? shortLabel : '~$shortLabel',
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
if (showLabels) {
|
|
markers.add(_buildNodeLabelMarker(point: point, label: fullLabel));
|
|
}
|
|
hopLastLast = hopLast;
|
|
hopLast = hopKey;
|
|
}
|
|
|
|
final selfLat = context.read<MeshCoreConnector>().selfLatitude;
|
|
final selfLon = context.read<MeshCoreConnector>().selfLongitude;
|
|
if (selfLat != null && selfLon != null) {
|
|
final selfPoint = LatLng(selfLat, selfLon);
|
|
markers.add(
|
|
Marker(
|
|
point: selfPoint,
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
if (showLabels) {
|
|
markers.add(
|
|
_buildNodeLabelMarker(
|
|
point: selfPoint,
|
|
label: context.l10n.pathTrace_you,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Add target contact endpoint marker.
|
|
final targetPos = _targetContactPosition;
|
|
if (targetPos != null && target != null && target.type == advTypeChat) {
|
|
final isGuessed = _targetContactIsGuessed;
|
|
final targetName = target.name;
|
|
markers.add(
|
|
Marker(
|
|
point: targetPos,
|
|
width: 35,
|
|
height: 35,
|
|
child: Container(
|
|
padding: const EdgeInsets.all(4),
|
|
decoration: BoxDecoration(
|
|
color: isGuessed
|
|
? Colors.purple.withValues(alpha: 0.55)
|
|
: Colors.red,
|
|
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: const Icon(Icons.person, color: Colors.white, size: 18),
|
|
),
|
|
),
|
|
);
|
|
if (showLabels) {
|
|
markers.add(
|
|
_buildNodeLabelMarker(
|
|
point: targetPos,
|
|
label: isGuessed ? '~$targetName' : targetName,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
return markers;
|
|
}
|
|
|
|
Marker _buildNodeLabelMarker({required LatLng point, required String label}) {
|
|
return Marker(
|
|
point: point,
|
|
width: 120,
|
|
height: 24,
|
|
alignment: Alignment.topCenter,
|
|
child: IgnorePointer(
|
|
child: Transform.translate(
|
|
offset: const Offset(0, -20),
|
|
child: FittedBox(
|
|
fit: BoxFit.contain,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: Colors.black54,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
alignment: Alignment.center,
|
|
child: Text(
|
|
label,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
String formatDirectionText(PathTraceData pathTraceData, int index) {
|
|
if (pathTraceData.pathData.isEmpty) {
|
|
return context.l10n.pathTrace_you;
|
|
}
|
|
if (index < 0 || index > pathTraceData.pathData.length) {
|
|
return context.l10n.pathTrace_you;
|
|
}
|
|
|
|
if (index == 0 || index == pathTraceData.pathData.length) {
|
|
if (index == 0) {
|
|
return context.l10n.pathTrace_you;
|
|
} else {
|
|
final hop = pathTraceData.pathData.last;
|
|
final contactName = pathTraceData.pathContacts[_hopKey(hop)]?.name;
|
|
final hex = PathHelper.formatHopHex(hop);
|
|
return contactName != null
|
|
? "$hex: $contactName"
|
|
: "$hex: ${context.l10n.channelPath_unknownRepeater}";
|
|
}
|
|
} else {
|
|
final hop = pathTraceData.pathData[index - 1];
|
|
final contactName = pathTraceData.pathContacts[_hopKey(hop)]?.name;
|
|
final hex = PathHelper.formatHopHex(hop);
|
|
return contactName != null
|
|
? "$hex: $contactName"
|
|
: "$hex: ${context.l10n.channelPath_unknownRepeater}";
|
|
}
|
|
}
|
|
|
|
String formatDirectionSubText(PathTraceData pathTraceData, int index) {
|
|
if (pathTraceData.pathData.isEmpty) {
|
|
return context.l10n.pathTrace_you;
|
|
}
|
|
if (index < 0 || index > pathTraceData.pathData.length) {
|
|
return context.l10n.pathTrace_you;
|
|
}
|
|
|
|
if (index == 0 || index == pathTraceData.pathData.length) {
|
|
if (index == 0) {
|
|
final hop = pathTraceData.pathData.first;
|
|
final contactName = pathTraceData.pathContacts[_hopKey(hop)]?.name;
|
|
final hex = PathHelper.formatHopHex(hop);
|
|
return contactName != null
|
|
? "$hex: $contactName"
|
|
: "$hex: ${context.l10n.channelPath_unknownRepeater}";
|
|
} else {
|
|
return context.l10n.pathTrace_you;
|
|
}
|
|
} else {
|
|
final hop = pathTraceData.pathData[index];
|
|
final contactName = pathTraceData.pathContacts[_hopKey(hop)]?.name;
|
|
final hex = PathHelper.formatHopHex(hop);
|
|
return contactName != null
|
|
? "$hex: $contactName"
|
|
: "$hex: ${context.l10n.channelPath_unknownRepeater}";
|
|
}
|
|
}
|
|
|
|
Widget _buildMapPathTrace(
|
|
BuildContext context,
|
|
MapTileCacheService tileCache,
|
|
Contact? target,
|
|
) {
|
|
final isDesktop = _isDesktopPlatform(defaultTargetPlatform);
|
|
return FlutterMap(
|
|
key: _mapKey,
|
|
mapController: _mapController,
|
|
options: MapOptions(
|
|
interactionOptions: InteractionOptions(
|
|
flags: ~InteractiveFlag.rotate,
|
|
scrollWheelVelocity: isDesktop ? 0.012 : 0.005,
|
|
cursorKeyboardRotationOptions:
|
|
CursorKeyboardRotationOptions.disabled(),
|
|
keyboardOptions: isDesktop
|
|
? const KeyboardOptions(
|
|
enableArrowKeysPanning: true,
|
|
enableWASDPanning: true,
|
|
enableRFZooming: true,
|
|
)
|
|
: const KeyboardOptions.disabled(),
|
|
),
|
|
initialCenter: _initialCenter!,
|
|
initialZoom: _initialZoom,
|
|
initialCameraFit: _bounds == null
|
|
? null
|
|
: CameraFit.bounds(
|
|
bounds: _bounds!,
|
|
padding: const EdgeInsets.all(64),
|
|
maxZoom: 16,
|
|
),
|
|
minZoom: _mapMinZoom,
|
|
maxZoom: _mapMaxZoom,
|
|
onPositionChanged: (camera, hasGesture) {
|
|
final shouldShow = camera.zoom >= _labelZoomThreshold;
|
|
if (shouldShow != _showNodeLabels && mounted) {
|
|
setState(() {
|
|
_showNodeLabels = shouldShow;
|
|
});
|
|
}
|
|
},
|
|
),
|
|
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,
|
|
showLabels: _showNodeLabels,
|
|
target: target,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildLegendCard(
|
|
BuildContext context,
|
|
PathTraceData pathTraceData,
|
|
bool isImperial,
|
|
) {
|
|
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} ${formatDistance(_pathDistanceMeters, isImperial: isImperial)}',
|
|
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) {
|
|
final snrUi = snrUiFromSNR(
|
|
index < pathTraceData.snrData.length
|
|
? pathTraceData.snrData[index]
|
|
: null,
|
|
context.read<MeshCoreConnector>().currentSf,
|
|
);
|
|
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: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
snrUi.icon,
|
|
color: snrUi.color,
|
|
size: 18.0,
|
|
),
|
|
Text(
|
|
snrUi.text,
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
color: snrUi.color,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
onTap: () {
|
|
// Handle item tap
|
|
},
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|