mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-07-28 14:36:39 +10:00
Add guessed node location map keys and translations
Adds map_showGuessedLocations and map_guessedLocation to app_en.arb and translates them across all 14 supported locales. Regenerates dart localizations.
This commit is contained in:
@@ -818,6 +818,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
title: context.l10n.contacts_repeaterPathTrace,
|
||||
path: Uint8List.fromList(pathBytes),
|
||||
flipPathRound: true,
|
||||
targetContact: widget.contact,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1131,6 +1131,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
contact.name,
|
||||
),
|
||||
path: contact.traceRouteBytes ?? Uint8List(0),
|
||||
targetContact: contact,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
+276
-8
@@ -15,6 +15,7 @@ import '../models/app_settings.dart';
|
||||
import '../models/channel.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../services/app_settings_service.dart';
|
||||
import '../services/path_history_service.dart';
|
||||
import '../services/map_marker_service.dart';
|
||||
import '../services/map_tile_cache_service.dart';
|
||||
import '../utils/contact_search.dart';
|
||||
@@ -64,6 +65,8 @@ class _MapScreenState extends State<MapScreen> {
|
||||
final List<Polyline> _polylines = [];
|
||||
bool _legendExpanded = false;
|
||||
bool _showNodeLabels = true;
|
||||
List<_GuessedLocation> _cachedGuessedLocations = [];
|
||||
String _guessedLocationsCacheKey = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -119,8 +122,8 @@ class _MapScreenState extends State<MapScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer2<MeshCoreConnector, AppSettingsService>(
|
||||
builder: (context, connector, settingsService, child) {
|
||||
return Consumer3<MeshCoreConnector, AppSettingsService, PathHistoryService>(
|
||||
builder: (context, connector, settingsService, pathHistory, child) {
|
||||
final tileCache = context.read<MapTileCacheService>();
|
||||
final settings = settingsService.settings;
|
||||
final contacts = connector.contacts;
|
||||
@@ -160,6 +163,31 @@ class _MapScreenState extends State<MapScreen> {
|
||||
.where((c) => c.hasLocation)
|
||||
.toList();
|
||||
|
||||
// All contacts with a known location — used as anchors regardless of
|
||||
// time/key-prefix filters so that repeaters are always available.
|
||||
final allContactsWithLocation = contacts
|
||||
.where((c) => c.hasLocation)
|
||||
.toList();
|
||||
|
||||
// Compute guessed locations with caching
|
||||
final maxRangeKm = _estimateLoRaRangeKm(connector);
|
||||
final cacheKey =
|
||||
'${filteredByKeyPrefix.length}:${allContactsWithLocation.length}:${pathHistory.version}:${connector.currentSf}:${connector.currentBwHz}:${connector.currentTxPower}:${settings.mapShowGuessedLocations}';
|
||||
if (cacheKey != _guessedLocationsCacheKey) {
|
||||
_guessedLocationsCacheKey = cacheKey;
|
||||
_cachedGuessedLocations = settings.mapShowGuessedLocations
|
||||
? _computeGuessedLocations(
|
||||
filteredByKeyPrefix,
|
||||
allContactsWithLocation,
|
||||
pathHistory,
|
||||
maxRangeKm,
|
||||
)
|
||||
: [];
|
||||
}
|
||||
final guessedLocations = settings.mapShowGuessedLocations
|
||||
? _cachedGuessedLocations
|
||||
: <_GuessedLocation>[];
|
||||
|
||||
_polylines.clear();
|
||||
_polylines.addAll(
|
||||
_points.length > 1
|
||||
@@ -430,6 +458,8 @@ class _MapScreenState extends State<MapScreen> {
|
||||
size: 34,
|
||||
),
|
||||
),
|
||||
if (!_isBuildingPathTrace)
|
||||
...guessedLocations.map(_buildGuessedMarker),
|
||||
..._buildMarkers(
|
||||
contactsWithLocation,
|
||||
settings,
|
||||
@@ -489,6 +519,7 @@ class _MapScreenState extends State<MapScreen> {
|
||||
contactsWithLocation,
|
||||
settings,
|
||||
sharedMarkers.length,
|
||||
guessedLocations.length,
|
||||
),
|
||||
if (_isBuildingPathTrace) _buildPathTraceOverlay(),
|
||||
],
|
||||
@@ -512,6 +543,201 @@ class _MapScreenState extends State<MapScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
List<_GuessedLocation> _computeGuessedLocations(
|
||||
List<Contact> allContacts,
|
||||
List<Contact> withLocation,
|
||||
PathHistoryService pathHistory,
|
||||
double? maxRangeKm,
|
||||
) {
|
||||
// Index known-location repeaters by their 1-byte hash.
|
||||
// null value = two repeaters share the same hash byte (ambiguous collision).
|
||||
final repeaterByHash = <int, Contact?>{};
|
||||
for (final c in withLocation) {
|
||||
if (c.type == advTypeRepeater) {
|
||||
if (repeaterByHash.containsKey(c.publicKey[0])) {
|
||||
repeaterByHash[c.publicKey[0]] = null; // collision: can't disambiguate
|
||||
} else {
|
||||
repeaterByHash[c.publicKey[0]] = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final result = <_GuessedLocation>[];
|
||||
|
||||
for (final contact in allContacts) {
|
||||
if (contact.hasLocation) continue;
|
||||
|
||||
final anchorSet = <LatLng>{};
|
||||
|
||||
// Collect the contact-side (last-hop) repeater from every known path.
|
||||
// path = [device-side hop, ..., contact-side hop]
|
||||
// Only path.last is actually within radio range of the contact — using
|
||||
// earlier bytes would anchor against our own side of the network.
|
||||
final pathSets = <List<int>>[
|
||||
contact.path.toList(),
|
||||
...pathHistory
|
||||
.getRecentPaths(contact.publicKeyHex)
|
||||
.map((r) => r.pathBytes),
|
||||
];
|
||||
final lastHopBytes = <int>{};
|
||||
for (final pathBytes in pathSets) {
|
||||
if (pathBytes.isEmpty) continue;
|
||||
final lastHop = pathBytes.last;
|
||||
lastHopBytes.add(lastHop);
|
||||
final r = repeaterByHash[lastHop];
|
||||
if (r != null) anchorSet.add(LatLng(r.latitude!, r.longitude!));
|
||||
}
|
||||
|
||||
// Fallback: for any last-hop byte with no GPS repeater, average the
|
||||
// positions of contacts with known GPS that share the same last hop.
|
||||
// Those contacts are all adjacent to the same unknown repeater, so their
|
||||
// centroid is a reasonable proxy for its location.
|
||||
for (final byte in lastHopBytes) {
|
||||
if (repeaterByHash.containsKey(byte)) continue;
|
||||
for (final c in withLocation) {
|
||||
if (c.path.isNotEmpty && c.path.last == byte) {
|
||||
anchorSet.add(LatLng(c.latitude!, c.longitude!));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter anchors that are geometrically inconsistent with radio range.
|
||||
// Two anchors more than 2 * maxRange apart cannot both be in direct radio
|
||||
// range of the same node, so isolated outliers are removed.
|
||||
final anchors = maxRangeKm != null && anchorSet.length > 1
|
||||
? _filterConsistentAnchors(anchorSet.toList(), maxRangeKm)
|
||||
: anchorSet.toList();
|
||||
|
||||
if (anchors.isEmpty) continue;
|
||||
|
||||
final LatLng position;
|
||||
if (anchors.length == 1) {
|
||||
// Offset single-anchor guesses so they don't overlap the repeater marker.
|
||||
// Use the contact's public key byte as a deterministic angle seed.
|
||||
const offsetDeg = 0.003; // ~330 m at the equator
|
||||
final angle = (contact.publicKey[1] / 255.0) * 2 * pi;
|
||||
position = LatLng(
|
||||
anchors[0].latitude + offsetDeg * cos(angle),
|
||||
anchors[0].longitude + offsetDeg * sin(angle),
|
||||
);
|
||||
} else {
|
||||
double lat = 0, lon = 0;
|
||||
for (final a in anchors) {
|
||||
lat += a.latitude;
|
||||
lon += a.longitude;
|
||||
}
|
||||
position = LatLng(lat / anchors.length, lon / anchors.length);
|
||||
}
|
||||
result.add(
|
||||
_GuessedLocation(
|
||||
contact: contact,
|
||||
position: position,
|
||||
highConfidence: anchors.length >= 2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Estimates the free-space maximum LoRa range in km from the connected
|
||||
/// device's current radio parameters. Returns null if parameters are unknown.
|
||||
double? _estimateLoRaRangeKm(MeshCoreConnector connector) {
|
||||
final freqHz = connector.currentFreqHz;
|
||||
final bwHz = connector.currentBwHz;
|
||||
final sf = connector.currentSf;
|
||||
final txPower = connector.currentTxPower;
|
||||
if (freqHz == null || bwHz == null || sf == null || txPower == null) {
|
||||
return null;
|
||||
}
|
||||
// LoRa receiver sensitivity = thermal noise + NF + required demod SNR
|
||||
const noiseFigureDb = 6.0;
|
||||
final thermalNoiseDbm = -174.0 + 10 * log(bwHz.toDouble()) / ln10;
|
||||
final sensitivityDbm =
|
||||
thermalNoiseDbm + noiseFigureDb + _sfToRequiredSnrDb(sf);
|
||||
// FSPL at max range equals link budget:
|
||||
// FSPL = 20*log10(d_m) + 20*log10(f_hz) - 147.55
|
||||
final linkBudgetDb = txPower.toDouble() - sensitivityDbm;
|
||||
final exponent =
|
||||
(linkBudgetDb + 147.55 - 20 * log(freqHz.toDouble()) / ln10) / 20;
|
||||
return pow(10, exponent) / 1000;
|
||||
}
|
||||
|
||||
double _sfToRequiredSnrDb(int sf) {
|
||||
switch (sf) {
|
||||
case 5:
|
||||
return -2.5;
|
||||
case 6:
|
||||
return -5.0;
|
||||
case 7:
|
||||
return -7.5;
|
||||
case 8:
|
||||
return -10.0;
|
||||
case 9:
|
||||
return -12.5;
|
||||
case 10:
|
||||
return -15.0;
|
||||
case 11:
|
||||
return -17.5;
|
||||
case 12:
|
||||
return -20.0;
|
||||
default:
|
||||
return -10.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes anchors that have no neighbour within 2 * maxRangeKm.
|
||||
/// A node cannot be simultaneously in radio range of two points farther apart
|
||||
/// than twice the expected maximum range.
|
||||
List<LatLng> _filterConsistentAnchors(
|
||||
List<LatLng> anchors,
|
||||
double maxRangeKm,
|
||||
) {
|
||||
const distance = Distance();
|
||||
final maxDistM = maxRangeKm * 2000;
|
||||
return anchors
|
||||
.where(
|
||||
(a) => anchors.any((b) => b != a && distance(a, b) <= maxDistM),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Marker _buildGuessedMarker(_GuessedLocation guess) {
|
||||
final color = _getNodeColor(guess.contact.type);
|
||||
return Marker(
|
||||
point: guess.position,
|
||||
width: 35,
|
||||
height: 35,
|
||||
child: GestureDetector(
|
||||
onTap: () => _showNodeInfo(
|
||||
context,
|
||||
guess.contact,
|
||||
guessedPosition: guess.position,
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: guess.highConfidence ? 0.55 : 0.30),
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.not_listed_location,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Marker> _buildMarkers(
|
||||
List<Contact> contacts,
|
||||
settings, {
|
||||
@@ -657,6 +883,7 @@ class _MapScreenState extends State<MapScreen> {
|
||||
List<Contact> contactsWithLocation,
|
||||
settings,
|
||||
int markerCount,
|
||||
int guessedCount,
|
||||
) {
|
||||
int nodeCount = 0;
|
||||
for (final contact in contactsWithLocation) {
|
||||
@@ -696,7 +923,12 @@ class _MapScreenState extends State<MapScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.map_nodesCount(nodeCount),
|
||||
context.l10n.map_nodesCount(
|
||||
nodeCount +
|
||||
(settings.mapShowGuessedLocations
|
||||
? guessedCount
|
||||
: 0),
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
@@ -764,6 +996,12 @@ class _MapScreenState extends State<MapScreen> {
|
||||
context.l10n.map_pinPublic,
|
||||
Colors.orange,
|
||||
),
|
||||
if (settings.mapShowGuessedLocations && guessedCount > 0)
|
||||
_buildLegendItem(
|
||||
Icons.not_listed_location,
|
||||
context.l10n.map_guessedLocation,
|
||||
Colors.grey,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -952,7 +1190,11 @@ class _MapScreenState extends State<MapScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
void _showNodeInfo(BuildContext context, Contact contact) {
|
||||
void _showNodeInfo(
|
||||
BuildContext context,
|
||||
Contact contact, {
|
||||
LatLng? guessedPosition,
|
||||
}) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
@@ -972,10 +1214,16 @@ class _MapScreenState extends State<MapScreen> {
|
||||
children: [
|
||||
_buildInfoRow('Type', contact.typeLabel),
|
||||
_buildInfoRow('Path', contact.pathLabel),
|
||||
_buildInfoRow(
|
||||
'Location',
|
||||
'${contact.latitude!.toStringAsFixed(6)}, ${contact.longitude!.toStringAsFixed(6)}',
|
||||
),
|
||||
if (contact.hasLocation)
|
||||
_buildInfoRow(
|
||||
'Location',
|
||||
'${contact.latitude!.toStringAsFixed(6)}, ${contact.longitude!.toStringAsFixed(6)}',
|
||||
)
|
||||
else if (guessedPosition != null)
|
||||
_buildInfoRow(
|
||||
'Est. Location',
|
||||
'~${guessedPosition.latitude.toStringAsFixed(6)}, ${guessedPosition.longitude.toStringAsFixed(6)}',
|
||||
),
|
||||
_buildInfoRow(
|
||||
context.l10n.map_lastSeen,
|
||||
_formatLastSeen(contact.lastSeen),
|
||||
@@ -1481,6 +1729,14 @@ class _MapScreenState extends State<MapScreen> {
|
||||
},
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
CheckboxListTile(
|
||||
title: Text(context.l10n.map_showGuessedLocations),
|
||||
value: settings.mapShowGuessedLocations,
|
||||
onChanged: (value) {
|
||||
service.setMapShowGuessedLocations(value ?? true);
|
||||
},
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.map_keyPrefix,
|
||||
@@ -1744,6 +2000,18 @@ class _MapScreenState extends State<MapScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
class _GuessedLocation {
|
||||
final Contact contact;
|
||||
final LatLng position;
|
||||
final bool highConfidence;
|
||||
|
||||
_GuessedLocation({
|
||||
required this.contact,
|
||||
required this.position,
|
||||
required this.highConfidence,
|
||||
});
|
||||
}
|
||||
|
||||
class _MarkerPayload {
|
||||
final LatLng position;
|
||||
final String label;
|
||||
|
||||
+134
-12
@@ -54,6 +54,7 @@ class PathTraceMapScreen extends StatefulWidget {
|
||||
final int? repeaterId;
|
||||
final bool flipPathRound;
|
||||
final bool reversePathRound;
|
||||
final Contact? targetContact;
|
||||
|
||||
const PathTraceMapScreen({
|
||||
super.key,
|
||||
@@ -62,6 +63,7 @@ class PathTraceMapScreen extends StatefulWidget {
|
||||
this.repeaterId,
|
||||
this.flipPathRound = false,
|
||||
this.reversePathRound = false,
|
||||
this.targetContact,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -78,6 +80,11 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
||||
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);
|
||||
@@ -242,25 +249,90 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
||||
}
|
||||
});
|
||||
|
||||
// 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;
|
||||
final target = widget.targetContact;
|
||||
if (target != null) {
|
||||
if (target.hasLocation) {
|
||||
targetPos = LatLng(target.latitude!, target.longitude!);
|
||||
} else if (pathData.isNotEmpty) {
|
||||
// Infer from the last hop: average GPS contacts sharing that hop.
|
||||
final lastHop = pathData.last;
|
||||
final peers = connector.contacts
|
||||
.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 = (target.publicKey[1] / 255.0) * 2 * pi;
|
||||
targetPos = LatLng(
|
||||
lat + offsetDeg * cos(angle),
|
||||
lon + offsetDeg * sin(angle),
|
||||
);
|
||||
targetGuessed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
_targetContactPosition = targetPos;
|
||||
_targetContactIsGuessed = targetGuessed;
|
||||
|
||||
_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) {
|
||||
if (contact != null && contact.hasLocation) {
|
||||
_points.add(LatLng(contact.latitude!, contact.longitude!));
|
||||
} else {
|
||||
final inferred = inferredPositions[hop];
|
||||
if (inferred != null) _points.add(inferred);
|
||||
}
|
||||
}
|
||||
if (targetPos != null) _points.add(targetPos);
|
||||
_polylines = _points.length > 1
|
||||
? [
|
||||
Polyline(
|
||||
@@ -382,8 +454,13 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
||||
final markers = <Marker>[];
|
||||
for (final hop in pathData) {
|
||||
final contact = _traceData!.pathContacts[hop];
|
||||
if (contact == null || !contact.hasLocation) continue;
|
||||
final point = LatLng(contact.latitude!, contact.longitude!);
|
||||
final inferred = _inferredHopPositions[hop];
|
||||
final hasGps = contact != null && contact.hasLocation;
|
||||
if (!hasGps && inferred == null) continue;
|
||||
final point = hasGps
|
||||
? LatLng(contact.latitude!, contact.longitude!)
|
||||
: inferred!;
|
||||
final label = hop.toRadixString(16).padLeft(2, '0').toUpperCase();
|
||||
markers.add(
|
||||
Marker(
|
||||
point: point,
|
||||
@@ -392,7 +469,9 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green,
|
||||
color: hasGps
|
||||
? Colors.green
|
||||
: Colors.orange.withValues(alpha: 0.75),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
boxShadow: [
|
||||
@@ -405,10 +484,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
contact.publicKey
|
||||
.sublist(0, 1)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
|
||||
.join(),
|
||||
hasGps ? label : '~$label',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -419,7 +495,12 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
||||
),
|
||||
);
|
||||
if (showLabels) {
|
||||
markers.add(_buildNodeLabelMarker(point: point, label: contact.name));
|
||||
markers.add(
|
||||
_buildNodeLabelMarker(
|
||||
point: point,
|
||||
label: contact?.name ?? '~$label',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,6 +549,47 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
// Add target contact endpoint marker.
|
||||
final targetPos = _targetContactPosition;
|
||||
if (targetPos != null) {
|
||||
final isGuessed = _targetContactIsGuessed;
|
||||
final targetName = widget.targetContact?.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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user