diff --git a/lib/services/relay_candidate_sorter.dart b/lib/services/relay_candidate_sorter.dart new file mode 100644 index 0000000..ba5d856 --- /dev/null +++ b/lib/services/relay_candidate_sorter.dart @@ -0,0 +1,48 @@ +import 'package:geolocator/geolocator.dart'; +import 'package:latlong2/latlong.dart'; + +import '../models/contact.dart'; + +class RelayCandidateSorter { + const RelayCandidateSorter(); + + List sortByDistanceFromSelf( + List contacts, { + required LatLng? selfPoint, + }) { + final sorted = List.from(contacts); + sorted.sort((a, b) { + if (selfPoint != null) { + final distanceCompare = _distanceFrom( + selfPoint, + a, + ).compareTo(_distanceFrom(selfPoint, b)); + if (distanceCompare != 0) { + return distanceCompare; + } + } + + final nameCompare = a.displayName.compareTo(b.displayName); + if (nameCompare != 0) { + return nameCompare; + } + + return a.publicKeyHex.compareTo(b.publicKeyHex); + }); + return sorted; + } + + double _distanceFrom(LatLng selfPoint, Contact contact) { + final location = contact.displayLocation; + if (location == null) { + return double.infinity; + } + + return Geolocator.distanceBetween( + selfPoint.latitude, + selfPoint.longitude, + location.latitude, + location.longitude, + ); + } +} diff --git a/lib/widgets/contacts/contact_route_dialog.dart b/lib/widgets/contacts/contact_route_dialog.dart index 8e8642d..259857f 100644 --- a/lib/widgets/contacts/contact_route_dialog.dart +++ b/lib/widgets/contacts/contact_route_dialog.dart @@ -11,6 +11,7 @@ import '../../providers/app_provider.dart'; import '../../providers/connection_provider.dart'; import '../../services/contact_route_resolver.dart'; import '../../services/path_history_service.dart'; +import '../../services/relay_candidate_sorter.dart'; import '../../services/route_hash_preferences.dart'; class ContactRouteDialogResult { @@ -72,6 +73,8 @@ class _ContactRouteDialogState extends State { late final TextEditingController _controller; late final TextEditingController _relaySearchController; final PathHistoryService _pathHistoryService = PathHistoryService(); + final RelayCandidateSorter _relayCandidateSorter = + const RelayCandidateSorter(); int _selectedHashSize = RouteHashPreferences.defaultHashSize; ParsedContactRoute? _parsedRoute; String? _errorText; @@ -133,13 +136,16 @@ class _ContactRouteDialogState extends State { } } - List get _routeCandidates => - widget.availableContacts - .where( - (contact) => contact.isRepeater && contact.displayLocation != null, - ) - .toList() - ..sort((a, b) => a.displayName.compareTo(b.displayName)); + List _routeCandidates({required LatLng? selfPoint}) => + _relayCandidateSorter.sortByDistanceFromSelf( + widget.availableContacts + .where( + (contact) => + contact.isRepeater && contact.displayLocation != null, + ) + .toList(), + selfPoint: selfPoint, + ); List _mapSelectionForText(String text) { final tokens = text @@ -169,7 +175,8 @@ class _ContactRouteDialogState extends State { final contactHashSize = widget.contact.hasPath ? widget.contact.pathHashSize : null; - final hashSize = contactHashSize ?? await RouteHashPreferences.getHashSize(); + final hashSize = + contactHashSize ?? await RouteHashPreferences.getHashSize(); if (!mounted) return; setState(() { _selectedHashSize = hashSize; @@ -697,18 +704,12 @@ class _ContactRouteDialogState extends State { children: [ Row( children: [ - Text( - 'Path Size', - style: Theme.of(context).textTheme.labelLarge, - ), + Text('Path Size', style: Theme.of(context).textTheme.labelLarge), const Spacer(), SegmentedButton( segments: [ for (final size in RouteHashPreferences.supportedSizes) - ButtonSegment( - value: size, - label: Text('${size}B'), - ), + ButtonSegment(value: size, label: Text('${size}B')), ], selected: {_selectedHashSize}, onSelectionChanged: (selection) { @@ -822,7 +823,6 @@ class _ContactRouteDialogState extends State { @override Widget build(BuildContext context) { final appProvider = context.watch(); - final routeCandidates = _routeCandidates; final connectionProvider = context.watch(); final selfPoint = connectionProvider.deviceInfo.advLat != null && @@ -834,6 +834,7 @@ class _ContactRouteDialogState extends State { connectionProvider.deviceInfo.advLon! / 1e6, ) : null; + final routeCandidates = _routeCandidates(selfPoint: selfPoint); final recipientLocation = widget.contact.displayLocation; final recipientPoint = recipientLocation == null ? null diff --git a/test/services/relay_candidate_sorter_test.dart b/test/services/relay_candidate_sorter_test.dart new file mode 100644 index 0000000..4192ae5 --- /dev/null +++ b/test/services/relay_candidate_sorter_test.dart @@ -0,0 +1,60 @@ +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/relay_candidate_sorter.dart'; + +void main() { + test('sorts relay candidates by distance from self point', () { + final sorter = RelayCandidateSorter(); + + final sorted = sorter.sortByDistanceFromSelf([ + _contact(seed: 1, name: 'Far', lat: 46.08, lon: 14.60), + _contact(seed: 2, name: 'Near', lat: 46.0570, lon: 14.5060), + _contact(seed: 3, name: 'Middle', lat: 46.06, lon: 14.52), + ], selfPoint: const LatLng(46.0569, 14.5058)); + + expect(sorted.map((contact) => contact.displayName).toList(), [ + 'Near', + 'Middle', + 'Far', + ]); + }); + + test('falls back to stable name ordering when self point is unavailable', () { + final sorter = RelayCandidateSorter(); + + final sorted = sorter.sortByDistanceFromSelf([ + _contact(seed: 1, name: 'Zulu', lat: 46.08, lon: 14.60), + _contact(seed: 2, name: 'Alpha', lat: 46.0570, lon: 14.5060), + ], selfPoint: null); + + expect(sorted.map((contact) => contact.displayName).toList(), [ + 'Alpha', + 'Zulu', + ]); + }); +} + +Contact _contact({ + required int seed, + required String name, + required double lat, + required double lon, +}) { + final publicKey = Uint8List(32)..fillRange(0, 32, seed); + return Contact( + publicKey: publicKey, + type: ContactType.repeater, + flags: 0, + outPathLen: -1, + outPath: Uint8List(64), + advName: name, + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: (lat * 1e6).round(), + advLon: (lon * 1e6).round(), + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); +}