Enhance contact handling and UI updates across multiple screens

add unfiltered contact access and improve last seen resolution
This commit is contained in:
Winston Lowe 2026-03-25 18:30:27 -07:00
parent 9dbf374ac6
commit 882abf3879
9 changed files with 142 additions and 16 deletions

View file

@ -328,6 +328,9 @@ class MeshCoreConnector extends ChangeNotifier {
),
]);
List<Contact> get allContactsUnfiltered =>
List.unmodifiable([..._contacts, ..._discoveredContacts]);
List<Contact> 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<void> getContacts({int? since, bool preserveExisting = false}) async {
if (!isConnected) return;

View file

@ -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 = <LatLng>[];

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: () {

View file

@ -139,7 +139,9 @@ class _MapScreenState extends State<MapScreen> {
builder: (context, connector, settingsService, pathHistory, child) {
final tileCache = context.read<MapTileCacheService>();
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<MapScreen> {
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
});

View file

@ -76,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;
@ -268,17 +270,41 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
.toList();
Map<int, Contact> 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;
}
}
});

View file

@ -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();

View file

@ -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';

View file

@ -113,7 +113,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
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';

View file

@ -108,7 +108,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
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';