feat: Implement advertisement path tracking with location history and UI integration

This commit is contained in:
Janez T
2025-10-15 21:59:04 +02:00
parent 6be182e20a
commit aab79a56ea
7 changed files with 446 additions and 5 deletions

View File

@@ -0,0 +1,40 @@
import 'package:latlong2/latlong.dart';
/// Single advertisement location point in a contact's movement history
class AdvertLocation {
final LatLng location;
final DateTime timestamp;
AdvertLocation({
required this.location,
required this.timestamp,
});
/// Get friendly time ago display
String get timeAgo {
final diff = DateTime.now().difference(timestamp);
if (diff.inMinutes < 1) return 'Just now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
return '${diff.inDays}d ago';
}
@override
String toString() {
return 'AdvertLocation(lat: ${location.latitude.toStringAsFixed(6)}, '
'lon: ${location.longitude.toStringAsFixed(6)}, '
'time: ${timestamp.toIso8601String()})';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is AdvertLocation &&
other.location.latitude == location.latitude &&
other.location.longitude == location.longitude &&
other.timestamp == timestamp;
}
@override
int get hashCode => Object.hash(location.latitude, location.longitude, timestamp);
}

View File

@@ -1,7 +1,9 @@
import 'dart:math';
import 'dart:typed_data';
import 'package:latlong2/latlong.dart';
import 'package:flutter/material.dart';
import 'contact_telemetry.dart';
import 'advert_location.dart';
/// MeshCore contact types
enum ContactType {
@@ -53,6 +55,9 @@ class Contact {
// Telemetry data (updated separately)
ContactTelemetry? telemetry;
// Advertisement location history (most recent first)
final List<AdvertLocation> advertHistory;
// UI state tracking
final bool isNew; // Whether contact is newly added and not yet viewed
@@ -68,8 +73,9 @@ class Contact {
required this.advLon,
required this.lastMod,
this.telemetry,
List<AdvertLocation>? advertHistory,
this.isNew = false,
});
}) : advertHistory = advertHistory ?? [];
/// Get public key as hex string (first 8 bytes)
String get publicKeyShort {
@@ -242,6 +248,49 @@ class Contact {
return 0; // 5+ hops
}
/// Add a new advertisement location to history (maintains max 100 points)
Contact addAdvertLocation(LatLng location, DateTime timestamp) {
final newPoint = AdvertLocation(location: location, timestamp: timestamp);
// Check if this location is significantly different from the last one
// (avoid duplicate points for stationary contacts)
if (advertHistory.isNotEmpty) {
final lastPoint = advertHistory.first;
final distance = _calculateDistance(lastPoint.location, location);
// If less than 5 meters apart and within 1 minute, skip
if (distance < 5 && timestamp.difference(lastPoint.timestamp).inSeconds < 60) {
return this;
}
}
// Add new point at the beginning (most recent first)
final updatedHistory = [newPoint, ...advertHistory];
// Keep only the most recent 100 points
final trimmedHistory = updatedHistory.length > 100
? updatedHistory.sublist(0, 100)
: updatedHistory;
return copyWith(advertHistory: trimmedHistory);
}
/// Calculate distance between two points in meters (Haversine formula)
double _calculateDistance(LatLng point1, LatLng point2) {
const double earthRadius = 6371000; // meters
final lat1 = point1.latitude * (pi / 180);
final lat2 = point2.latitude * (pi / 180);
final dLat = (point2.latitude - point1.latitude) * (pi / 180);
final dLon = (point2.longitude - point1.longitude) * (pi / 180);
final a = sin(dLat / 2) * sin(dLat / 2) +
cos(lat1) * cos(lat2) *
sin(dLon / 2) * sin(dLon / 2);
final c = 2 * atan2(sqrt(a), sqrt(1 - a));
return earthRadius * c;
}
Contact copyWith({
Uint8List? publicKey,
ContactType? type,
@@ -254,6 +303,7 @@ class Contact {
int? advLon,
int? lastMod,
ContactTelemetry? telemetry,
List<AdvertLocation>? advertHistory,
bool? isNew,
}) {
return Contact(
@@ -268,6 +318,7 @@ class Contact {
advLon: advLon ?? this.advLon,
lastMod: lastMod ?? this.lastMod,
telemetry: telemetry ?? this.telemetry,
advertHistory: advertHistory ?? this.advertHistory,
isNew: isNew ?? this.isNew,
);
}

View File

@@ -128,15 +128,32 @@ class ContactsProvider with ChangeNotifier {
// Check if this is a new contact
final isNewContact = !_contacts.containsKey(contact.publicKeyHex);
// If it's a new contact, mark it as new
Contact updatedContact;
if (isNewContact) {
_contacts[contact.publicKeyHex] = contact.copyWith(isNew: true);
// New contact - add initial location to history if available
updatedContact = contact.copyWith(isNew: true);
if (contact.advertLocation != null) {
final timestamp = DateTime.fromMillisecondsSinceEpoch(contact.lastAdvert * 1000);
updatedContact = updatedContact.addAdvertLocation(contact.advertLocation!, timestamp);
}
} else {
// Keep existing isNew status when updating
// Existing contact - preserve history and isNew status
final existingContact = _contacts[contact.publicKeyHex]!;
_contacts[contact.publicKeyHex] = contact.copyWith(isNew: existingContact.isNew);
// Start with existing contact
updatedContact = contact.copyWith(
isNew: existingContact.isNew,
advertHistory: existingContact.advertHistory,
);
// Add new location to history if location has changed
if (contact.advertLocation != null) {
final timestamp = DateTime.fromMillisecondsSinceEpoch(contact.lastAdvert * 1000);
updatedContact = updatedContact.addAdvertLocation(contact.advertLocation!, timestamp);
}
}
_contacts[contact.publicKeyHex] = updatedContact;
_persistContacts();
notifyListeners();
}

View File

@@ -6,9 +6,13 @@ class MapProvider with ChangeNotifier {
double? _targetZoom;
bool _shouldAnimate = false;
// Track which contact paths are currently visible
final Set<String> _visibleContactPaths = {};
LatLng? get targetLocation => _targetLocation;
double? get targetZoom => _targetZoom;
bool get shouldAnimate => _shouldAnimate;
Set<String> get visibleContactPaths => Set.unmodifiable(_visibleContactPaths);
void navigateToLocation({
required LatLng location,
@@ -32,4 +36,32 @@ class MapProvider with ChangeNotifier {
_targetZoom = zoom;
notifyListeners();
}
/// Toggle path visibility for a contact
void toggleContactPath(String publicKeyHex) {
if (_visibleContactPaths.contains(publicKeyHex)) {
_visibleContactPaths.remove(publicKeyHex);
} else {
_visibleContactPaths.add(publicKeyHex);
}
notifyListeners();
}
/// Check if a contact's path is visible
bool isContactPathVisible(String publicKeyHex) {
return _visibleContactPaths.contains(publicKeyHex);
}
/// Hide all contact paths
void hideAllPaths() {
_visibleContactPaths.clear();
notifyListeners();
}
/// Show path for specific contact (hide all others)
void showOnlyPath(String publicKeyHex) {
_visibleContactPaths.clear();
_visibleContactPaths.add(publicKeyHex);
notifyListeners();
}
}

View File

@@ -887,6 +887,28 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
userAgentPackageName: 'com.meshcore.sar',
maxZoom: _currentLayer.maxZoom,
),
// Advertisement path polylines (rendered before markers)
Consumer<MapProvider>(
builder: (context, mapProvider, _) {
return PolylineLayer(
polylines: contactsWithLocation
.where((contact) => mapProvider.isContactPathVisible(contact.publicKeyHex))
.where((contact) => contact.advertHistory.length >= 2)
.map((contact) {
return Polyline(
points: contact.advertHistory
.map((advert) => advert.location)
.toList(),
color: Colors.blue.withValues(alpha: 0.7),
strokeWidth: 3.0,
borderColor: Colors.white.withValues(alpha: 0.5),
borderStrokeWidth: 1.0,
);
})
.toList(),
);
},
),
MarkerLayer(
markers: [
// Contact markers

View File

@@ -4,8 +4,10 @@ import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import 'package:flutter_compass/flutter_compass.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import '../../models/contact.dart';
import '../../models/sar_marker.dart';
import '../../providers/map_provider.dart';
import '../common/location_display.dart';
import 'compass/compass_header.dart';
import 'compass/compass_filters.dart';
@@ -605,6 +607,26 @@ class _DetailedCompassDialogState extends State<DetailedCompassDialog> {
],
),
),
// Show path toggle button for contacts with history
if (_selectedContact != null && _selectedContact!.advertHistory.length >= 2)
Consumer<MapProvider>(
builder: (context, mapProvider, _) {
final isPathVisible = mapProvider.isContactPathVisible(_selectedContact!.publicKeyHex);
return IconButton(
icon: Icon(
isPathVisible ? Icons.route : Icons.route_outlined,
size: 20,
color: isPathVisible ? Theme.of(context).colorScheme.primary : null,
),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
tooltip: isPathVisible ? 'Hide path' : 'Show path (${_selectedContact!.advertHistory.length} points)',
onPressed: () {
mapProvider.toggleContactPath(_selectedContact!.publicKeyHex);
},
);
},
),
IconButton(
icon: const Icon(Icons.close, size: 20),
padding: EdgeInsets.zero,