diff --git a/ADVERT_PATH_TRACKING.md b/ADVERT_PATH_TRACKING.md new file mode 100644 index 0000000..95c31ef --- /dev/null +++ b/ADVERT_PATH_TRACKING.md @@ -0,0 +1,257 @@ +# Advertisement Path Tracking Implementation + +## Overview +Contacts now automatically track their advertisement location history, allowing visualization of movement paths on the map. + +## Implementation Details + +### 1. Data Model (lib/models/) + +#### AdvertLocation Model +**File**: `lib/models/advert_location.dart` + +- Stores a single GPS point with timestamp +- Provides `timeAgo` display formatting +- Immutable value object with proper equality implementation + +#### Contact Model Updates +**File**: `lib/models/contact.dart` + +**New fields:** +- `advertHistory: List` - Stores up to 100 most recent location points + +**New methods:** +- `addAdvertLocation(LatLng, DateTime)` - Intelligently adds location to history + - Deduplicates points (skips if <5m apart and <60s interval) + - Maintains max 100 points (oldest removed first) + - Uses Haversine formula for distance calculation + +### 2. State Management (lib/providers/) + +#### ContactsProvider Updates +**File**: `lib/providers/contacts_provider.dart` + +**Modified**: `addOrUpdateContact()` method +- Automatically adds advertisement location to history when contact is updated +- Preserves existing history when updating contacts +- Timestamp extracted from `lastAdvert` field (Unix seconds) + +#### MapProvider Updates +**File**: `lib/providers/map_provider.dart` + +**New state:** +- `_visibleContactPaths: Set` - Tracks which paths are currently visible + +**New methods:** +- `toggleContactPath(publicKeyHex)` - Show/hide path for specific contact +- `isContactPathVisible(publicKeyHex)` - Check path visibility +- `hideAllPaths()` - Clear all visible paths +- `showOnlyPath(publicKeyHex)` - Isolate single contact's path + +### 3. Map Rendering (lib/screens/) + +#### MapTab Updates +**File**: `lib/screens/map_tab.dart` + +**Added**: `PolylineLayer` before `MarkerLayer` +- Renders blue polylines with white borders (3px stroke + 1px border) +- Only renders paths for contacts marked as visible in MapProvider +- Only renders if contact has ≥2 location points +- Uses `Consumer` to reactively update when visibility changes + +**Visual styling:** +- Color: `Colors.blue` with 70% opacity +- Border: `Colors.white` with 50% opacity +- Stroke width: 3px (main), 1px (border) + +### 4. User Interface (lib/widgets/) + +#### DetailedCompassDialog Updates +**File**: `lib/widgets/map/detailed_compass_dialog.dart` + +**Added**: Path toggle button in contact detail view +- Only visible when contact has ≥2 location points +- Shows route icon (filled when active, outlined when inactive) +- Tooltip displays current state and point count +- Primary color when path is visible +- Uses `Consumer` for reactivity + +**User flow:** +1. Tap contact marker on map → opens detailed compass dialog +2. If contact has movement history (≥2 points), path toggle button appears +3. Tap button → path appears on map as blue polyline +4. Tap again → path disappears + +### 5. Data Flow + +``` +BLE Device → PUSH_CODE_NEW_ADVERT (0x8A) + ↓ +FrameParser.parseContact() + ↓ +ContactsProvider.addOrUpdateContact() + ↓ +Contact.addAdvertLocation() [auto-deduplication] + ↓ +advertHistory updated (max 100 points) + ↓ +MapTab.PolylineLayer renders if path visible +``` + +### 6. Storage Behavior + +**Persistence**: Advertisement history is persisted via `ContactStorageService` +- Uses `contact_storage.json` in app documents directory +- Automatically saved when contacts are updated +- Loaded on app startup + +**Capacity**: Each contact stores up to 100 location points +- Oldest points automatically removed when limit reached +- Prevents unbounded memory growth + +## Usage Example + +1. **Automatic tracking** - No user action required: + ``` + Contact broadcasts location → History automatically updated + ``` + +2. **View path on map**: + ``` + Tap contact marker → Path toggle button → Tap to show → Blue line appears + ``` + +3. **Multiple paths**: + ``` + Each contact has independent path visibility + Can show multiple paths simultaneously + ``` + +## Performance Considerations + +1. **Deduplication**: Prevents excessive data accumulation for stationary contacts + - Skip if <5 meters apart AND <60 seconds interval + +2. **Bounded history**: Max 100 points per contact + - Typical SAR operation: 1 point/minute × 8 hours = 480 points (trimmed to 100) + +3. **Conditional rendering**: Polylines only rendered when: + - Path visibility enabled via MapProvider + - Contact has ≥2 location points + +4. **Memory efficiency**: + - Each AdvertLocation: ~48 bytes (2 doubles + DateTime) + - Max per contact: 100 × 48 = ~4.8KB + - 50 contacts: ~240KB total + +## Future: GPX Export (Not Implemented) + +### Rationale for Deferring +GPX export was intentionally NOT implemented in this iteration to: +1. Validate path tracking UX first +2. Gather user feedback on data granularity needs +3. Determine preferred export formats (GPX vs KML vs GeoJSON) + +### Implementation Considerations + +When implementing GPX export, consider: + +1. **Track segmentation**: + ```xml + + Contact Name - YYYY-MM-DD + + + + + + + + ``` + +2. **Metadata**: + - Contact name + - Date range of track + - Device type (from contact telemetry) + - Total distance traveled + - Duration + +3. **Gap handling**: + - Break into segments if gap >15 minutes between points + - Prevents drawing straight lines across large time gaps + +4. **Multi-contact export**: + - Option to export all visible paths as separate tracks + - Single GPX file with multiple `` elements + +5. **UI integration**: + - Add "Export Path" button in detailed compass dialog + - Share sheet for exporting GPX file + - Option to select date range + +### Recommended Package +```yaml +dependencies: + gpx: ^2.2.0 # GPX file generation and parsing +``` + +### Sample Implementation (Future) +```dart +import 'package:gpx/gpx.dart'; + +String exportContactPathToGpx(Contact contact) { + final gpx = Gpx(); + gpx.creator = 'MeshCore SAR'; + + final track = Trk(); + track.name = '${contact.advName} - ${DateTime.now().toIso8601String()}'; + + final segment = Trkseg(); + for (final point in contact.advertHistory.reversed) { + segment.trkpts.add(Wpt( + lat: point.location.latitude, + lon: point.location.longitude, + time: point.timestamp, + )); + } + + track.trksegs.add(segment); + gpx.trks.add(track); + + return GpxWriter().asString(gpx, pretty: true); +} +``` + +## Testing Checklist + +- [x] Advertisement locations automatically tracked when contact updates +- [x] Deduplication prevents duplicate points for stationary contacts +- [x] History limited to 100 points per contact +- [x] Path toggle button appears only when ≥2 points exist +- [x] Polyline renders correctly on map +- [x] Path visibility persists across dialog open/close +- [x] Multiple contact paths can be visible simultaneously +- [ ] GPX export (deferred to future iteration) + +## Known Limitations + +1. **No manual path clearing**: Users cannot manually clear a contact's path history + - Workaround: Path auto-trims to 100 points + +2. **No date range filtering**: Cannot view path for specific time period + - All points always rendered (up to 100) + +3. **No distance/duration display**: Path metadata not calculated + - Future enhancement: Show "Total: 2.4km over 3h" + +## Migration Notes + +**Existing contacts**: No migration required +- Existing contacts start with empty `advertHistory` +- History begins accumulating from first update after app upgrade +- No data loss or corruption risk + +**Storage format**: JSON-compatible +- `advertHistory` serialized as array of objects +- Standard DateTime ISO-8601 strings +- LatLng as lat/lon decimal degrees diff --git a/lib/models/advert_location.dart b/lib/models/advert_location.dart new file mode 100644 index 0000000..79c814a --- /dev/null +++ b/lib/models/advert_location.dart @@ -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); +} diff --git a/lib/models/contact.dart b/lib/models/contact.dart index 23de813..3f94a42 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -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 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? 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? 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, ); } diff --git a/lib/providers/contacts_provider.dart b/lib/providers/contacts_provider.dart index dd09e7d..abaf7c1 100644 --- a/lib/providers/contacts_provider.dart +++ b/lib/providers/contacts_provider.dart @@ -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(); } diff --git a/lib/providers/map_provider.dart b/lib/providers/map_provider.dart index da16be0..3f0e67b 100644 --- a/lib/providers/map_provider.dart +++ b/lib/providers/map_provider.dart @@ -6,9 +6,13 @@ class MapProvider with ChangeNotifier { double? _targetZoom; bool _shouldAnimate = false; + // Track which contact paths are currently visible + final Set _visibleContactPaths = {}; + LatLng? get targetLocation => _targetLocation; double? get targetZoom => _targetZoom; bool get shouldAnimate => _shouldAnimate; + Set 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(); + } } diff --git a/lib/screens/map_tab.dart b/lib/screens/map_tab.dart index b8762ca..e2ed316 100644 --- a/lib/screens/map_tab.dart +++ b/lib/screens/map_tab.dart @@ -887,6 +887,28 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { userAgentPackageName: 'com.meshcore.sar', maxZoom: _currentLayer.maxZoom, ), + // Advertisement path polylines (rendered before markers) + Consumer( + 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 diff --git a/lib/widgets/map/detailed_compass_dialog.dart b/lib/widgets/map/detailed_compass_dialog.dart index 2bea8c5..5719061 100644 --- a/lib/widgets/map/detailed_compass_dialog.dart +++ b/lib/widgets/map/detailed_compass_dialog.dart @@ -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 { ], ), ), + // Show path toggle button for contacts with history + if (_selectedContact != null && _selectedContact!.advertHistory.length >= 2) + Consumer( + 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,