Merge pull request #307 from ericszimmermann/ez_location_channel_message_path

location aware channel_message_path
This commit is contained in:
zjs81 2026-03-23 18:47:06 -07:00 committed by GitHub
commit 68eeefa04e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 97 additions and 44 deletions

3
.gitignore vendored
View file

@ -58,6 +58,7 @@ secrets.dart
.DS_Store
.AppleDouble
.LSOverride
macos/Flutter/GeneratedPluginRegistrant.swift
# iOS
**/ios/Pods/
@ -85,4 +86,4 @@ keystore.properties
.vscode/settings.json
# Cloudflare Wrangler
.wrangler
.wrangler

View file

@ -40,8 +40,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
final primaryPath = !channelMessage && !message.isOutgoing
? Uint8List.fromList(primaryPathTmp.reversed.toList())
: primaryPathTmp;
final contacts = connector.allContacts;
final hops = _buildPathHops(primaryPath, contacts, l10n);
final hops = _buildPathHops(primaryPath, connector, l10n);
final hasHopDetails = primaryPath.isNotEmpty;
final observedLabel = _formatObservedHops(
primaryPath.length,
@ -303,10 +302,12 @@ class _ChannelMessagePathMapScreenState
extends State<ChannelMessagePathMapScreen> {
static const double _labelZoomThreshold = 8.5;
final MapController _mapController = MapController();
Uint8List? _selectedPath;
double _pathDistance = 0.0;
bool _showNodeLabels = true;
bool _didReceivePositionUpdate = false;
int? _focusedHopIndex;
@override
void initState() {
@ -337,6 +338,22 @@ class _ChannelMessagePathMapScreenState
return totalDistance;
}
void _focusHop(_PathHop hop) {
if (!hop.hasLocation) return;
final targetZoom = _didReceivePositionUpdate
? max(_mapController.camera.zoom, 10.0)
: 12.0;
_mapController.move(hop.position!, targetZoom);
}
void _onHopTapped(_PathHop hop) {
_focusHop(hop);
if (!mounted) return;
setState(() {
_focusedHopIndex = hop.index;
});
}
@override
Widget build(BuildContext context) {
return Consumer<MeshCoreConnector>(
@ -365,8 +382,7 @@ class _ChannelMessagePathMapScreenState
: selectedPathTmp;
final selectedIndex = _indexForPath(selectedPath, observedPaths);
final contacts = connector.allContacts;
final hops = _buildPathHops(selectedPath, contacts, context.l10n);
final hops = _buildPathHops(selectedPath, connector, context.l10n);
final points = <LatLng>[];
@ -421,6 +437,7 @@ class _ChannelMessagePathMapScreenState
children: [
FlutterMap(
key: mapKey,
mapController: _mapController,
options: MapOptions(
initialCenter: initialCenter,
initialZoom: initialZoom,
@ -472,6 +489,7 @@ class _ChannelMessagePathMapScreenState
) {
setState(() {
_selectedPath = observedPaths[index].pathBytes;
_focusedHopIndex = null;
});
}),
if (points.isEmpty)
@ -727,8 +745,17 @@ class _ChannelMessagePathMapScreenState
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) {
final hop = hops[index];
final isFocused = _focusedHopIndex == hop.index;
return ListTile(
dense: true,
enabled: hop.hasLocation,
selected: isFocused,
selectedTileColor: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.12),
onTap: hop.hasLocation
? () => _onHopTapped(hop)
: null,
leading: CircleAvatar(
radius: 14,
child: Text(
@ -787,19 +814,71 @@ class _ObservedPath {
List<_PathHop> _buildPathHops(
Uint8List pathBytes,
List<Contact> contacts,
MeshCoreConnector connector,
AppLocalizations l10n,
) {
if (pathBytes.isEmpty) return const [];
final candidatesByPrefix = <int, List<Contact>>{};
for (final contact in connector.allContacts) {
if (contact.publicKey.isEmpty) continue;
if (contact.type != advTypeRepeater && contact.type != advTypeRoom) {
continue;
}
final prefix = contact.publicKey.first;
candidatesByPrefix.putIfAbsent(prefix, () => <Contact>[]).add(contact);
}
for (final candidates in candidatesByPrefix.values) {
candidates.sort((a, b) => b.lastSeen.compareTo(a.lastSeen));
}
final startPoint =
(connector.selfLatitude != null && connector.selfLongitude != null)
? LatLng(connector.selfLatitude!, connector.selfLongitude!)
: null;
var previousPosition = startPoint;
final distance = Distance();
final hops = <_PathHop>[];
for (var i = 0; i < pathBytes.length; i++) {
final prefix = pathBytes[i];
final contact = _matchContactForPrefix(contacts, prefix);
final searchPoint = i == 0 ? startPoint : previousPosition;
final candidates = candidatesByPrefix[pathBytes[i]];
Contact? contact;
if (candidates != null && candidates.isNotEmpty) {
var bestIndex = 0;
if (searchPoint != null) {
var bestDistance = double.infinity;
for (var j = 0; j < candidates.length; j++) {
final candidate = candidates[j];
if (!candidate.hasLocation ||
candidate.latitude == null ||
candidate.longitude == null) {
continue;
}
final currentDistance = distance(
searchPoint,
LatLng(candidate.latitude!, candidate.longitude!),
);
if (currentDistance < bestDistance) {
bestDistance = currentDistance;
bestIndex = j;
}
}
}
contact = candidates.removeAt(bestIndex);
if (candidates.isEmpty) {
candidatesByPrefix.remove(pathBytes[i]);
}
}
final resolvedPosition = _resolvePosition(contact);
if (resolvedPosition != null) {
previousPosition = resolvedPosition;
}
hops.add(
_PathHop(
index: i + 1,
prefix: prefix,
prefix: pathBytes[i],
contact: contact,
position: _resolvePosition(contact),
position: resolvedPosition,
l10n: l10n,
),
);
@ -807,42 +886,13 @@ List<_PathHop> _buildPathHops(
return hops;
}
Contact? _matchContactForPrefix(List<Contact> contacts, int prefix) {
final matches = contacts
.where(
(contact) =>
(contact.type == advTypeRepeater || contact.type == advTypeRoom) &&
contact.publicKey.isNotEmpty &&
contact.publicKey[0] == prefix,
)
.toList();
if (matches.isEmpty) return null;
Contact? pickWhere(bool Function(Contact) predicate) {
for (final contact in matches) {
if (predicate(contact)) return contact;
}
return null;
}
return pickWhere((c) => c.type == advTypeRepeater && _hasValidLocation(c)) ??
pickWhere((c) => c.type == advTypeRepeater) ??
pickWhere(_hasValidLocation) ??
matches.first;
}
LatLng? _resolvePosition(Contact? contact) {
if (contact == null) return null;
if (!_hasValidLocation(contact)) return null;
return LatLng(contact.latitude!, contact.longitude!);
}
bool _hasValidLocation(Contact contact) {
final lat = contact.latitude;
final lon = contact.longitude;
if (lat == null || lon == null) return false;
if (lat == 0 && lon == 0) return false;
return true;
if (!contact.hasLocation) return null;
final latitude = contact.latitude;
final longitude = contact.longitude;
if (latitude == null || longitude == null) return null;
return LatLng(latitude, longitude);
}
String _formatPrefix(int prefix) {

View file

@ -9,6 +9,7 @@ import flutter_blue_plus_darwin
import flutter_local_notifications
import mobile_scanner
import package_info_plus
import path_provider_foundation
import share_plus
import shared_preferences_foundation
import sqflite_darwin
@ -20,6 +21,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))