Refactor contact handling and other improvments (#317)

* 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
This commit is contained in:
Winston Lowe
2026-03-26 22:28:01 -07:00
committed by GitHub
parent 411cd3f8d2
commit d0e3767db6
51 changed files with 440 additions and 105 deletions
+15 -3
View File
@@ -822,7 +822,8 @@ List<_PathHop> _buildPathHops(
) {
if (pathBytes.isEmpty) return const [];
final candidatesByPrefix = <int, List<Contact>>{};
for (final contact in connector.allContacts) {
final allContacts = connector.allContacts;
for (final contact in allContacts) {
if (contact.publicKey.isEmpty) continue;
if (contact.type != advTypeRepeater && contact.type != advTypeRoom) {
continue;
@@ -839,7 +840,8 @@ List<_PathHop> _buildPathHops(
: null;
var previousPosition = startPoint;
final distance = Distance();
var lastDistance = 0.0;
var bestDistance = 0.0;
final hops = <_PathHop>[];
for (var i = 0; i < pathBytes.length; i++) {
final searchPoint = i == 0 ? startPoint : previousPosition;
@@ -848,7 +850,7 @@ List<_PathHop> _buildPathHops(
if (candidates != null && candidates.isNotEmpty) {
var bestIndex = 0;
if (searchPoint != null) {
var bestDistance = double.infinity;
bestDistance = double.infinity;
for (var j = 0; j < candidates.length; j++) {
final candidate = candidates[j];
if (!candidate.hasLocation ||
@@ -876,6 +878,16 @@ List<_PathHop> _buildPathHops(
if (resolvedPosition != null) {
previousPosition = resolvedPosition;
}
// If the best candidate is much farther than the previous hop, it's likely not the correct match.
if (lastDistance + bestDistance > 70000 &&
candidates != null &&
candidates.isNotEmpty) {
i--;
lastDistance = bestDistance;
continue;
}
lastDistance = bestDistance;
hops.add(
_PathHop(
index: i + 1,
+1 -1
View File
@@ -127,7 +127,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
canPop: allowBack,
child: Scaffold(
appBar: AppBar(
title: AppBarTitle(context.l10n.channels_title, indicators: false),
title: AppBarTitle(context.l10n.channels_title),
centerTitle: true,
automaticallyImplyLeading: false,
actions: [
+1 -1
View File
@@ -290,6 +290,7 @@ class _ChatScreenState extends State<ChatScreen> {
tooltip: context.l10n.chat_pathManagement,
onPressed: () => _showPathHistory(context),
),
const RadioStatsIconButton(),
Consumer<MeshCoreConnector>(
builder: (context, connector, _) {
return PopupMenuButton<String>(
@@ -362,7 +363,6 @@ class _ChatScreenState extends State<ChatScreen> {
);
},
),
const RadioStatsIconButton(),
],
),
body: Consumer<MeshCoreConnector>(
@@ -24,6 +24,7 @@ class _CompanionRadioStatsScreenState extends State<CompanionRadioStatsScreen> {
final c = context.read<MeshCoreConnector>();
_connector = c;
c.acquireRadioStatsPolling();
c.setPollingInterval(1);
c.radioStatsNotifier.addListener(_onStatsUpdate);
}
@@ -44,6 +45,7 @@ class _CompanionRadioStatsScreenState extends State<CompanionRadioStatsScreen> {
void dispose() {
_connector?.radioStatsNotifier.removeListener(_onStatsUpdate);
_connector?.releaseRadioStatsPolling();
_connector?.setPollingInterval(30);
super.dispose();
}
+7 -12
View File
@@ -1240,9 +1240,7 @@ class _ContactsScreenState extends State<ContactsScreen>
if (isRepeater) ...[
ListTile(
leading: const Icon(Icons.radar, color: Colors.green),
title: contact.pathBytesForDisplay.isNotEmpty
? Text(context.l10n.contacts_pathTrace)
: Text(context.l10n.contacts_ping),
title: Text(context.l10n.contacts_ping),
onTap: () {
final hw = context
.read<MeshCoreConnector>()
@@ -1251,11 +1249,8 @@ class _ContactsScreenState extends State<ContactsScreen>
context,
MaterialPageRoute(
builder: (context) => PathTraceMapScreen(
title: contact.pathBytesForDisplay.isNotEmpty
? context.l10n.contacts_repeaterPathTrace
: context.l10n.contacts_repeaterPing,
path: contact.pathBytesForDisplay,
flipPathAround: true,
title: context.l10n.contacts_repeaterPing,
path: Uint8List.fromList([contact.publicKey.first]),
targetContact: contact,
pathHashByteWidth: hw,
),
@@ -1274,9 +1269,7 @@ class _ContactsScreenState extends State<ContactsScreen>
] else if (isRoom) ...[
ListTile(
leading: const Icon(Icons.radar, color: Colors.green),
title: contact.pathLength > 0
? Text(context.l10n.contacts_pathTrace)
: Text(context.l10n.contacts_ping),
title: Text(context.l10n.contacts_pathTrace),
onTap: () {
final hw = context
.read<MeshCoreConnector>()
@@ -1288,7 +1281,9 @@ class _ContactsScreenState extends State<ContactsScreen>
title: contact.pathBytesForDisplay.isNotEmpty
? context.l10n.contacts_roomPathTrace
: context.l10n.contacts_roomPing,
path: contact.pathBytesForDisplay,
path: contact.pathBytesForDisplay.isNotEmpty
? contact.pathBytesForDisplay
: Uint8List.fromList([contact.publicKey.first]),
flipPathAround: contact.pathBytesForDisplay.isNotEmpty,
targetContact: contact,
pathHashByteWidth: hw,
+57 -5
View File
@@ -38,6 +38,13 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
super.dispose();
}
DateTime _resolveLastSeen(Contact contact) {
if (contact.type != advTypeChat) return contact.lastSeen;
return contact.lastMessageAt.isAfter(contact.lastSeen)
? contact.lastMessageAt
: contact.lastSeen;
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
@@ -108,11 +115,56 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Text(
_formatLastSeen(context, contact.lastSeen),
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
// Clamp text scaling in trailing section to prevent overflow while
// maintaining accessibility. Primary content (title/subtitle) scales normally.
trailing: MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler: TextScaler.linear(
MediaQuery.textScalerOf(
context,
).scale(1.0).clamp(1.0, 1.3),
),
),
child: SizedBox(
width: 120,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
_formatLastSeen(
context,
_resolveLastSeen(contact),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (contact.hasLocation)
Icon(
Icons.location_on,
size: 14,
color: Colors.grey[400],
),
if (contact.rawPacket != null)
const SizedBox(width: 2),
if (contact.rawPacket != null)
Icon(
Icons.cell_tower,
size: 14,
color: Colors.grey[400],
),
],
),
],
),
),
),
onTap: () {
+47 -22
View File
@@ -64,6 +64,7 @@ class _MapScreenState extends State<MapScreen> {
bool _hasInitializedMap = false;
bool _removedMarkersLoaded = false;
final List<int> _pathTrace = [];
final List<Contact> _pathTraceContacts = [];
final List<LatLng> _points = [];
final List<Polyline> _polylines = [];
bool _legendExpanded = false;
@@ -488,7 +489,7 @@ class _MapScreenState extends State<MapScreen> {
),
),
),
if (!_isBuildingPathTrace)
if (!settings.mapShowOverlaps)
..._buildGuessedMarker(
guessedLocations,
showLabels: _showNodeLabels,
@@ -788,17 +789,26 @@ class _MapScreenState extends State<MapScreen> {
final markers = <Marker>[];
for (final guess in guessed) {
if (guess.contact.type == advTypeChat && _isBuildingPathTrace) {
continue;
}
final color = _getNodeColor(guess.contact.type);
final marker = Marker(
point: guess.position,
width: 35,
height: 35,
child: GestureDetector(
onTap: () => _showNodeInfo(
context,
guess.contact,
guessedPosition: guess.position,
),
onLongPress: () => _isBuildingPathTrace
? _showNodeInfo(context, guess.contact)
: null,
onTap: () => _isBuildingPathTrace
? _addToPath(context, guess.contact, position: guess.position)
: _showNodeInfo(
context,
guess.contact,
guessedPosition: guess.position,
),
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
@@ -870,23 +880,29 @@ class _MapScreenState extends State<MapScreen> {
addContact = true;
}
final hasOverlap = contacts
.where(
(c) =>
c.publicKeyHex != contact.publicKeyHex &&
c.publicKey.first == contact.publicKey.first &&
(c.type == advTypeRepeater || c.type == advTypeRoom) &&
(contact.type == advTypeRepeater ||
contact.type == advTypeRoom),
)
.firstOrNull;
if (hasOverlap == null &&
settings.mapShowOverlaps &&
!_isBuildingPathTrace) {
if (contact.type == advTypeChat && _isBuildingPathTrace) {
addContact = false;
}
if (settings.mapShowOverlaps) {
final hasOverlap = contacts
.where(
(c) =>
c.publicKeyHex != contact.publicKeyHex &&
c.publicKey.first == contact.publicKey.first &&
(c.type == advTypeRepeater || c.type == advTypeRoom) &&
(contact.type == advTypeRepeater ||
contact.type == advTypeRoom),
)
.firstOrNull;
if (hasOverlap == null &&
settings.mapShowOverlaps &&
!_isBuildingPathTrace) {
addContact = false;
}
}
if (addContact) {
filtered.add(contact);
}
@@ -2121,12 +2137,18 @@ class _MapScreenState extends State<MapScreen> {
}
}
void _addToPath(BuildContext context, Contact contact) {
void _addToPath(BuildContext context, Contact contact, {LatLng? position}) {
setState(() {
_pathTrace.add(
contact.publicKey[0],
); // Add first 16 bytes of public key to path trace
_points.add(LatLng(contact.latitude!, contact.longitude!));
_pathTraceContacts.add(
contact.copyWith(
latitude: position?.latitude ?? contact.latitude,
longitude: position?.longitude ?? contact.longitude,
),
); // Add contact to path trace contacts
_points.add(position ?? LatLng(contact.latitude!, contact.longitude!));
});
}
@@ -2134,6 +2156,7 @@ class _MapScreenState extends State<MapScreen> {
setState(() {
_isBuildingPathTrace = true;
_pathTrace.clear();
_pathTraceContacts.clear();
_points.clear();
_polylines.clear();
_points.add(position);
@@ -2142,6 +2165,7 @@ class _MapScreenState extends State<MapScreen> {
void _removePath() {
setState(() {
_pathTraceContacts.removeLast();
_pathTrace.removeLast(); // Remove last node from path trace
_points.removeLast(); // Remove last point from points list
_polylines.clear(); // Clear polylines
@@ -2201,6 +2225,7 @@ class _MapScreenState extends State<MapScreen> {
title: l10n.contacts_pathTrace,
path: Uint8List.fromList(_pathTrace),
pathHashByteWidth: hashW,
pathContacts: _pathTraceContacts,
),
),
);
+40 -10
View File
@@ -56,6 +56,7 @@ class PathTraceMapScreen extends StatefulWidget {
final bool reversePathAround;
final Contact? targetContact;
final int pathHashByteWidth;
final List<Contact>? pathContacts;
const PathTraceMapScreen({
super.key,
@@ -66,6 +67,7 @@ class PathTraceMapScreen extends StatefulWidget {
this.reversePathAround = false,
this.targetContact,
this.pathHashByteWidth = pathHashSize,
this.pathContacts,
});
@override
@@ -74,6 +76,8 @@ class PathTraceMapScreen extends StatefulWidget {
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;
@@ -266,17 +270,43 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
.toList();
Map<int, Contact> pathContacts = {};
final contacts = connector.allContacts;
contacts.where((c) => c.type != advTypeChat).forEach((repeater) {
for (var repeaterData in pathData) {
if (listEquals(
repeater.publicKey.sublist(0, 1),
Uint8List.fromList([repeaterData]),
)) {
pathContacts[repeaterData] = repeater;
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.
+7 -1
View File
@@ -35,13 +35,15 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
// Common commands for quick access
late final List<Map<String, String>> _quickCommands = [
{'labelKey': 'advertise', 'command': 'advert'},
{'labelKey': 'getName', 'command': 'get name'},
{'labelKey': 'getRadio', 'command': 'get radio'},
{'labelKey': 'getTx', 'command': 'get tx'},
{'labelKey': 'discovery', 'command': 'discover.neighbors'},
{'labelKey': 'neighbors', 'command': 'neighbors'},
{'labelKey': 'version', 'command': 'ver'},
{'labelKey': 'advertise', 'command': 'advert'},
{'labelKey': 'clock', 'command': 'clock'},
{'labelKey': 'clock sync', 'command': 'clock sync'},
];
@override
@@ -407,6 +409,10 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
return l10n.repeater_cliQuickAdvertise;
case 'clock':
return l10n.repeater_cliQuickClock;
case 'clock sync':
return l10n.repeater_cliQuickClockSync;
case 'discovery':
return l10n.repeater_cliQuickDiscovery;
default:
return key;
}