From 882abf3879a0fda92f46ec36dd7beecbdd1e3a3a Mon Sep 17 00:00:00 2001 From: Winston Lowe Date: Wed, 25 Mar 2026 18:30:27 -0700 Subject: [PATCH] Enhance contact handling and UI updates across multiple screens add unfiltered contact access and improve last seen resolution --- lib/connector/meshcore_connector.dart | 15 +++++ lib/screens/channel_message_path_screen.dart | 10 +++- lib/screens/discovery_screen.dart | 62 ++++++++++++++++++-- lib/screens/map_screen.dart | 6 +- lib/screens/path_trace_map.dart | 28 ++++++++- lib/utils/gpx_export.dart | 32 ++++++++-- lib/widgets/path_management_dialog.dart | 1 + lib/widgets/repeater_login_dialog.dart | 2 +- lib/widgets/room_login_dialog.dart | 2 +- 9 files changed, 142 insertions(+), 16 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 11e2d4d..358326b 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -328,6 +328,9 @@ class MeshCoreConnector extends ChangeNotifier { ), ]); + List get allContactsUnfiltered => + List.unmodifiable([..._contacts, ..._discoveredContacts]); + List get discoveredContacts { return List.unmodifiable(_discoveredContacts); } @@ -2372,6 +2375,18 @@ class MeshCoreConnector extends ChangeNotifier { }); } + Contact getFromDiscovered(Contact contact) { + final tmp = _discoveredContacts.firstWhere( + (c) => c.publicKeyHex == contact.publicKeyHex, + orElse: () => contact, + ); + return contact.copyWith( + rawPacket: tmp.rawPacket, + latitude: tmp.latitude, + longitude: tmp.longitude, + ); + } + Future getContacts({int? since, bool preserveExisting = false}) async { if (!isConnected) return; diff --git a/lib/screens/channel_message_path_screen.dart b/lib/screens/channel_message_path_screen.dart index 94b8eee..89e2fdd 100644 --- a/lib/screens/channel_message_path_screen.dart +++ b/lib/screens/channel_message_path_screen.dart @@ -40,7 +40,12 @@ class ChannelMessagePathScreen extends StatelessWidget { final primaryPath = !channelMessage && !message.isOutgoing ? Uint8List.fromList(primaryPathTmp.reversed.toList()) : primaryPathTmp; +<<<<<<< HEAD final hops = _buildPathHops(primaryPath, connector, l10n); +======= + final contacts = connector.allContactsUnfiltered; + final hops = _buildPathHops(primaryPath, contacts, l10n); +>>>>>>> da74560 (Enhance contact handling and UI updates across multiple screens) final hasHopDetails = primaryPath.isNotEmpty; final observedLabel = _formatObservedHops( primaryPath.length, @@ -385,7 +390,10 @@ class _ChannelMessagePathMapScreenState : selectedPathTmp; final selectedIndex = _indexForPath(selectedPath, observedPaths); - final hops = _buildPathHops(selectedPath, connector, context.l10n); + final contacts = connector.allContactsUnfiltered + .map((c) => connector.getFromDiscovered(c)) + .toList(); + final hops = _buildPathHops(selectedPath, contacts, context.l10n); final points = []; diff --git a/lib/screens/discovery_screen.dart b/lib/screens/discovery_screen.dart index 4e7c6e8..3f9d965 100644 --- a/lib/screens/discovery_screen.dart +++ b/lib/screens/discovery_screen.dart @@ -38,6 +38,13 @@ class _DiscoveryScreenState extends State { 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 { 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: () { diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 700c382..e2d5fa3 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -139,7 +139,9 @@ class _MapScreenState extends State { builder: (context, connector, settingsService, pathHistory, child) { final tileCache = context.read(); final settings = settingsService.settings; - final allContacts = connector.allContacts; + final allContacts = connector.allContacts + .map((c) => connector.getFromDiscovered(c)) + .toList(); final contacts = settings.mapShowDiscoveryContacts ? allContacts @@ -2158,10 +2160,10 @@ class _MapScreenState extends State { void _removePath() { setState(() { - _pathTrace.removeLast(); // Remove last node from path trace _pathTraceContacts.remove( _pathTrace.last, ); // Remove last contact from path trace + _pathTrace.removeLast(); // Remove last node from path trace _points.removeLast(); // Remove last point from points list _polylines.clear(); // Clear polylines }); diff --git a/lib/screens/path_trace_map.dart b/lib/screens/path_trace_map.dart index 8d6b0e7..1064320 100644 --- a/lib/screens/path_trace_map.dart +++ b/lib/screens/path_trace_map.dart @@ -76,6 +76,8 @@ class PathTraceMapScreen extends StatefulWidget { class _PathTraceMapScreenState extends State { 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? _frameSubscription; Timer? _timeoutTimer; @@ -268,17 +270,41 @@ class _PathTraceMapScreenState extends State { .toList(); Map pathContacts = {}; + Contact lastContact = Contact( + path: Uint8List(0), + pathLength: 0, + publicKey: connector.selfPublicKey ?? Uint8List(0), + name: context.l10n.pathTrace_you, + type: advTypeChat, + latitude: connector.selfLatitude, + longitude: connector.selfLongitude, + lastSeen: DateTime.now(), + ); if (widget.pathContacts != null) { pathContacts = {for (var c in widget.pathContacts!) c.publicKey[0]: c}; } else { - final contacts = connector.allContacts; + final contacts = connector.allContactsUnfiltered + .map((c) => connector.getFromDiscovered(c)) + .toList(); 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; } } }); diff --git a/lib/utils/gpx_export.dart b/lib/utils/gpx_export.dart index b0165bd..021bb17 100644 --- a/lib/utils/gpx_export.dart +++ b/lib/utils/gpx_export.dart @@ -14,12 +14,13 @@ class ContactExport { final double lon; final String desc; final double? ele; - + final String url; ContactExport({ required this.name, required this.lat, required this.lon, required this.desc, + required this.url, this.ele, }); } @@ -40,6 +41,7 @@ class GpxExport { String name, double lat, double lon, + String url, String desc, [ double? ele, ]) { @@ -50,55 +52,72 @@ class GpxExport { lon: lon, desc: desc.trim(), ele: ele, + url: url, ), ); } void addRepeaters() { - final contacts = _connector.contacts + final contacts = _connector.allContacts .where((c) => c.type == advTypeRepeater || c.type == advTypeRoom) + .map((c) => _connector.getFromDiscovered(c)) .toList(); for (var contact in contacts) { if (contact.latitude == null || contact.longitude == null) { continue; } + final url = contact.rawPacket != null + ? "meshcore://${pubKeyToHex(contact.rawPacket!)}" + : ""; _addContact( contact.name, contact.latitude!, contact.longitude!, "Type: ${contact.typeLabel}\nPublic Key: ${contact.publicKeyHex}", + url, ); } } void addContacts() { - final contacts = _connector.contacts + final contacts = _connector.allContacts .where((c) => c.type == advTypeChat) + .map((c) => _connector.getFromDiscovered(c)) .toList(); for (var contact in contacts) { if (contact.latitude == null || contact.longitude == null) { continue; } + final url = contact.rawPacket != null + ? "meshcore://${pubKeyToHex(contact.rawPacket!)}" + : ""; _addContact( contact.name, contact.latitude!, contact.longitude!, "Type: ${contact.typeLabel}\nPublic Key: ${contact.publicKeyHex}", + url, ); } } void addAll() { - final contacts = _connector.contacts; - for (var contact in contacts.toList()) { + final contacts = _connector.allContacts + .map((c) => _connector.getFromDiscovered(c)) + .toList(); + for (var contact in contacts) { if (contact.latitude == null || contact.longitude == null) { continue; } + final url = contact.rawPacket != null + ? "meshcore://${pubKeyToHex(contact.rawPacket!)}" + : ""; _addContact( contact.name, contact.latitude ?? 0.0, contact.longitude ?? 0.0, "Type: ${contact.typeLabel}\nPublic Key: ${contact.publicKeyHex}", + url, ); } } @@ -138,6 +157,9 @@ class GpxExport { ele: c.ele, name: c.name, desc: c.desc, + extensions: { + "meshcore": {"url": c.url}, + }, ), ) .toList(); diff --git a/lib/widgets/path_management_dialog.dart b/lib/widgets/path_management_dialog.dart index 4e91a69..c2eda30 100644 --- a/lib/widgets/path_management_dialog.dart +++ b/lib/widgets/path_management_dialog.dart @@ -1,6 +1,7 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:meshcore_open/connector/meshcore_protocol.dart'; import 'package:meshcore_open/models/path_history.dart'; import 'package:meshcore_open/screens/path_trace_map.dart'; import 'package:meshcore_open/widgets/elements_ui.dart'; diff --git a/lib/widgets/repeater_login_dialog.dart b/lib/widgets/repeater_login_dialog.dart index ce6c2b7..48bb6ac 100644 --- a/lib/widgets/repeater_login_dialog.dart +++ b/lib/widgets/repeater_login_dialog.dart @@ -113,7 +113,7 @@ class _RepeaterLoginDialogState extends State { messageBytes: responseBytes, ); final timeoutSeconds = (timeoutMs / 1000).ceil(); - final timeout = Duration(milliseconds: timeoutMs); + final timeout = Duration(milliseconds: timeoutMs + 2000); final selectionLabel = selection.useFlood ? 'flood' : '${selection.hopCount} hops'; diff --git a/lib/widgets/room_login_dialog.dart b/lib/widgets/room_login_dialog.dart index 91d2c8c..3a923fe 100644 --- a/lib/widgets/room_login_dialog.dart +++ b/lib/widgets/room_login_dialog.dart @@ -108,7 +108,7 @@ class _RoomLoginDialogState extends State { messageBytes: responseBytes, ); final timeoutSeconds = (timeoutMs / 1000).ceil(); - final timeout = Duration(milliseconds: timeoutMs); + final timeout = Duration(milliseconds: timeoutMs + 2000); final selectionLabel = selection.useFlood ? 'flood' : '${selection.hopCount} hops';