mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Fix retry flow and contact filters
This commit is contained in:
@@ -1293,6 +1293,9 @@ class AppProvider with ChangeNotifier {
|
||||
retryAttempt: retryAttempt,
|
||||
);
|
||||
};
|
||||
connectionProvider.resolveContactForDmCallback = (contactPublicKey) {
|
||||
return contactsProvider.findContactByKey(contactPublicKey);
|
||||
};
|
||||
messagesProvider.onFinalRouterFallbackCallback =
|
||||
({required messageId, required contact, required message}) async {
|
||||
return _sendWithFinalNearestRouterFallback(
|
||||
@@ -1321,6 +1324,9 @@ class AppProvider with ChangeNotifier {
|
||||
roundTripTimeMs: roundTripTimeMs,
|
||||
);
|
||||
};
|
||||
messagesProvider.onManualRetryPreparedCallback = (messageId) {
|
||||
_directMessageRouteSessions.remove(messageId);
|
||||
};
|
||||
}
|
||||
|
||||
Future<Contact> _prepareDirectMessageSend({
|
||||
|
||||
@@ -185,6 +185,7 @@ class ConnectionProvider with ChangeNotifier {
|
||||
onMessageEchoDetected;
|
||||
Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse;
|
||||
Function(Uint8List payload, int snrRaw, int rssiDbm)? onRawDataReceived;
|
||||
Contact? Function(Uint8List contactPublicKey)? resolveContactForDmCallback;
|
||||
|
||||
// Track pending send operations for auto-recovery
|
||||
final Map<String, _PendingSendOperation> _pendingSendOperations = {};
|
||||
@@ -1164,6 +1165,7 @@ class ConnectionProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
var effectiveContact = contact;
|
||||
effectiveContact ??= resolveContactForDmCallback?.call(contactPublicKey);
|
||||
if (messageId != null &&
|
||||
effectiveContact != null &&
|
||||
prepareDirectMessageSendCallback != null) {
|
||||
|
||||
@@ -844,16 +844,30 @@ class ContactsProvider with ChangeNotifier {
|
||||
Uint8List publicKey, {
|
||||
required int signedEncodedPathLen,
|
||||
required Uint8List paddedPathBytes,
|
||||
LatLng? inferredFallbackLocation,
|
||||
}) {
|
||||
final contact = findContactByKey(publicKey);
|
||||
if (contact == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
_contacts[contact.publicKeyHex] = contact.copyWith(
|
||||
var updatedContact = contact.copyWith(
|
||||
outPathLen: signedEncodedPathLen,
|
||||
outPath: Uint8List.fromList(paddedPathBytes),
|
||||
);
|
||||
if (inferredFallbackLocation != null) {
|
||||
updatedContact = updatedContact
|
||||
.copyWith(
|
||||
advLat: _coordinateToAdvertMicrodegrees(
|
||||
inferredFallbackLocation.latitude,
|
||||
),
|
||||
advLon: _coordinateToAdvertMicrodegrees(
|
||||
inferredFallbackLocation.longitude,
|
||||
),
|
||||
)
|
||||
.addAdvertLocation(inferredFallbackLocation, DateTime.now());
|
||||
}
|
||||
_contacts[contact.publicKeyHex] = updatedContact;
|
||||
_persistContacts();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ class MessagesProvider with ChangeNotifier {
|
||||
|
||||
Future<void> Function({required Contact contact, required int failureStreak})?
|
||||
onDirectPathFailedCallback;
|
||||
void Function(String messageId)? onManualRetryPreparedCallback;
|
||||
Future<bool> Function({
|
||||
required String messageId,
|
||||
required Contact contact,
|
||||
@@ -165,8 +166,12 @@ class MessagesProvider with ChangeNotifier {
|
||||
|
||||
final index = _messages.indexWhere((message) => message.id == messageId);
|
||||
if (index != -1) {
|
||||
final nextPathLen = selection.hopCount > 0
|
||||
? selection.hopCount
|
||||
: _messages[index].pathLen;
|
||||
_messages[index] = _messages[index].copyWith(
|
||||
usedFloodFallback: selection.usesFlood,
|
||||
pathLen: nextPathLen,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2069,6 +2074,7 @@ class MessagesProvider with ChangeNotifier {
|
||||
_clearAckHistoryForMessage(messageId);
|
||||
_retryManager.clearRetry(messageId);
|
||||
_messageRouteMetadata.remove(messageId);
|
||||
onManualRetryPreparedCallback?.call(messageId);
|
||||
|
||||
_messages[index] = Message(
|
||||
id: message.id,
|
||||
|
||||
@@ -32,6 +32,7 @@ class ContactsTab extends StatefulWidget {
|
||||
class _ContactsTabState extends State<ContactsTab> {
|
||||
Position? _currentPosition;
|
||||
final Set<String> _resolvingAdvertKeys = <String>{};
|
||||
bool _isResolvingPendingBatch = false;
|
||||
final Map<ContactSection, ContactSortMode> _sortModes = {
|
||||
ContactSection.teamMembers: ContactSortMode.lastSeen,
|
||||
ContactSection.repeaters: ContactSortMode.lastSeen,
|
||||
@@ -95,6 +96,38 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
}
|
||||
}
|
||||
|
||||
void _schedulePendingAdvertResolution(
|
||||
List<PendingAdvert> pendingAdverts,
|
||||
ConnectionProvider connectionProvider,
|
||||
) {
|
||||
if (_isResolvingPendingBatch ||
|
||||
!connectionProvider.deviceInfo.isConnected ||
|
||||
pendingAdverts.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final advertsToResolve = pendingAdverts
|
||||
.where((advert) => !_resolvingAdvertKeys.contains(advert.publicKeyHex))
|
||||
.toList();
|
||||
if (advertsToResolve.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
if (!mounted || _isResolvingPendingBatch) return;
|
||||
|
||||
_isResolvingPendingBatch = true;
|
||||
try {
|
||||
for (final advert in advertsToResolve) {
|
||||
if (!mounted) break;
|
||||
await _handleResolveAdvert(advert);
|
||||
}
|
||||
} finally {
|
||||
_isResolvingPendingBatch = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Calculate distance between two points in meters
|
||||
double _calculateDistanceInMeters(
|
||||
double lat1,
|
||||
@@ -230,6 +263,7 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
body: Consumer<ContactsProvider>(
|
||||
builder: (context, contactsProvider, child) {
|
||||
final messagesProvider = context.watch<MessagesProvider>();
|
||||
final connectionProvider = context.watch<ConnectionProvider>();
|
||||
final chatContacts = _sortContacts(
|
||||
contactsProvider.chatContacts,
|
||||
ContactSection.teamMembers,
|
||||
@@ -248,6 +282,8 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
);
|
||||
final pendingAdverts = contactsProvider.pendingAdverts;
|
||||
|
||||
_schedulePendingAdvertResolution(pendingAdverts, connectionProvider);
|
||||
|
||||
// Check if there are any displayable contacts
|
||||
final hasDisplayableContacts =
|
||||
chatContacts.isNotEmpty ||
|
||||
@@ -287,27 +323,6 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(8),
|
||||
children: [
|
||||
// Pending adverts (public key only; quick resolve)
|
||||
if (pendingAdverts.isNotEmpty) ...[
|
||||
_SectionHeader(
|
||||
title: l10n.pending,
|
||||
count: pendingAdverts.length,
|
||||
icon: Icons.person_add_alt_1,
|
||||
),
|
||||
...pendingAdverts.map(
|
||||
(advert) => _PendingAdvertTile(
|
||||
advert: advert,
|
||||
subtitle:
|
||||
'${l10n.publicKey}: ${advert.publicKeyHex}\n${l10n.lastSeen}: ${_formatRelativeTime(context, advert.receivedAt)}',
|
||||
isResolving: _resolvingAdvertKeys.contains(
|
||||
advert.publicKeyHex,
|
||||
),
|
||||
onResolve: () => _handleResolveAdvert(advert),
|
||||
),
|
||||
),
|
||||
const Divider(height: 32),
|
||||
],
|
||||
|
||||
// Team Members (Chat contacts)
|
||||
if (chatContacts.isNotEmpty) ...[
|
||||
_SectionHeader(
|
||||
@@ -347,6 +362,27 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
const Divider(height: 32),
|
||||
],
|
||||
|
||||
// Pending adverts are kept below resolved sections while we load details.
|
||||
if (pendingAdverts.isNotEmpty) ...[
|
||||
_SectionHeader(
|
||||
title: l10n.pending,
|
||||
count: pendingAdverts.length,
|
||||
icon: Icons.person_search,
|
||||
),
|
||||
...pendingAdverts.map(
|
||||
(advert) => _PendingAdvertTile(
|
||||
advert: advert,
|
||||
subtitle:
|
||||
'${l10n.publicKey}: ${advert.shortDisplayKey}\n${l10n.lastSeen}: ${_formatRelativeTime(context, advert.receivedAt)}',
|
||||
isResolving: _resolvingAdvertKeys.contains(
|
||||
advert.publicKeyHex,
|
||||
),
|
||||
onResolve: () => _handleResolveAdvert(advert),
|
||||
),
|
||||
),
|
||||
const Divider(height: 32),
|
||||
],
|
||||
|
||||
// Channels (visible in both simple and advanced mode)
|
||||
_SectionHeader(
|
||||
title: l10n.channels,
|
||||
@@ -513,7 +549,7 @@ class _PendingAdvertTile extends StatelessWidget {
|
||||
)
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.person_add_alt_1),
|
||||
tooltip: 'Quick add',
|
||||
tooltip: 'Resolve contact',
|
||||
onPressed: onResolve,
|
||||
),
|
||||
),
|
||||
|
||||
336
lib/services/contact_route_resolver.dart
Normal file
336
lib/services/contact_route_resolver.dart
Normal file
@@ -0,0 +1,336 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../models/contact.dart';
|
||||
|
||||
class ResolvedContactRoutePlan {
|
||||
final List<String> tokens;
|
||||
final List<Contact> selectedContacts;
|
||||
final String summary;
|
||||
|
||||
const ResolvedContactRoutePlan({
|
||||
required this.tokens,
|
||||
required this.selectedContacts,
|
||||
required this.summary,
|
||||
});
|
||||
|
||||
String get canonicalText => tokens.join(',');
|
||||
}
|
||||
|
||||
class ContactRouteResolver {
|
||||
static const Distance _distance = Distance();
|
||||
|
||||
const ContactRouteResolver._();
|
||||
|
||||
static ResolvedContactRoutePlan? resolveAutomaticRoute({
|
||||
required LatLng senderLocation,
|
||||
required Contact recipient,
|
||||
required List<Contact> availableContacts,
|
||||
required int hashSize,
|
||||
}) {
|
||||
final recipientLocation = recipient.displayLocation;
|
||||
if (recipientLocation == null) return null;
|
||||
|
||||
final repeaters = availableContacts
|
||||
.where(
|
||||
(contact) =>
|
||||
contact.isRepeater &&
|
||||
contact.displayLocation != null &&
|
||||
contact.publicKeyHex != recipient.publicKeyHex,
|
||||
)
|
||||
.toList();
|
||||
if (repeaters.isEmpty) return null;
|
||||
|
||||
final recipientLatLng = LatLng(
|
||||
recipientLocation.latitude,
|
||||
recipientLocation.longitude,
|
||||
);
|
||||
|
||||
final routedCandidates =
|
||||
repeaters
|
||||
.where(
|
||||
(contact) =>
|
||||
contact.routeHasPath && contact.routeHashSize == hashSize,
|
||||
)
|
||||
.toList()
|
||||
..sort(
|
||||
(a, b) =>
|
||||
_scoreKnownRouteRepeater(
|
||||
senderLocation: senderLocation,
|
||||
recipientLocation: recipientLatLng,
|
||||
repeater: a,
|
||||
availableContacts: repeaters,
|
||||
hashSize: hashSize,
|
||||
).compareTo(
|
||||
_scoreKnownRouteRepeater(
|
||||
senderLocation: senderLocation,
|
||||
recipientLocation: recipientLatLng,
|
||||
repeater: b,
|
||||
availableContacts: repeaters,
|
||||
hashSize: hashSize,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (routedCandidates.isNotEmpty) {
|
||||
final anchor = routedCandidates.first;
|
||||
final tokens = <String>[
|
||||
...anchor.routeCanonicalText
|
||||
.split(',')
|
||||
.where((token) => token.isNotEmpty)
|
||||
.map((token) => token.toUpperCase()),
|
||||
_tokenFor(anchor, hashSize),
|
||||
];
|
||||
final selectedContacts = _matchContactsForTokens(
|
||||
tokens,
|
||||
availableContacts: repeaters,
|
||||
);
|
||||
return ResolvedContactRoutePlan(
|
||||
tokens: _dedupeTokens(tokens),
|
||||
selectedContacts: selectedContacts,
|
||||
summary: 'Resolved via known repeater route',
|
||||
);
|
||||
}
|
||||
|
||||
final corridorRepeaters = List<Contact>.from(repeaters)
|
||||
..sort((a, b) {
|
||||
final progressA = _progressAlongSegment(
|
||||
point: LatLng(
|
||||
a.displayLocation!.latitude,
|
||||
a.displayLocation!.longitude,
|
||||
),
|
||||
start: senderLocation,
|
||||
end: recipientLatLng,
|
||||
);
|
||||
final progressB = _progressAlongSegment(
|
||||
point: LatLng(
|
||||
b.displayLocation!.latitude,
|
||||
b.displayLocation!.longitude,
|
||||
),
|
||||
start: senderLocation,
|
||||
end: recipientLatLng,
|
||||
);
|
||||
final progressCompare = progressA.compareTo(progressB);
|
||||
if (progressCompare != 0) return progressCompare;
|
||||
return _scoreRepeater(
|
||||
senderLocation: senderLocation,
|
||||
recipientLocation: recipientLatLng,
|
||||
repeater: a,
|
||||
).compareTo(
|
||||
_scoreRepeater(
|
||||
senderLocation: senderLocation,
|
||||
recipientLocation: recipientLatLng,
|
||||
repeater: b,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
final selected = <Contact>[];
|
||||
var currentPoint = senderLocation;
|
||||
var currentDistanceToRecipient = _distance.as(
|
||||
LengthUnit.Meter,
|
||||
senderLocation,
|
||||
recipientLatLng,
|
||||
);
|
||||
final maxCorridorDistance = math.max(
|
||||
1500.0,
|
||||
currentDistanceToRecipient * 0.22,
|
||||
);
|
||||
|
||||
for (final repeater in corridorRepeaters) {
|
||||
if (selected.length >= 4) break;
|
||||
final repeaterPoint = LatLng(
|
||||
repeater.displayLocation!.latitude,
|
||||
repeater.displayLocation!.longitude,
|
||||
);
|
||||
final distanceToSegment = _distanceToSegmentMeters(
|
||||
repeaterPoint,
|
||||
senderLocation,
|
||||
recipientLatLng,
|
||||
);
|
||||
if (distanceToSegment > maxCorridorDistance) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final nextDistanceToRecipient = _distance.as(
|
||||
LengthUnit.Meter,
|
||||
repeaterPoint,
|
||||
recipientLatLng,
|
||||
);
|
||||
if (nextDistanceToRecipient >= currentDistanceToRecipient - 300) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final distanceFromCurrent = _distance.as(
|
||||
LengthUnit.Meter,
|
||||
currentPoint,
|
||||
repeaterPoint,
|
||||
);
|
||||
if (distanceFromCurrent < 100) {
|
||||
continue;
|
||||
}
|
||||
|
||||
selected.add(repeater);
|
||||
currentPoint = repeaterPoint;
|
||||
currentDistanceToRecipient = nextDistanceToRecipient;
|
||||
|
||||
if (currentDistanceToRecipient < 2500) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (selected.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ResolvedContactRoutePlan(
|
||||
tokens: selected.map((contact) => _tokenFor(contact, hashSize)).toList(),
|
||||
selectedContacts: selected,
|
||||
summary: 'Resolved from repeater locations',
|
||||
);
|
||||
}
|
||||
|
||||
static List<Contact> _matchContactsForTokens(
|
||||
List<String> tokens, {
|
||||
required List<Contact> availableContacts,
|
||||
}) {
|
||||
final matches = <Contact>[];
|
||||
final seen = <String>{};
|
||||
for (final token in tokens) {
|
||||
final match = availableContacts
|
||||
.where(
|
||||
(contact) => contact.publicKeyHex.toUpperCase().startsWith(token),
|
||||
)
|
||||
.firstOrNull;
|
||||
if (match != null && seen.add(match.publicKeyHex)) {
|
||||
matches.add(match);
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
static List<String> _dedupeTokens(List<String> tokens) {
|
||||
final result = <String>[];
|
||||
for (final token in tokens) {
|
||||
if (result.isEmpty || result.last != token) {
|
||||
result.add(token);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static String _tokenFor(Contact contact, int hashSize) {
|
||||
final hex = contact.publicKeyHex.toUpperCase();
|
||||
final length = hashSize * 2;
|
||||
return hex.length < length ? hex : hex.substring(0, length);
|
||||
}
|
||||
|
||||
static double _scoreRepeater({
|
||||
required LatLng senderLocation,
|
||||
required LatLng recipientLocation,
|
||||
required Contact repeater,
|
||||
}) {
|
||||
final point = LatLng(
|
||||
repeater.displayLocation!.latitude,
|
||||
repeater.displayLocation!.longitude,
|
||||
);
|
||||
final toRecipient = _distance.as(
|
||||
LengthUnit.Meter,
|
||||
point,
|
||||
recipientLocation,
|
||||
);
|
||||
final corridor = _distanceToSegmentMeters(
|
||||
point,
|
||||
senderLocation,
|
||||
recipientLocation,
|
||||
);
|
||||
return (toRecipient * 0.75) + (corridor * 0.25);
|
||||
}
|
||||
|
||||
static double _scoreKnownRouteRepeater({
|
||||
required LatLng senderLocation,
|
||||
required LatLng recipientLocation,
|
||||
required Contact repeater,
|
||||
required List<Contact> availableContacts,
|
||||
required int hashSize,
|
||||
}) {
|
||||
final baseScore = _scoreRepeater(
|
||||
senderLocation: senderLocation,
|
||||
recipientLocation: recipientLocation,
|
||||
repeater: repeater,
|
||||
);
|
||||
final repeaterPoint = LatLng(
|
||||
repeater.displayLocation!.latitude,
|
||||
repeater.displayLocation!.longitude,
|
||||
);
|
||||
|
||||
final chainContacts = repeater.routeCanonicalText
|
||||
.split(',')
|
||||
.where((token) => token.isNotEmpty)
|
||||
.map(
|
||||
(token) => availableContacts
|
||||
.where(
|
||||
(contact) =>
|
||||
contact.displayLocation != null &&
|
||||
contact.publicKeyHex.toUpperCase().startsWith(
|
||||
token.toUpperCase(),
|
||||
),
|
||||
)
|
||||
.firstOrNull,
|
||||
)
|
||||
.whereType<Contact>()
|
||||
.toList();
|
||||
|
||||
final chainEnd = chainContacts.isNotEmpty
|
||||
? LatLng(
|
||||
chainContacts.last.displayLocation!.latitude,
|
||||
chainContacts.last.displayLocation!.longitude,
|
||||
)
|
||||
: senderLocation;
|
||||
final chainGap = _distance.as(LengthUnit.Meter, chainEnd, repeaterPoint);
|
||||
final senderGap = _distance.as(
|
||||
LengthUnit.Meter,
|
||||
senderLocation,
|
||||
repeaterPoint,
|
||||
);
|
||||
|
||||
return (chainGap * 0.55) + (baseScore * 0.35) + (senderGap * 0.10);
|
||||
}
|
||||
|
||||
static double _progressAlongSegment({
|
||||
required LatLng point,
|
||||
required LatLng start,
|
||||
required LatLng end,
|
||||
}) {
|
||||
final dx = end.longitude - start.longitude;
|
||||
final dy = end.latitude - start.latitude;
|
||||
final lengthSquared = (dx * dx) + (dy * dy);
|
||||
if (lengthSquared == 0) return 0;
|
||||
return (((point.longitude - start.longitude) * dx) +
|
||||
((point.latitude - start.latitude) * dy)) /
|
||||
lengthSquared;
|
||||
}
|
||||
|
||||
static double _distanceToSegmentMeters(LatLng p, LatLng a, LatLng b) {
|
||||
final ax = a.longitude;
|
||||
final ay = a.latitude;
|
||||
final bx = b.longitude;
|
||||
final by = b.latitude;
|
||||
final px = p.longitude;
|
||||
final py = p.latitude;
|
||||
|
||||
final abx = bx - ax;
|
||||
final aby = by - ay;
|
||||
final apx = px - ax;
|
||||
final apy = py - ay;
|
||||
final ab2 = abx * abx + aby * aby;
|
||||
if (ab2 == 0) {
|
||||
return _distance.as(LengthUnit.Meter, a, p);
|
||||
}
|
||||
var t = (apx * abx + apy * aby) / ab2;
|
||||
t = t.clamp(0.0, 1.0);
|
||||
final closest = LatLng(ay + aby * t, ax + abx * t);
|
||||
return _distance.as(LengthUnit.Meter, closest, p);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,38 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' as flutter_map;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import '../../models/contact.dart';
|
||||
import '../../providers/connection_provider.dart';
|
||||
import '../../providers/app_provider.dart';
|
||||
import '../../services/contact_route_resolver.dart';
|
||||
import '../../services/route_hash_preferences.dart';
|
||||
|
||||
class ContactRouteDialogResult {
|
||||
final ParsedContactRoute? route;
|
||||
final bool shouldClear;
|
||||
final LatLng? inferredFallbackLocation;
|
||||
|
||||
const ContactRouteDialogResult._({this.route, required this.shouldClear});
|
||||
const ContactRouteDialogResult._({
|
||||
this.route,
|
||||
required this.shouldClear,
|
||||
this.inferredFallbackLocation,
|
||||
});
|
||||
|
||||
const ContactRouteDialogResult.set(ParsedContactRoute route)
|
||||
: this._(route: route, shouldClear: false);
|
||||
|
||||
const ContactRouteDialogResult.setWithFallback(
|
||||
ParsedContactRoute route, {
|
||||
LatLng? inferredFallbackLocation,
|
||||
}) : this._(
|
||||
route: route,
|
||||
shouldClear: false,
|
||||
inferredFallbackLocation: inferredFallbackLocation,
|
||||
);
|
||||
|
||||
const ContactRouteDialogResult.clear() : this._(shouldClear: true);
|
||||
}
|
||||
|
||||
@@ -60,6 +79,7 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
||||
ParsedContactRoute? _parsedRoute;
|
||||
String? _errorText;
|
||||
bool _showRoutingInfo = false;
|
||||
List<Contact> _selectedMapHops = const [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -107,6 +127,36 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
||||
}
|
||||
}
|
||||
|
||||
List<Contact> get _routeCandidates =>
|
||||
widget.availableContacts
|
||||
.where(
|
||||
(contact) => contact.isRepeater && contact.displayLocation != null,
|
||||
)
|
||||
.toList()
|
||||
..sort((a, b) => a.displayName.compareTo(b.displayName));
|
||||
|
||||
void _syncMapSelectionFromController() {
|
||||
final tokens = _controller.text
|
||||
.trim()
|
||||
.split(',')
|
||||
.map((token) => token.trim().toUpperCase())
|
||||
.where((token) => token.isNotEmpty)
|
||||
.toList();
|
||||
final selected = <Contact>[];
|
||||
final seen = <String>{};
|
||||
for (final token in tokens) {
|
||||
final match = _routeCandidates
|
||||
.where(
|
||||
(contact) => contact.publicKeyHex.toUpperCase().startsWith(token),
|
||||
)
|
||||
.firstOrNull;
|
||||
if (match != null && seen.add(match.publicKeyHex)) {
|
||||
selected.add(match);
|
||||
}
|
||||
}
|
||||
_selectedMapHops = selected;
|
||||
}
|
||||
|
||||
Future<void> _loadHashSizePreference() async {
|
||||
final hashSize = await RouteHashPreferences.getHashSize();
|
||||
if (!mounted) return;
|
||||
@@ -114,6 +164,7 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
||||
_selectedHashSize = hashSize;
|
||||
});
|
||||
_reparse();
|
||||
_syncMapSelectionFromController();
|
||||
}
|
||||
|
||||
String _tokenFor(Contact contact, int hashSize) {
|
||||
@@ -125,23 +176,168 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
||||
return hex.substring(0, length);
|
||||
}
|
||||
|
||||
void _appendHop(Contact contact) {
|
||||
final token = _tokenFor(contact, _selectedHashSize);
|
||||
final current = _controller.text.trim();
|
||||
_controller.text = current.isEmpty ? token : '$current,$token';
|
||||
void _syncControllerFromSelectedHops() {
|
||||
final tokens = _selectedMapHops
|
||||
.map((contact) => _tokenFor(contact, _selectedHashSize))
|
||||
.toList();
|
||||
_controller.text = tokens.join(',');
|
||||
_controller.selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: _controller.text.length),
|
||||
);
|
||||
}
|
||||
|
||||
void _toggleHop(Contact contact) {
|
||||
setState(() {
|
||||
if (_selectedMapHops.any(
|
||||
(item) => item.publicKeyHex == contact.publicKeyHex,
|
||||
)) {
|
||||
_selectedMapHops = _selectedMapHops
|
||||
.where((item) => item.publicKeyHex != contact.publicKeyHex)
|
||||
.toList();
|
||||
} else {
|
||||
_selectedMapHops = [..._selectedMapHops, contact];
|
||||
}
|
||||
_syncControllerFromSelectedHops();
|
||||
_reparse();
|
||||
});
|
||||
}
|
||||
|
||||
void _applyResolvedPlan(ResolvedContactRoutePlan plan) {
|
||||
setState(() {
|
||||
_selectedMapHops = plan.selectedContacts;
|
||||
_controller.text = plan.canonicalText;
|
||||
_controller.selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: _controller.text.length),
|
||||
);
|
||||
_errorText = null;
|
||||
});
|
||||
_reparse();
|
||||
}
|
||||
|
||||
LatLng? _resolveLastHopLocation() {
|
||||
if (_selectedMapHops.isNotEmpty) {
|
||||
return _selectedMapHops.last.displayLocation == null
|
||||
? null
|
||||
: LatLng(
|
||||
_selectedMapHops.last.displayLocation!.latitude,
|
||||
_selectedMapHops.last.displayLocation!.longitude,
|
||||
);
|
||||
}
|
||||
|
||||
final tokens = _controller.text
|
||||
.trim()
|
||||
.split(',')
|
||||
.map((token) => token.trim().toUpperCase())
|
||||
.where((token) => token.isNotEmpty)
|
||||
.toList();
|
||||
if (tokens.isEmpty) return null;
|
||||
final lastToken = tokens.last;
|
||||
final match = _routeCandidates
|
||||
.where(
|
||||
(contact) => contact.publicKeyHex.toUpperCase().startsWith(lastToken),
|
||||
)
|
||||
.firstOrNull;
|
||||
final location = match?.displayLocation;
|
||||
if (location == null) return null;
|
||||
return LatLng(location.latitude, location.longitude);
|
||||
}
|
||||
|
||||
LatLng? _buildSyntheticFallbackLocation() {
|
||||
final lastHopLocation = _resolveLastHopLocation();
|
||||
if (lastHopLocation == null) return null;
|
||||
|
||||
final seed = widget.contact.publicKey.fold<int>(
|
||||
_controller.text.codeUnits.fold<int>(0, (sum, unit) => sum + unit),
|
||||
(sum, byte) => sum + byte,
|
||||
);
|
||||
final angle = (seed % 360) * (math.pi / 180.0);
|
||||
const radiusMeters = 500.0;
|
||||
final latOffset = (radiusMeters / 111320.0) * math.cos(angle);
|
||||
final lonDenominator =
|
||||
111320.0 * math.cos(lastHopLocation.latitude * (math.pi / 180.0));
|
||||
final lonOffset = lonDenominator.abs() < 1e-6
|
||||
? 0.0
|
||||
: (radiusMeters / lonDenominator) * math.sin(angle);
|
||||
return LatLng(
|
||||
lastHopLocation.latitude + latOffset,
|
||||
lastHopLocation.longitude + lonOffset,
|
||||
);
|
||||
}
|
||||
|
||||
void _resolvePathAutomatically() {
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
final advLat = connectionProvider.deviceInfo.advLat;
|
||||
final advLon = connectionProvider.deviceInfo.advLon;
|
||||
final recipientLocation = widget.contact.displayLocation;
|
||||
if (advLat == null ||
|
||||
advLon == null ||
|
||||
(advLat == 0 && advLon == 0) ||
|
||||
recipientLocation == null) {
|
||||
setState(() {
|
||||
_errorText =
|
||||
'Automatic resolve needs both your advertised location and the contact location.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final plan = ContactRouteResolver.resolveAutomaticRoute(
|
||||
senderLocation: LatLng(advLat / 1e6, advLon / 1e6),
|
||||
recipient: widget.contact,
|
||||
availableContacts: widget.availableContacts,
|
||||
hashSize: _selectedHashSize,
|
||||
);
|
||||
if (plan == null) {
|
||||
setState(() {
|
||||
_errorText =
|
||||
'Could not resolve a route from available repeater locations.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
_applyResolvedPlan(plan);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appProvider = context.watch<AppProvider>();
|
||||
final routeCandidates =
|
||||
widget.availableContacts
|
||||
.where((contact) => contact.isRepeater || contact.isRoom)
|
||||
.toList()
|
||||
..sort((a, b) => a.displayName.compareTo(b.displayName));
|
||||
final routeCandidates = _routeCandidates;
|
||||
final connectionProvider = context.watch<ConnectionProvider>();
|
||||
final selfPoint =
|
||||
connectionProvider.deviceInfo.advLat != null &&
|
||||
connectionProvider.deviceInfo.advLon != null &&
|
||||
!(connectionProvider.deviceInfo.advLat == 0 &&
|
||||
connectionProvider.deviceInfo.advLon == 0)
|
||||
? LatLng(
|
||||
connectionProvider.deviceInfo.advLat! / 1e6,
|
||||
connectionProvider.deviceInfo.advLon! / 1e6,
|
||||
)
|
||||
: null;
|
||||
final recipientLocation = widget.contact.displayLocation;
|
||||
final recipientPoint = recipientLocation == null
|
||||
? null
|
||||
: LatLng(recipientLocation.latitude, recipientLocation.longitude);
|
||||
final routePoints = <LatLng>[
|
||||
...?selfPoint == null ? null : [selfPoint],
|
||||
..._selectedMapHops
|
||||
.where((contact) => contact.displayLocation != null)
|
||||
.map(
|
||||
(contact) => LatLng(
|
||||
contact.displayLocation!.latitude,
|
||||
contact.displayLocation!.longitude,
|
||||
),
|
||||
),
|
||||
...?recipientPoint == null ? null : [recipientPoint],
|
||||
];
|
||||
final mapPoints = <LatLng>[
|
||||
...?selfPoint == null ? null : [selfPoint],
|
||||
...?recipientPoint == null ? null : [recipientPoint],
|
||||
...routeCandidates.map(
|
||||
(contact) => LatLng(
|
||||
contact.displayLocation!.latitude,
|
||||
contact.displayLocation!.longitude,
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
return FractionallySizedBox(
|
||||
heightFactor: 0.85,
|
||||
@@ -155,83 +351,193 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _controller,
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Route',
|
||||
hintText: _selectedHashSize == 1
|
||||
? 'AA,BB,CC'
|
||||
: _selectedHashSize == 2
|
||||
? 'AABB,CCDD'
|
||||
: 'AABBCC,DDEEFF',
|
||||
helperText:
|
||||
'Use comma-separated hops. Path byte size comes from global Settings. Colon form like AA:BB is also accepted.',
|
||||
errorText: _errorText,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_parsedRoute == null
|
||||
? 'Preview: enter a route to validate it.'
|
||||
: 'Preview: ${_parsedRoute!.summary} • ${_parsedRoute!.byteLength} bytes • descriptor 0x${_parsedRoute!.encodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
if (_parsedRoute != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
SelectableText(
|
||||
_parsedRoute!.canonicalText,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
_AutomationRoutingInfo(
|
||||
isExpanded: _showRoutingInfo,
|
||||
onToggle: () {
|
||||
setState(() {
|
||||
_showRoutingInfo = !_showRoutingInfo;
|
||||
});
|
||||
},
|
||||
autoRouteRotationEnabled: appProvider.autoRouteRotationEnabled,
|
||||
nearestRelayFallbackEnabled:
|
||||
appProvider.nearestRelayFallbackEnabled,
|
||||
clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Pick hops from contacts',
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (routeCandidates.isEmpty)
|
||||
const Text(
|
||||
'No repeater or room contacts are available for route building.',
|
||||
)
|
||||
else
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: routeCandidates.length,
|
||||
itemBuilder: (context, index) {
|
||||
final candidate = routeCandidates[index];
|
||||
return ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(candidate.displayName),
|
||||
trailing: TextButton(
|
||||
onPressed: () => _appendHop(candidate),
|
||||
child: Text(
|
||||
'Use ${_tokenFor(candidate, _selectedHashSize)}',
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _controller,
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Route',
|
||||
hintText: _selectedHashSize == 1
|
||||
? 'AA,BB,CC'
|
||||
: _selectedHashSize == 2
|
||||
? 'AABB,CCDD'
|
||||
: 'AABBCC,DDEEFF',
|
||||
helperText:
|
||||
'Use comma-separated hops. Path byte size comes from global Settings. Colon form like AA:BB is also accepted.',
|
||||
errorText: _errorText,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_parsedRoute == null
|
||||
? 'Preview: enter a route to validate it.'
|
||||
: 'Preview: ${_parsedRoute!.summary} • ${_parsedRoute!.byteLength} bytes • descriptor 0x${_parsedRoute!.encodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
if (_parsedRoute != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
SelectableText(
|
||||
_parsedRoute!.canonicalText,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: _resolvePathAutomatically,
|
||||
icon: const Icon(Icons.auto_fix_high),
|
||||
label: const Text('Resolve Path'),
|
||||
),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 260),
|
||||
child: Text(
|
||||
'Tap repeaters on the map to build the path.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
height: 260,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor,
|
||||
),
|
||||
),
|
||||
child: mapPoints.length < 2
|
||||
? const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'Map path builder needs your advertised location, the contact location, and visible repeater locations.',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
)
|
||||
: flutter_map.FlutterMap(
|
||||
options: flutter_map.MapOptions(
|
||||
initialCameraFit:
|
||||
flutter_map.CameraFit.bounds(
|
||||
bounds:
|
||||
flutter_map
|
||||
.LatLngBounds.fromPoints(
|
||||
mapPoints,
|
||||
),
|
||||
padding: const EdgeInsets.all(32),
|
||||
),
|
||||
),
|
||||
children: [
|
||||
flutter_map.TileLayer(
|
||||
urlTemplate:
|
||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: 'com.meshcore.sar',
|
||||
),
|
||||
if (routePoints.length >= 2)
|
||||
flutter_map.PolylineLayer(
|
||||
polylines: [
|
||||
flutter_map.Polyline(
|
||||
points: routePoints,
|
||||
strokeWidth: 4,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
flutter_map.MarkerLayer(
|
||||
markers: [
|
||||
...routeCandidates.map((candidate) {
|
||||
final isSelected = _selectedMapHops
|
||||
.any(
|
||||
(item) =>
|
||||
item.publicKeyHex ==
|
||||
candidate.publicKeyHex,
|
||||
);
|
||||
return flutter_map.Marker(
|
||||
point: LatLng(
|
||||
candidate
|
||||
.displayLocation!
|
||||
.latitude,
|
||||
candidate
|
||||
.displayLocation!
|
||||
.longitude,
|
||||
),
|
||||
width: 64,
|
||||
height: 70,
|
||||
child: GestureDetector(
|
||||
onTap: () =>
|
||||
_toggleHop(candidate),
|
||||
child: _RouteMarkerDot(
|
||||
label: _tokenFor(
|
||||
candidate,
|
||||
_selectedHashSize,
|
||||
),
|
||||
color: isSelected
|
||||
? Theme.of(
|
||||
context,
|
||||
).colorScheme.primary
|
||||
: Colors.blueGrey,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_selectedMapHops.isNotEmpty)
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: _selectedMapHops.map((contact) {
|
||||
return InputChip(
|
||||
label: Text(contact.displayName),
|
||||
onDeleted: () => _toggleHop(contact),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_AutomationRoutingInfo(
|
||||
isExpanded: _showRoutingInfo,
|
||||
onToggle: () {
|
||||
setState(() {
|
||||
_showRoutingInfo = !_showRoutingInfo;
|
||||
});
|
||||
},
|
||||
autoRouteRotationEnabled:
|
||||
appProvider.autoRouteRotationEnabled,
|
||||
nearestRelayFallbackEnabled:
|
||||
appProvider.nearestRelayFallbackEnabled,
|
||||
clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
),
|
||||
OverflowBar(
|
||||
alignment: MainAxisAlignment.spaceBetween,
|
||||
spacing: 8,
|
||||
overflowSpacing: 8,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
@@ -244,13 +550,16 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
||||
).pop(const ContactRouteDialogResult.clear()),
|
||||
child: const Text('Clear Route'),
|
||||
),
|
||||
const Spacer(),
|
||||
FilledButton(
|
||||
onPressed: _parsedRoute == null
|
||||
? null
|
||||
: () => Navigator.of(
|
||||
context,
|
||||
).pop(ContactRouteDialogResult.set(_parsedRoute!)),
|
||||
: () => Navigator.of(context).pop(
|
||||
ContactRouteDialogResult.setWithFallback(
|
||||
_parsedRoute!,
|
||||
inferredFallbackLocation:
|
||||
_buildSyntheticFallbackLocation(),
|
||||
),
|
||||
),
|
||||
child: const Text('Set Route'),
|
||||
),
|
||||
],
|
||||
@@ -262,6 +571,31 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
||||
}
|
||||
}
|
||||
|
||||
class _RouteMarkerDot extends StatelessWidget {
|
||||
final String label;
|
||||
final Color color;
|
||||
|
||||
const _RouteMarkerDot({required this.label, required this.color});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: label,
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 26,
|
||||
height: 26,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AutomationRoutingInfo extends StatelessWidget {
|
||||
final bool isExpanded;
|
||||
final VoidCallback onToggle;
|
||||
|
||||
@@ -128,6 +128,24 @@ class ContactTile extends StatelessWidget {
|
||||
final Widget subtitleWidget = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
_buildMetaPill(
|
||||
context,
|
||||
icon: _contactTypeIcon(contact),
|
||||
label: contact.type.displayName,
|
||||
),
|
||||
_buildMetaPill(
|
||||
context,
|
||||
icon: Icons.key_outlined,
|
||||
label: contact.publicKeyShort,
|
||||
monospace: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (location != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
_buildLocationLine(
|
||||
@@ -591,6 +609,7 @@ class ContactTile extends StatelessWidget {
|
||||
contact.publicKey,
|
||||
signedEncodedPathLen: parsedRoute.signedEncodedPathLen,
|
||||
paddedPathBytes: parsedRoute.paddedPathBytes,
|
||||
inferredFallbackLocation: routeResult.inferredFallbackLocation,
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -671,6 +690,53 @@ class ContactTile extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMetaPill(
|
||||
BuildContext context, {
|
||||
required IconData icon,
|
||||
required String label,
|
||||
bool monospace = false,
|
||||
}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.75),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 12, color: colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamily: monospace ? 'monospace' : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IconData _contactTypeIcon(Contact contact) {
|
||||
switch (contact.type) {
|
||||
case ContactType.chat:
|
||||
return Icons.person_outline;
|
||||
case ContactType.repeater:
|
||||
return Icons.router_outlined;
|
||||
case ContactType.room:
|
||||
return Icons.meeting_room_outlined;
|
||||
case ContactType.channel:
|
||||
return Icons.campaign_outlined;
|
||||
case ContactType.none:
|
||||
return Icons.help_outline;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildLocationLine(
|
||||
BuildContext context, {
|
||||
required double latitude,
|
||||
|
||||
@@ -1788,6 +1788,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
receptionDetails?.rssiDbm ??
|
||||
matchedRxLog?.logRxDataInfo?.rssiDbm ??
|
||||
message.lastEchoRssiDbm;
|
||||
final routeMetadata = messagesProvider.getMessageRouteMetadata(message.id);
|
||||
|
||||
// Look up contact information for rich display name
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
@@ -2515,7 +2516,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
switch (recipient.deliveryStatus) {
|
||||
case MessageDeliveryStatus.delivered:
|
||||
statusColor = Colors.green;
|
||||
statusIcon = Icons.check_circle;
|
||||
statusIcon = Icons.done_all;
|
||||
statusText =
|
||||
recipient.roundTripTimeMs != null
|
||||
? '${recipient.roundTripTimeMs}ms'
|
||||
@@ -2523,6 +2524,15 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
context,
|
||||
)!.delivered;
|
||||
break;
|
||||
case MessageDeliveryStatus.sent:
|
||||
statusColor = Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant;
|
||||
statusIcon = Icons.done;
|
||||
statusText = AppLocalizations.of(
|
||||
context,
|
||||
)!.sent;
|
||||
break;
|
||||
case MessageDeliveryStatus.failed:
|
||||
statusColor = Colors.red;
|
||||
statusIcon = Icons.cancel;
|
||||
@@ -2531,7 +2541,6 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
)!.failed;
|
||||
break;
|
||||
case MessageDeliveryStatus.sending:
|
||||
case MessageDeliveryStatus.sent:
|
||||
default:
|
||||
statusColor = Colors.orange;
|
||||
statusIcon = Icons.schedule;
|
||||
@@ -2726,6 +2735,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
context,
|
||||
message: message,
|
||||
isSarMarker: isSarMarker,
|
||||
routeMetadata: routeMetadata,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/contact.dart';
|
||||
import '../../models/message.dart';
|
||||
import '../../models/message_route_metadata.dart';
|
||||
import '../../utils/avatar_label_helper.dart';
|
||||
import '../../utils/message_extensions.dart';
|
||||
import '../common/contact_avatar.dart';
|
||||
@@ -59,6 +60,7 @@ Widget buildBubbleMetaFooter(
|
||||
BuildContext context, {
|
||||
required Message message,
|
||||
required bool isSarMarker,
|
||||
MessageRouteMetadata? routeMetadata,
|
||||
}) {
|
||||
final metaColor = Theme.of(
|
||||
context,
|
||||
@@ -86,12 +88,13 @@ Widget buildBubbleMetaFooter(
|
||||
).textTheme.labelSmall?.copyWith(color: metaColor),
|
||||
),
|
||||
]);
|
||||
} else if (!isSarMarker && message.pathLen < 255) {
|
||||
} else if (!isSarMarker && _effectivePathLen(message, routeMetadata) < 255) {
|
||||
final effectivePathLen = _effectivePathLen(message, routeMetadata);
|
||||
items.addAll([
|
||||
Icon(Icons.alt_route, size: 11, color: metaColor),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
message.pathLen == 0 ? 'direct' : '${message.pathLen}hop',
|
||||
effectivePathLen == 0 ? 'direct' : '${effectivePathLen}hop',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: metaColor),
|
||||
@@ -124,6 +127,9 @@ Widget buildBubbleMetaFooter(
|
||||
);
|
||||
}
|
||||
|
||||
int _effectivePathLen(Message message, MessageRouteMetadata? routeMetadata) =>
|
||||
routeMetadata?.hopCount ?? message.pathLen;
|
||||
|
||||
Widget buildChannelHeaderPill(
|
||||
BuildContext context, {
|
||||
required String label,
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../models/message.dart';
|
||||
import '../../models/message_route_metadata.dart';
|
||||
import '../../models/path_selection.dart';
|
||||
import '../../models/message_reception_details.dart';
|
||||
import '../../providers/messages_provider.dart';
|
||||
@@ -209,7 +210,7 @@ Widget buildSentDirectSignalStatus(
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.alt_route,
|
||||
label: hopDisplayLabel(message),
|
||||
label: hopDisplayLabelForMessage(message, routeMetadata),
|
||||
color: Colors.indigo,
|
||||
),
|
||||
_techChip(
|
||||
@@ -294,6 +295,17 @@ String hopDisplayLabel(Message message) {
|
||||
return '${message.pathLen} hop${message.pathLen == 1 ? '' : 's'}';
|
||||
}
|
||||
|
||||
String hopDisplayLabelForMessage(
|
||||
Message message,
|
||||
MessageRouteMetadata? routeMetadata,
|
||||
) {
|
||||
final effectivePathLen = routeMetadata?.hopCount ?? message.pathLen;
|
||||
if (effectivePathLen == 0) return 'Direct';
|
||||
if (effectivePathLen >= 255 && message.isContactMessage) return 'Direct';
|
||||
if (effectivePathLen >= 255) return 'Unknown';
|
||||
return '$effectivePathLen hop${effectivePathLen == 1 ? '' : 's'}';
|
||||
}
|
||||
|
||||
Widget _techChip(
|
||||
BuildContext context, {
|
||||
required IconData icon,
|
||||
|
||||
@@ -458,6 +458,24 @@ void main() {
|
||||
expect(updated.routeCanonicalText, 'AABB,CCDD');
|
||||
});
|
||||
|
||||
test('stores inferred fallback gps when route is set locally', () {
|
||||
final route = ContactRouteCodec.parse('AABB,CCDD');
|
||||
const fallback = LatLng(46.1001, 14.5002);
|
||||
|
||||
provider.setContactRouteLocal(
|
||||
publicKey,
|
||||
signedEncodedPathLen: route.signedEncodedPathLen,
|
||||
paddedPathBytes: route.paddedPathBytes,
|
||||
inferredFallbackLocation: fallback,
|
||||
);
|
||||
|
||||
final updated = provider.findContactByKey(publicKey)!;
|
||||
expect(updated.displayLocation, isNotNull);
|
||||
expect(updated.displayLocation!.latitude, closeTo(46.1001, 0.000001));
|
||||
expect(updated.displayLocation!.longitude, closeTo(14.5002, 0.000001));
|
||||
expect(updated.advertHistory, isNotEmpty);
|
||||
});
|
||||
|
||||
test('resetContactRouteLocal clears route state', () {
|
||||
final route = ContactRouteCodec.parse('AA,BB,CC');
|
||||
provider.setContactRouteLocal(
|
||||
@@ -576,46 +594,49 @@ void main() {
|
||||
expect(distanceMeters, closeTo(100.0, 8.0));
|
||||
});
|
||||
|
||||
test('does not infer a fallback location when the contact advertises one', () {
|
||||
final repeaterKey = Uint8List.fromList([
|
||||
0xCC,
|
||||
0xDD,
|
||||
0x10,
|
||||
0x11,
|
||||
0x12,
|
||||
0x13,
|
||||
...List<int>.generate(26, (index) => index + 20),
|
||||
]);
|
||||
provider.addOrUpdateContact(
|
||||
createContact(
|
||||
key: repeaterKey,
|
||||
type: ContactType.repeater,
|
||||
name: 'Relay Alpha',
|
||||
),
|
||||
);
|
||||
test(
|
||||
'does not infer a fallback location when the contact advertises one',
|
||||
() {
|
||||
final repeaterKey = Uint8List.fromList([
|
||||
0xCC,
|
||||
0xDD,
|
||||
0x10,
|
||||
0x11,
|
||||
0x12,
|
||||
0x13,
|
||||
...List<int>.generate(26, (index) => index + 20),
|
||||
]);
|
||||
provider.addOrUpdateContact(
|
||||
createContact(
|
||||
key: repeaterKey,
|
||||
type: ContactType.repeater,
|
||||
name: 'Relay Alpha',
|
||||
),
|
||||
);
|
||||
|
||||
final targetKey = createPublicKey(121);
|
||||
final route = ContactRouteCodec.parse('AABB,CCDD');
|
||||
provider.addOrUpdateContact(
|
||||
Contact(
|
||||
publicKey: targetKey,
|
||||
type: ContactType.chat,
|
||||
flags: 0,
|
||||
outPathLen: route.signedEncodedPathLen,
|
||||
outPath: route.paddedPathBytes,
|
||||
advName: 'Has Advert',
|
||||
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
advLat: (45.1234 * 1e6).toInt(),
|
||||
advLon: (13.8765 * 1e6).toInt(),
|
||||
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
),
|
||||
);
|
||||
final targetKey = createPublicKey(121);
|
||||
final route = ContactRouteCodec.parse('AABB,CCDD');
|
||||
provider.addOrUpdateContact(
|
||||
Contact(
|
||||
publicKey: targetKey,
|
||||
type: ContactType.chat,
|
||||
flags: 0,
|
||||
outPathLen: route.signedEncodedPathLen,
|
||||
outPath: route.paddedPathBytes,
|
||||
advName: 'Has Advert',
|
||||
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
advLat: (45.1234 * 1e6).toInt(),
|
||||
advLon: (13.8765 * 1e6).toInt(),
|
||||
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
),
|
||||
);
|
||||
|
||||
final updated = provider.findContactByKey(targetKey)!;
|
||||
expect(updated.displayLocation, isNotNull);
|
||||
expect(updated.displayLocation!.latitude, closeTo(45.1234, 0.000001));
|
||||
expect(updated.displayLocation!.longitude, closeTo(13.8765, 0.000001));
|
||||
});
|
||||
final updated = provider.findContactByKey(targetKey)!;
|
||||
expect(updated.displayLocation, isNotNull);
|
||||
expect(updated.displayLocation!.latitude, closeTo(45.1234, 0.000001));
|
||||
expect(updated.displayLocation!.longitude, closeTo(13.8765, 0.000001));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('ContactsProvider.updateFastGps', () {
|
||||
|
||||
143
test/services/contact_route_resolver_test.dart
Normal file
143
test/services/contact_route_resolver_test.dart
Normal file
@@ -0,0 +1,143 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:meshcore_sar_app/models/contact.dart';
|
||||
import 'package:meshcore_sar_app/services/contact_route_resolver.dart';
|
||||
|
||||
void main() {
|
||||
test('prefers known repeater route when available', () {
|
||||
final recipient = _contact(
|
||||
name: 'Target',
|
||||
type: ContactType.chat,
|
||||
seed: 90,
|
||||
lat: 46.20,
|
||||
lon: 14.70,
|
||||
);
|
||||
final routedRepeater = _contact(
|
||||
name: 'Routed',
|
||||
type: ContactType.repeater,
|
||||
seed: 10,
|
||||
lat: 46.15,
|
||||
lon: 14.60,
|
||||
signedPathLen: ContactRouteCodec.toSignedDescriptor(1),
|
||||
outPath: Uint8List.fromList([0xAA]),
|
||||
);
|
||||
|
||||
final plan = ContactRouteResolver.resolveAutomaticRoute(
|
||||
senderLocation: const LatLng(46.00, 14.50),
|
||||
recipient: recipient,
|
||||
availableContacts: [routedRepeater],
|
||||
hashSize: 1,
|
||||
);
|
||||
|
||||
expect(plan, isNotNull);
|
||||
expect(plan!.tokens, [
|
||||
'AA',
|
||||
routedRepeater.publicKeyHex.substring(0, 2).toUpperCase(),
|
||||
]);
|
||||
});
|
||||
|
||||
test('falls back to location-only repeaters', () {
|
||||
final recipient = _contact(
|
||||
name: 'Target',
|
||||
type: ContactType.chat,
|
||||
seed: 90,
|
||||
lat: 46.20,
|
||||
lon: 14.70,
|
||||
);
|
||||
final nearRepeater = _contact(
|
||||
name: 'Near',
|
||||
type: ContactType.repeater,
|
||||
seed: 10,
|
||||
lat: 46.08,
|
||||
lon: 14.55,
|
||||
);
|
||||
final farRepeater = _contact(
|
||||
name: 'Far',
|
||||
type: ContactType.repeater,
|
||||
seed: 11,
|
||||
lat: 46.40,
|
||||
lon: 15.10,
|
||||
);
|
||||
|
||||
final plan = ContactRouteResolver.resolveAutomaticRoute(
|
||||
senderLocation: const LatLng(46.00, 14.50),
|
||||
recipient: recipient,
|
||||
availableContacts: [nearRepeater, farRepeater],
|
||||
hashSize: 1,
|
||||
);
|
||||
|
||||
expect(plan, isNotNull);
|
||||
expect(plan!.selectedContacts.first.displayName, 'Near');
|
||||
});
|
||||
|
||||
test('for known routes prefers anchor closest to the chain end', () {
|
||||
final recipient = _contact(
|
||||
name: 'Target',
|
||||
type: ContactType.chat,
|
||||
seed: 90,
|
||||
lat: 46.20,
|
||||
lon: 14.70,
|
||||
);
|
||||
final chainHop = _contact(
|
||||
name: 'Chain Hop',
|
||||
type: ContactType.repeater,
|
||||
seed: 0xAA,
|
||||
lat: 46.01,
|
||||
lon: 14.51,
|
||||
);
|
||||
final nearAnchor = _contact(
|
||||
name: 'Near Anchor',
|
||||
type: ContactType.repeater,
|
||||
seed: 10,
|
||||
lat: 46.03,
|
||||
lon: 14.53,
|
||||
signedPathLen: ContactRouteCodec.toSignedDescriptor(1),
|
||||
outPath: Uint8List.fromList([0xAA]),
|
||||
);
|
||||
final farAnchor = _contact(
|
||||
name: 'Far Anchor',
|
||||
type: ContactType.repeater,
|
||||
seed: 11,
|
||||
lat: 46.18,
|
||||
lon: 14.68,
|
||||
signedPathLen: ContactRouteCodec.toSignedDescriptor(1),
|
||||
outPath: Uint8List.fromList([0xAA]),
|
||||
);
|
||||
|
||||
final plan = ContactRouteResolver.resolveAutomaticRoute(
|
||||
senderLocation: const LatLng(46.00, 14.50),
|
||||
recipient: recipient,
|
||||
availableContacts: [chainHop, nearAnchor, farAnchor],
|
||||
hashSize: 1,
|
||||
);
|
||||
|
||||
expect(plan, isNotNull);
|
||||
expect(plan!.selectedContacts.last.displayName, 'Near Anchor');
|
||||
});
|
||||
}
|
||||
|
||||
Contact _contact({
|
||||
required String name,
|
||||
required ContactType type,
|
||||
required int seed,
|
||||
required double lat,
|
||||
required double lon,
|
||||
int signedPathLen = -1,
|
||||
Uint8List? outPath,
|
||||
}) {
|
||||
final publicKey = Uint8List(32)..fillRange(0, 32, seed);
|
||||
return Contact(
|
||||
publicKey: publicKey,
|
||||
type: type,
|
||||
flags: 0,
|
||||
outPathLen: signedPathLen,
|
||||
outPath: outPath ?? Uint8List(64),
|
||||
advName: name,
|
||||
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
advLat: (lat * 1e6).round(),
|
||||
advLon: (lon * 1e6).round(),
|
||||
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user