mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-04-20 22:13:48 +00:00
* Refactor contact filtering and improve localization strings; enhance path trace handling * Add localization for new CLI commands and update existing strings * Enhance contact handling and UI updates across multiple screens add unfiltered contact access and improve last seen resolution * Add polling interval configuration and improve contact handling * Reorder command constants for better organization and clarity * Refactor contact handling by removing unnecessary mapping and improving clarity across multiple screens * Moved RadioStatsIconButton in chat screen for improved UI consistency * Added indicators to AppBar for channels * Ignore contacts with self public key in contact handling * Simplify path removal logic and clean up unused imports in path management dialog * Enhance path hop resolution by adding distance checks to improve candidate selection accuracy * Remove unnecessary reset of radio stats poll reference count in polling interval setter
944 lines
31 KiB
Dart
944 lines
31 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/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 Uint8List pathData;
|
|
final List<double> 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 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;
|
|
//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;
|
|
|
|
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 byte.
|
|
Map<int, 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;
|
|
|
|
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 buildPath(Uint8List pathBytes) {
|
|
Uint8List traceBytes;
|
|
|
|
if (pathBytes.isEmpty) {
|
|
final pk = widget.targetContact?.publicKey;
|
|
final n = widget.pathHashByteWidth.clamp(1, pubKeySize);
|
|
if (pk != null && pk.length >= n) {
|
|
return Uint8List.fromList(pk.sublist(0, n));
|
|
}
|
|
traceBytes = Uint8List(1);
|
|
traceBytes[0] = pk?[0] ?? 0;
|
|
return traceBytes;
|
|
}
|
|
|
|
if (widget.targetContact?.type == advTypeRepeater ||
|
|
widget.targetContact?.type == advTypeRoom) {
|
|
final len = (pathBytes.length + pathBytes.length + 1);
|
|
traceBytes = Uint8List(len);
|
|
traceBytes[pathBytes.length] = widget.targetContact?.publicKey[0] ?? 0;
|
|
for (int i = 0; i < pathBytes.length; i++) {
|
|
traceBytes[i] = pathBytes[i];
|
|
if (i < pathBytes.length) {
|
|
traceBytes[len - 1 - i] = pathBytes[i];
|
|
}
|
|
}
|
|
} else {
|
|
if (pathBytes.length < 2) {
|
|
return pathBytes[0] == 0 ? Uint8List(0) : pathBytes;
|
|
}
|
|
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;
|
|
}
|
|
|
|
Future<void> _doPathTrace() async {
|
|
if (mounted) {
|
|
setState(() {
|
|
_isLoading = true;
|
|
_failed2Loaded = false;
|
|
});
|
|
}
|
|
|
|
final pathTmp = widget.reversePathAround
|
|
? Uint8List.fromList(widget.path.reversed.toList())
|
|
: widget.path;
|
|
|
|
final path = widget.flipPathAround ? buildPath(pathTmp) : pathTmp;
|
|
|
|
appLogger.info(
|
|
'Initiating path trace with path: ${_formatPathPrefixes(path)}',
|
|
tag: 'PathTraceMapScreen',
|
|
noNotify: !mounted,
|
|
);
|
|
|
|
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);
|
|
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 > 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);
|
|
}
|
|
}
|
|
} 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
|
|
int pathLength = buffer.readUInt8();
|
|
buffer.skipBytes(5); // Skip Flag byte and tag data
|
|
buffer.skipBytes(4); // Skip auth code
|
|
Uint8List pathData = buffer.readBytes(pathLength);
|
|
List<double> snrData = buffer
|
|
.readRemainingBytes()
|
|
.map((snr) => snr.toSigned(8).toDouble() / 4)
|
|
.toList();
|
|
|
|
Map<int, 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) {
|
|
pathContacts = {for (var c in widget.pathContacts!) c.publicKey[0]: 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 (var repeaterData in pathData) {
|
|
if (listEquals(
|
|
repeater.publicKey.sublist(0, 1),
|
|
Uint8List.fromList([repeaterData]),
|
|
)) {
|
|
pathContacts[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<int, LatLng> inferredPositions = {};
|
|
for (final hop in pathData) {
|
|
final contact = pathContacts[hop];
|
|
if (contact != null && contact.hasLocation) continue;
|
|
final peers = connector.contacts
|
|
.where(
|
|
(c) => c.hasLocation && c.path.isNotEmpty && c.path.last == 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[hop] = 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 (widget.path.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
|
|
? widget.path.first
|
|
: widget.path.last;
|
|
|
|
final peers = connector.allContacts
|
|
.where(
|
|
(c) =>
|
|
c.hasLocation &&
|
|
c.path.isNotEmpty &&
|
|
c.path.last == 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(lastHop)) {
|
|
final lat = inferredPositions[lastHop]!.latitude;
|
|
final lon = inferredPositions[lastHop]!.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[lastHop];
|
|
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!));
|
|
int hopLast = 0;
|
|
int hopLastLast = 0;
|
|
for (final hop in _traceData!.pathData) {
|
|
if (hop == hopLastLast && widget.flipPathAround) {
|
|
break; //skip duplicate hops in round-trip paths
|
|
}
|
|
final contact = _traceData!.pathContacts[hop];
|
|
if (contact != null && contact.hasLocation) {
|
|
_points.add(LatLng(contact.latitude!, contact.longitude!));
|
|
} else {
|
|
final inferred = inferredPositions[hop];
|
|
if (inferred != null) _points.add(inferred);
|
|
}
|
|
hopLastLast = hopLast;
|
|
hopLast = hop;
|
|
}
|
|
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},${_formatPathPrefixes(_traceData!.pathData)}',
|
|
);
|
|
_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 (_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<int> pathData, {
|
|
required bool showLabels,
|
|
required Contact? target,
|
|
}) {
|
|
final markers = <Marker>[];
|
|
int hopLast = 0;
|
|
int hopLastLast = 0;
|
|
for (final hop in pathData) {
|
|
final contact = _traceData!.pathContacts[hop];
|
|
final inferred = _inferredHopPositions[hop];
|
|
final hasGps = contact != null && contact.hasLocation;
|
|
if (hop == hopLastLast && widget.flipPathAround) {
|
|
continue; //skip duplicate hops in round-trip paths
|
|
}
|
|
if (!hasGps && inferred == null) {
|
|
hopLastLast = hopLast;
|
|
hopLast = hop;
|
|
continue; //skip hops with no GPS and no inferred position
|
|
}
|
|
final point = hasGps
|
|
? LatLng(contact.latitude!, contact.longitude!)
|
|
: inferred!;
|
|
final label = hop.toRadixString(16).padLeft(2, '0').toUpperCase();
|
|
|
|
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 ? label : '~$label',
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
if (showLabels) {
|
|
markers.add(
|
|
_buildNodeLabelMarker(
|
|
point: point,
|
|
label: contact?.name ?? '~$label',
|
|
),
|
|
);
|
|
}
|
|
hopLastLast = hopLast;
|
|
hopLast = hop;
|
|
}
|
|
|
|
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 (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: ${context.l10n.channelPath_unknownRepeater}";
|
|
}
|
|
} 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: ${context.l10n.channelPath_unknownRepeater}";
|
|
}
|
|
}
|
|
|
|
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: ${context.l10n.channelPath_unknownRepeater}";
|
|
} 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: ${context.l10n.channelPath_unknownRepeater}";
|
|
}
|
|
}
|
|
|
|
Widget _buildMapPathTrace(
|
|
BuildContext context,
|
|
MapTileCacheService tileCache,
|
|
Contact? target,
|
|
) {
|
|
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,
|
|
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
|
|
},
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|