diff --git a/lib/models/message_contact_location.dart b/lib/models/message_contact_location.dart new file mode 100644 index 0000000..933e26d --- /dev/null +++ b/lib/models/message_contact_location.dart @@ -0,0 +1,63 @@ +import 'package:latlong2/latlong.dart'; + +class MessageContactLocation { + final LatLng location; + final String source; + final DateTime capturedAt; + final DateTime? sourceTimestamp; + + const MessageContactLocation({ + required this.location, + required this.source, + required this.capturedAt, + this.sourceTimestamp, + }); + + String get technicalSourceLabel { + switch (source) { + case 'telemetry': + return 'telemetry'; + case 'advert': + return 'advert'; + default: + return source; + } + } + + String get formattedCoordinates => + '${location.latitude.toStringAsFixed(6)}, ${location.longitude.toStringAsFixed(6)}'; + + Map toJson() { + return { + 'latitude': location.latitude, + 'longitude': location.longitude, + 'source': source, + 'capturedAtMillis': capturedAt.millisecondsSinceEpoch, + 'sourceTimestampMillis': sourceTimestamp?.millisecondsSinceEpoch, + }; + } + + static MessageContactLocation? fromJson(Map json) { + final latitude = json['latitude']; + final longitude = json['longitude']; + final source = json['source']; + final capturedAtMillis = json['capturedAtMillis']; + if (latitude is! num || + longitude is! num || + source is! String || + capturedAtMillis is! int) { + return null; + } + + return MessageContactLocation( + location: LatLng(latitude.toDouble(), longitude.toDouble()), + source: source, + capturedAt: DateTime.fromMillisecondsSinceEpoch(capturedAtMillis), + sourceTimestamp: json['sourceTimestampMillis'] is int + ? DateTime.fromMillisecondsSinceEpoch( + json['sourceTimestampMillis'] as int, + ) + : null, + ); + } +} diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 672edbc..6dedfe4 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -607,14 +607,30 @@ class AppProvider with ChangeNotifier { connectionProvider.onMessageReceived = (message) { // Enrich message with sender name from contacts first Message enrichedMessage = message; + Contact? senderContact; if (message.senderPublicKeyPrefix != null && message.senderName == null) { final contact = contactsProvider.findContactByKey( message.senderPublicKeyPrefix!, ); if (contact != null) { + senderContact = contact; enrichedMessage = message.copyWith(senderName: contact.advName); } } + senderContact ??= message.senderPublicKeyPrefix != null + ? contactsProvider.findContactByKey(message.senderPublicKeyPrefix!) + : null; + senderContact ??= enrichedMessage.senderName != null + ? contactsProvider.contacts + .where((c) => c.advName == enrichedMessage.senderName) + .firstOrNull + : null; + final contactLocationSnapshot = senderContact != null + ? contactsProvider.buildMessageContactLocationSnapshot( + senderContact, + capturedAt: enrichedMessage.receivedAt, + ) + : null; // Check if message is a drawing broadcast if (DrawingMessageParser.isDrawingMessage(enrichedMessage.text)) { @@ -645,6 +661,7 @@ class AppProvider with ChangeNotifier { messagesProvider.addMessage( updatedMessage, contactLookup: (name) => '', + contactLocationSnapshot: contactLocationSnapshot, ); // Broadcast drawing message to SSE clients if server is running @@ -680,6 +697,7 @@ class AppProvider with ChangeNotifier { return ''; } }, + contactLocationSnapshot: contactLocationSnapshot, ); connectionProvider.broadcastMessageToSseClients(enrichedMessage); return; @@ -707,6 +725,7 @@ class AppProvider with ChangeNotifier { return ''; } }, + contactLocationSnapshot: contactLocationSnapshot, ); connectionProvider.broadcastMessageToSseClients(enrichedMessage); return; @@ -743,6 +762,7 @@ class AppProvider with ChangeNotifier { return ''; } }, + contactLocationSnapshot: contactLocationSnapshot, ); // Broadcast message to SSE clients if server is running diff --git a/lib/providers/contacts_provider.dart b/lib/providers/contacts_provider.dart index 3618f8f..23a37aa 100644 --- a/lib/providers/contacts_provider.dart +++ b/lib/providers/contacts_provider.dart @@ -1,6 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:latlong2/latlong.dart'; import '../models/contact.dart'; +import '../models/message_contact_location.dart'; import '../services/cayenne_lpp_parser.dart'; import '../services/contact_storage_service.dart'; import '../utils/key_comparison.dart'; @@ -214,6 +215,52 @@ class ContactsProvider with ChangeNotifier { List get chatContactsWithLocation => chatContacts.where((c) => c.displayLocation != null).toList(); + MessageContactLocation? buildMessageContactLocationSnapshot( + Contact contact, { + DateTime? capturedAt, + }) { + final snapshotTime = capturedAt ?? DateTime.now(); + final telemetryGps = _getValidGpsOrNull(contact.telemetry?.gpsLocation); + final telemetryTimestamp = contact.telemetry?.timestamp; + + AdvertLocation? advertLocation; + for (final point in contact.advertHistory) { + if (!point.timestamp.isAfter(snapshotTime)) { + advertLocation = point; + break; + } + } + advertLocation ??= contact.advertHistory.isNotEmpty + ? contact.advertHistory.first + : null; + + if (telemetryGps != null) { + final shouldUseTelemetry = + telemetryTimestamp == null || + advertLocation == null || + !telemetryTimestamp.isBefore(advertLocation.timestamp); + if (shouldUseTelemetry) { + return MessageContactLocation( + location: telemetryGps, + source: 'telemetry', + capturedAt: snapshotTime, + sourceTimestamp: telemetryTimestamp, + ); + } + } + + if (advertLocation != null) { + return MessageContactLocation( + location: advertLocation.location, + source: 'advert', + capturedAt: snapshotTime, + sourceTimestamp: advertLocation.timestamp, + ); + } + + return null; + } + /// Sort contacts by last seen (most recent first) int _sortByLastSeen(Contact a, Contact b) { return b.lastSeenTime.compareTo(a.lastSeenTime); diff --git a/lib/providers/messages_provider.dart b/lib/providers/messages_provider.dart index 7992a0b..0a1fd09 100644 --- a/lib/providers/messages_provider.dart +++ b/lib/providers/messages_provider.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import '../models/message.dart'; import '../models/contact.dart'; +import '../models/message_contact_location.dart'; import '../models/sar_marker.dart'; import '../models/map_drawing.dart'; import '../services/message_storage_service.dart'; @@ -20,6 +21,7 @@ class MessagesProvider with ChangeNotifier { final NotificationService _notificationService = NotificationService(); bool _isInitialized = false; AppLocalizations? _localizations; + final Map _messageContactLocations = {}; // Track pending sent messages by expected ACK/TAG final Map _pendingSentMessages = {}; @@ -104,6 +106,9 @@ class MessagesProvider with ChangeNotifier { String? get targetMessageId => _targetMessageId; + MessageContactLocation? getMessageContactLocation(String messageId) => + _messageContactLocations[messageId]; + /// Set localizations for notifications void setLocalizations(AppLocalizations localizations) { _localizations = localizations; @@ -132,6 +137,11 @@ class MessagesProvider with ChangeNotifier { try { debugPrint('📦 [MessagesProvider] Loading persisted messages...'); final storedMessages = await _storageService.loadMessages(); + final storedContactLocations = await _storageService + .loadMessageContactLocations(); + _messageContactLocations + ..clear() + ..addAll(storedContactLocations); // Add stored messages with enhancement to ensure SAR detection for (final message in storedMessages) { @@ -294,6 +304,7 @@ class MessagesProvider with ChangeNotifier { void addMessage( Message message, { String Function(String name)? contactLookup, + MessageContactLocation? contactLocationSnapshot, }) { // Always enhance message with SAR parser to detect SAR markers var enhancedMessage = SarMessageParser.enhanceMessage(message); @@ -384,6 +395,9 @@ class MessagesProvider with ChangeNotifier { } _messages.add(finalMessage); + if (contactLocationSnapshot != null) { + _messageContactLocations[finalMessage.id] = contactLocationSnapshot; + } // If it's a SAR marker message, extract and store the marker if (finalMessage.isSarMarker) { @@ -570,7 +584,10 @@ class MessagesProvider with ChangeNotifier { /// Persist messages to storage (async, non-blocking) Future _persistMessages() async { try { - await _storageService.saveMessages(_messages); + await _storageService.saveMessages( + _messages, + messageContactLocations: _messageContactLocations, + ); } catch (e) { debugPrint('❌ [MessagesProvider] Error persisting messages: $e'); } @@ -685,6 +702,7 @@ class MessagesProvider with ChangeNotifier { } _messageContactMap.remove(messageId); _groupedMessageMapping.remove(messageId); + _messageContactLocations.remove(messageId); debugPrint('🗑️ [MessagesProvider] Message $messageId deleted'); @@ -713,6 +731,7 @@ class MessagesProvider with ChangeNotifier { void clearMessages() { _messages.clear(); _sarMarkers.clear(); + _messageContactLocations.clear(); _persistMessages(); notifyListeners(); } @@ -727,6 +746,7 @@ class MessagesProvider with ChangeNotifier { void clearAll() { _messages.clear(); _sarMarkers.clear(); + _messageContactLocations.clear(); _persistMessages(); notifyListeners(); } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 2225dc4..15d282c 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -403,25 +403,17 @@ class _SettingsScreenState extends State { channelCount: 2, ); - final sarMessages = SampleDataGenerator.generateSarMarkerMessages( + final sampleMessages = SampleDataGenerator.generateAllMessages( centerLocation: centerLocation, l10n: l10n, foundPersonCount: 2, fireCount: 1, stagingCount: 1, objectCount: 1, - ); - - final channelMessages = SampleDataGenerator.generateChannelMessages( - centerLocation: centerLocation, - l10n: l10n, generalChannelMessages: 8, emergencyChannelMessages: 5, ); - // Combine all messages - final allMessages = [...sarMessages, ...channelMessages]; - // Add to providers final contactsProvider = Provider.of( context, @@ -433,7 +425,12 @@ class _SettingsScreenState extends State { ); contactsProvider.addContacts(contacts); - messagesProvider.addMessages(allMessages); + for (final message in sampleMessages.messages) { + messagesProvider.addMessage( + message, + contactLocationSnapshot: sampleMessages.contactLocations[message.id], + ); + } if (!mounted) return; @@ -446,8 +443,8 @@ class _SettingsScreenState extends State { AppLocalizations.of(context)!.loadedSampleData( teamCount, channelCount, - sarMessages.length, - channelMessages.length, + sampleMessages.messages.where((m) => m.isSarMarker).length, + sampleMessages.messages.length, ), ), backgroundColor: Colors.green, diff --git a/lib/services/mesh_map_nodes_service.dart b/lib/services/mesh_map_nodes_service.dart index ed4f81b..4b5cccb 100644 --- a/lib/services/mesh_map_nodes_service.dart +++ b/lib/services/mesh_map_nodes_service.dart @@ -31,17 +31,22 @@ class MeshMapNode { } class MeshMapNodesService { - static const String _nodesEndpoint = 'https://api.meshcore.nz/api/v1/map/nodes'; + static const String _nodesEndpoint = + 'https://api.meshcore.nz/api/v1/map/nodes'; static const Duration _cacheTtl = Duration(minutes: 2); + static const Duration traceCacheTtl = Duration(minutes: 10); static List? _cachedNodes; static DateTime? _cachedAt; - static Future> fetchNodes({bool forceRefresh = false}) async { + static Future> fetchNodes({ + bool forceRefresh = false, + Duration cacheTtl = _cacheTtl, + }) async { final now = DateTime.now(); if (!forceRefresh && _cachedNodes != null && _cachedAt != null && - now.difference(_cachedAt!) < _cacheTtl) { + now.difference(_cachedAt!) < cacheTtl) { return _cachedNodes!; } @@ -58,7 +63,9 @@ class MeshMapNodesService { final nodes = nodesRaw .whereType>() .map(MeshMapNode.fromJson) - .where((n) => n.publicKey.isNotEmpty && n.latitude != 0 && n.longitude != 0) + .where( + (n) => n.publicKey.isNotEmpty && n.latitude != 0 && n.longitude != 0, + ) .toList(); _cachedNodes = nodes; diff --git a/lib/services/message_storage_service.dart b/lib/services/message_storage_service.dart index 5f02dcc..04a2728 100644 --- a/lib/services/message_storage_service.dart +++ b/lib/services/message_storage_service.dart @@ -2,15 +2,21 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../models/message.dart'; +import '../models/message_contact_location.dart'; import 'package:latlong2/latlong.dart'; /// Service for persisting messages to local storage class MessageStorageService { static const String _messagesKey = 'stored_messages'; + static const String _messageContactLocationsKey = + 'stored_message_contact_locations'; static const int _maxStoredMessages = 1000; // Store up to 1000 messages /// Save messages to persistent storage - Future saveMessages(List messages) async { + Future saveMessages( + List messages, { + Map messageContactLocations = const {}, + }) async { try { final prefs = await SharedPreferences.getInstance(); @@ -24,6 +30,19 @@ class MessageStorageService { final jsonString = jsonEncode(limitedList); await prefs.setString(_messagesKey, jsonString); + final retainedMessageIds = limitedList + .map((entry) => entry['id'] as String) + .toSet(); + final locationJson = {}; + for (final entry in messageContactLocations.entries) { + if (retainedMessageIds.contains(entry.key)) { + locationJson[entry.key] = entry.value.toJson(); + } + } + await prefs.setString( + _messageContactLocationsKey, + jsonEncode(locationJson), + ); debugPrint( '✅ [MessageStorage] Saved ${limitedList.length} messages to storage', @@ -33,6 +52,36 @@ class MessageStorageService { } } + Future> loadMessageContactLocations() + async { + try { + final prefs = await SharedPreferences.getInstance(); + final jsonString = prefs.getString(_messageContactLocationsKey); + if (jsonString == null || jsonString.isEmpty) { + return const {}; + } + + final decoded = jsonDecode(jsonString); + if (decoded is! Map) { + return const {}; + } + + final result = {}; + for (final entry in decoded.entries) { + final value = entry.value; + if (value is! Map) continue; + final snapshot = MessageContactLocation.fromJson(value); + if (snapshot != null) { + result[entry.key] = snapshot; + } + } + return result; + } catch (e) { + debugPrint('❌ [MessageStorage] Error loading contact locations: $e'); + return const {}; + } + } + /// Load messages from persistent storage Future> loadMessages() async { try { @@ -66,6 +115,7 @@ class MessageStorageService { try { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_messagesKey); + await prefs.remove(_messageContactLocationsKey); debugPrint('✅ [MessageStorage] Cleared all stored messages'); } catch (e) { debugPrint('❌ [MessageStorage] Error clearing messages: $e'); diff --git a/lib/utils/sample_data_generator.dart b/lib/utils/sample_data_generator.dart index 52eb12d..e8defb8 100644 --- a/lib/utils/sample_data_generator.dart +++ b/lib/utils/sample_data_generator.dart @@ -4,12 +4,66 @@ import 'package:flutter/material.dart'; import 'package:latlong2/latlong.dart'; import '../models/contact.dart'; import '../models/message.dart'; +import '../models/message_contact_location.dart'; import '../l10n/app_localizations.dart'; /// Generates sample data for testing/demo purposes class SampleDataGenerator { static final Random _random = Random(); + static MessageContactLocation _sampleSnapshot({ + required LatLng location, + required DateTime receivedAt, + required String source, + }) { + return MessageContactLocation( + location: location, + source: source, + capturedAt: receivedAt, + sourceTimestamp: receivedAt.subtract(const Duration(minutes: 2)), + ); + } + + static LatLng _randomNearbyLocation(LatLng center, double spread) { + final latOffset = (_random.nextDouble() - 0.5) * spread; + final lonOffset = (_random.nextDouble() - 0.5) * spread; + return LatLng(center.latitude + latOffset, center.longitude + lonOffset); + } + + static SampleMessageBatch generateAllMessages({ + required LatLng centerLocation, + required AppLocalizations l10n, + int foundPersonCount = 2, + int fireCount = 1, + int stagingCount = 1, + int objectCount = 1, + int generalChannelMessages = 8, + int emergencyChannelMessages = 5, + }) { + final sarBatch = generateSarMarkerMessages( + centerLocation: centerLocation, + l10n: l10n, + foundPersonCount: foundPersonCount, + fireCount: fireCount, + stagingCount: stagingCount, + objectCount: objectCount, + ); + final channelBatch = generateChannelMessages( + centerLocation: centerLocation, + l10n: l10n, + generalChannelMessages: generalChannelMessages, + emergencyChannelMessages: emergencyChannelMessages, + ); + + return SampleMessageBatch( + messages: [...sarBatch.messages, ...channelBatch.messages], + contactLocations: { + ...sarBatch.contactLocations, + ...channelBatch.contactLocations, + }, + ); + } + /// Generate sample contacts around a center location static List generateContacts({ required LatLng centerLocation, @@ -113,7 +167,7 @@ class SampleDataGenerator { } /// Generate sample SAR markers around a center location - static List generateSarMarkerMessages({ + static SampleMessageBatch generateSarMarkerMessages({ required LatLng centerLocation, required AppLocalizations l10n, int foundPersonCount = 2, @@ -122,6 +176,7 @@ class SampleDataGenerator { int objectCount = 1, }) { final messages = []; + final contactLocations = {}; final now = DateTime.now(); int messageId = 1; @@ -137,7 +192,7 @@ class SampleDataGenerator { ); final timestamp = now.subtract(Duration(minutes: 10 + i * 5)); - messages.add(Message( + final message = Message( id: 'sample_fp_$messageId', messageType: MessageType.contact, senderPublicKeyPrefix: senderKey.sublist(0, 6), @@ -150,7 +205,13 @@ class SampleDataGenerator { sarGpsCoordinates: LatLng(lat, lon), sarCustomEmoji: '🧑', senderName: l10n.sampleTeamMember, - )); + ); + messages.add(message); + contactLocations[message.id] = _sampleSnapshot( + location: _randomNearbyLocation(message.sarGpsCoordinates!, 0.006), + receivedAt: timestamp, + source: 'telemetry', + ); messageId++; } @@ -166,7 +227,7 @@ class SampleDataGenerator { ); final timestamp = now.subtract(Duration(minutes: 20 + i * 5)); - messages.add(Message( + final message = Message( id: 'sample_fire_$messageId', messageType: MessageType.contact, senderPublicKeyPrefix: senderKey.sublist(0, 6), @@ -179,7 +240,13 @@ class SampleDataGenerator { sarGpsCoordinates: LatLng(lat, lon), sarCustomEmoji: '🔥', senderName: l10n.sampleScout, - )); + ); + messages.add(message); + contactLocations[message.id] = _sampleSnapshot( + location: _randomNearbyLocation(message.sarGpsCoordinates!, 0.008), + receivedAt: timestamp, + source: 'advert', + ); messageId++; } @@ -195,7 +262,7 @@ class SampleDataGenerator { ); final timestamp = now.subtract(Duration(minutes: 30 + i * 5)); - messages.add(Message( + final message = Message( id: 'sample_staging_$messageId', messageType: MessageType.contact, senderPublicKeyPrefix: senderKey.sublist(0, 6), @@ -208,7 +275,13 @@ class SampleDataGenerator { sarGpsCoordinates: LatLng(lat, lon), sarCustomEmoji: '🏕️', senderName: l10n.sampleBase, - )); + ); + messages.add(message); + contactLocations[message.id] = _sampleSnapshot( + location: _randomNearbyLocation(message.sarGpsCoordinates!, 0.01), + receivedAt: timestamp, + source: 'advert', + ); messageId++; } @@ -231,24 +304,34 @@ class SampleDataGenerator { l10n.sampleObjectTrailMarker, ]; - messages.add(Message( + final message = Message( id: 'sample_object_$messageId', messageType: MessageType.contact, senderPublicKeyPrefix: senderKey.sublist(0, 6), pathLen: 1, textType: MessageTextType.plain, senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000, - text: 'S:📦:${lat.toStringAsFixed(5)},${lon.toStringAsFixed(5)}${notes[i % notes.length]}', + text: + 'S:📦:${lat.toStringAsFixed(5)},${lon.toStringAsFixed(5)}${notes[i % notes.length]}', receivedAt: timestamp, isSarMarker: true, sarGpsCoordinates: LatLng(lat, lon), sarCustomEmoji: '📦', senderName: l10n.sampleSearcher, - )); + ); + messages.add(message); + contactLocations[message.id] = _sampleSnapshot( + location: _randomNearbyLocation(message.sarGpsCoordinates!, 0.007), + receivedAt: timestamp, + source: 'telemetry', + ); messageId++; } - return messages; + return SampleMessageBatch( + messages: messages, + contactLocations: contactLocations, + ); } /// Generate sample map drawings @@ -272,7 +355,9 @@ class SampleDataGenerator { 'id': 'sample_line_${now.millisecondsSinceEpoch}', 'color': Colors.blue.toARGB32(), 'createdAt': now.subtract(const Duration(minutes: 15)).toIso8601String(), - 'points': linePoints.map((p) => {'lat': p.latitude, 'lon': p.longitude}).toList(), + 'points': linePoints + .map((p) => {'lat': p.latitude, 'lon': p.longitude}) + .toList(), 'sender': l10n.sampleTeamMember, }); @@ -297,7 +382,7 @@ class SampleDataGenerator { } /// Generate sample channel messages for public channels - static List generateChannelMessages({ + static SampleMessageBatch generateChannelMessages({ LatLng? centerLocation, required AppLocalizations l10n, int generalChannelMessages = 8, @@ -306,6 +391,7 @@ class SampleDataGenerator { // Use provided location or default to Ljubljana, Slovenia final center = centerLocation ?? const LatLng(46.0569, 14.5058); final messages = []; + final contactLocations = {}; final now = DateTime.now(); int messageId = 1000; // Start with high ID to avoid conflicts @@ -352,7 +438,11 @@ class SampleDataGenerator { ]; // Generate General channel messages - for (int i = 0; i < generalChannelMessages && i < generalMessages.length; i++) { + for ( + int i = 0; + i < generalChannelMessages && i < generalMessages.length; + i++ + ) { final senderKey = Uint8List.fromList( List.generate(32, (_) => _random.nextInt(256)), ); @@ -361,7 +451,7 @@ class SampleDataGenerator { final minutesAgo = 120 - (i * 15) - _random.nextInt(10); final timestamp = now.subtract(Duration(minutes: minutesAgo)); - messages.add(Message( + final message = Message( id: 'sample_general_$messageId', messageType: MessageType.channel, channelIdx: 0, // General channel @@ -372,12 +462,22 @@ class SampleDataGenerator { text: generalMessages[i], receivedAt: timestamp, senderName: teamNames[_random.nextInt(teamNames.length)], - )); + ); + messages.add(message); + contactLocations[message.id] = _sampleSnapshot( + location: _randomNearbyLocation(center, 0.018), + receivedAt: timestamp, + source: i.isEven ? 'telemetry' : 'advert', + ); messageId++; } // Generate Emergency channel messages - for (int i = 0; i < emergencyChannelMessages && i < emergencyMessages.length; i++) { + for ( + int i = 0; + i < emergencyChannelMessages && i < emergencyMessages.length; + i++ + ) { final senderKey = Uint8List.fromList( List.generate(32, (_) => _random.nextInt(256)), ); @@ -386,7 +486,7 @@ class SampleDataGenerator { final minutesAgo = 60 - (i * 10) - _random.nextInt(5); final timestamp = now.subtract(Duration(minutes: minutesAgo)); - messages.add(Message( + final message = Message( id: 'sample_emergency_$messageId', messageType: MessageType.channel, channelIdx: 1, // Emergency channel @@ -397,10 +497,29 @@ class SampleDataGenerator { text: emergencyMessages[i], receivedAt: timestamp, senderName: teamNames[_random.nextInt(teamNames.length)], - )); + ); + messages.add(message); + contactLocations[message.id] = _sampleSnapshot( + location: _randomNearbyLocation(center, 0.012), + receivedAt: timestamp, + source: i.isEven ? 'advert' : 'telemetry', + ); messageId++; } - return messages; + return SampleMessageBatch( + messages: messages, + contactLocations: contactLocations, + ); } } + +class SampleMessageBatch { + final List messages; + final Map contactLocations; + + const SampleMessageBatch({ + required this.messages, + required this.contactLocations, + }); +} diff --git a/lib/widgets/messages/message_bubble.dart b/lib/widgets/messages/message_bubble.dart index f03af56..f2acd3c 100644 --- a/lib/widgets/messages/message_bubble.dart +++ b/lib/widgets/messages/message_bubble.dart @@ -23,8 +23,10 @@ import '../../utils/key_comparison.dart'; import '../../utils/voice_message_parser.dart'; import '../../utils/image_message_parser.dart'; import '../../utils/tictactoe_message_parser.dart'; +import '../../utils/avatar_label_helper.dart'; import '../../l10n/app_localizations.dart'; import '../../utils/message_extensions.dart'; +import '../common/contact_avatar.dart'; import 'voice_message_bubble.dart'; import 'image_message_bubble.dart'; import 'tictactoe_message_bubble.dart'; @@ -62,6 +64,103 @@ class _MessageBubbleState extends State { bool _isExpanded = false; bool _showReceivedStats = false; + Widget _buildHeaderAvatar( + BuildContext context, { + required bool isOwnMessage, + required bool isChannelMessage, + required dynamic senderContact, + required String displayName, + }) { + if (isOwnMessage) { + return CircleAvatar( + radius: 10.5, + backgroundColor: Theme.of(context).colorScheme.primaryContainer, + child: Icon( + Icons.account_circle, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), + ); + } + + if (senderContact is Contact) { + return ContactAvatar( + contact: senderContact, + radius: 10.5, + displayName: displayName, + ); + } + + final background = isChannelMessage + ? Colors.teal.withValues(alpha: 0.16) + : Theme.of(context).colorScheme.surfaceContainerHighest; + final foreground = isChannelMessage + ? Colors.teal.shade800 + : Theme.of(context).colorScheme.onSurfaceVariant; + + return CircleAvatar( + radius: 10.5, + backgroundColor: background, + child: Text( + AvatarLabelHelper.buildLabel(displayName), + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + color: foreground, + letterSpacing: -0.2, + ), + ), + ); + } + + Widget _buildBubbleMetaFooter( + BuildContext context, { + required Message message, + required bool isOwnMessage, + required bool isSarMarker, + }) { + final metaColor = Theme.of( + context, + ).textTheme.labelSmall?.color?.withValues(alpha: 0.68); + + final items = [ + Text( + message.getLocalizedTimeAgo(context), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: metaColor, + fontWeight: FontWeight.w500, + ), + ), + ]; + + if (!isOwnMessage && !isSarMarker && message.pathLen < 255) { + items.addAll([ + Text( + ' • ', + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: metaColor), + ), + Icon(Icons.alt_route, size: 11, color: metaColor), + const SizedBox(width: 3), + Text( + message.pathLen == 0 ? 'direct' : '${message.pathLen}hop', + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: metaColor), + ), + ]); + } + + return Padding( + padding: const EdgeInsets.only(left: 6, right: 6, top: 1, bottom: 18), + child: Align( + alignment: Alignment.centerRight, + child: Row(mainAxisSize: MainAxisSize.min, children: items), + ), + ); + } + @override void didUpdateWidget(MessageBubble oldWidget) { super.didUpdateWidget(oldWidget); @@ -356,6 +455,7 @@ class _MessageBubbleState extends State { final radioSf = connectionProvider.deviceInfo.radioSf; final radioCr = connectionProvider.deviceInfo.radioCr; final contactsProvider = context.read(); + final messagesProvider = context.read(); final voiceProvider = context.read(); final imageProvider = context.read(); final selfPublicKey = connectionProvider.deviceInfo.publicKey; @@ -397,6 +497,10 @@ class _MessageBubbleState extends State { recipientName = recipientContact?.advName; } + final senderLocationSnapshot = messagesProvider.getMessageContactLocation( + widget.message.id, + ); + final envelope = VoiceEnvelope.tryParseText(widget.message.text); final legacyVoicePacket = VoicePacket.tryParseText(widget.message.text); final voiceSession = widget.message.voiceId != null @@ -496,6 +600,9 @@ class _MessageBubbleState extends State { 'Used flood fallback: ${widget.message.usedFloodFallback}', 'Sender key prefix: ${senderPrefixHex ?? '-'}', 'Sender name: ${senderName ?? widget.message.senderName ?? '-'}', + 'Sender location at receipt: ${senderLocationSnapshot?.formattedCoordinates ?? '-'}', + 'Sender location source: ${senderLocationSnapshot?.technicalSourceLabel ?? '-'}', + 'Sender location timestamp: ${senderLocationSnapshot?.sourceTimestamp?.toIso8601String() ?? '-'}', 'Recipient key prefix: ${recipientPrefixHex ?? '-'}', 'Recipient name: ${recipientName ?? '-'}', 'Drawing flag: ${widget.message.isDrawing}', @@ -1850,7 +1957,7 @@ class _MessageBubbleState extends State { ? null : () => _showMessageOptions(context), child: Container( - margin: const EdgeInsets.only(bottom: 8), + margin: EdgeInsets.zero, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( color: widget.isHighlighted @@ -1993,15 +2100,6 @@ class _MessageBubbleState extends State { ], ), ), - const Spacer(), - Text( - message.getLocalizedTimeAgo(context), - style: Theme.of(context).textTheme.labelSmall?.copyWith( - fontWeight: isSarMarker - ? FontWeight.w600 - : FontWeight.normal, - ), - ), ], ), @@ -2023,16 +2121,13 @@ class _MessageBubbleState extends State { shape: BoxShape.circle, ), ), - if (isOwnMessage) - Icon( - Icons.account_circle, - size: 16, - color: Theme.of(context).colorScheme.primary, - ) - else if (message.isChannelMessage) - const Icon(Icons.tag, size: 16) - else - const Icon(Icons.person, size: 16), + _buildHeaderAvatar( + context, + isOwnMessage: isOwnMessage, + isChannelMessage: message.isChannelMessage, + senderContact: senderContact, + displayName: displayName, + ), const SizedBox(width: 4), Expanded( child: Row( @@ -2151,83 +2246,140 @@ class _MessageBubbleState extends State { ], ), ), - // Time for regular messages (not shown for SAR/drawing as it's already above) - if (!isSarMarker && !message.isDrawing) ...[ - const SizedBox(width: 8), - // Hop count indicator for received messages - if (!isOwnMessage && message.pathLen < 255) ...[ - const SizedBox(width: 4), - Icon( - Icons.alt_route, - size: 11, - color: Theme.of( - context, - ).textTheme.labelSmall?.color?.withValues(alpha: 0.6), - ), - const SizedBox(width: 2), - Text( - message.pathLen == 0 - ? 'direct' - : '${message.pathLen}hop', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Theme.of( - context, - ).textTheme.labelSmall?.color?.withValues(alpha: 0.6), - ), - ), - const SizedBox(width: 4), - ], - Text( - message.getLocalizedTimeAgo(context), - style: Theme.of(context).textTheme.labelSmall, - ), - ], ], ), const SizedBox(height: 8), // SAR marker content if (isSarMarker && message.sarMarkerType != null) ...[ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - message.sarCustomEmoji ?? - message.sarMarkerType!.emoji, - style: const TextStyle(fontSize: 32), - ), - const SizedBox(width: 10), - Expanded( - child: Text( - message.sarNotes != null && - message.sarNotes!.isNotEmpty - ? message.sarNotes! - : message.sarMarkerType!.getLocalizedName( + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white.withValues( + alpha: isDarkMode ? 0.06 : 0.42, + ), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: _getSarMarkerBorderColor( + context, + isDarkMode, + ).withValues(alpha: 0.22), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: _getSarMarkerBorderColor( + context, + isDarkMode, + ).withValues(alpha: isDarkMode ? 0.2 : 0.12), + borderRadius: BorderRadius.circular(16), + ), + alignment: Alignment.center, + child: Text( + message.sarCustomEmoji ?? + message.sarMarkerType!.emoji, + style: const TextStyle(fontSize: 30, height: 1), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + message.sarMarkerType!.getLocalizedName( context, ), - style: Theme.of(context).textTheme.titleSmall - ?.copyWith(fontWeight: FontWeight.bold), + style: Theme.of(context).textTheme.titleMedium + ?.copyWith( + fontWeight: FontWeight.w800, + letterSpacing: -0.2, + ), + ), + if (message.sarNotes != null && + message.sarNotes!.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + message.sarNotes!, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + height: 1.25, + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.86), + ), + ), + ], + ], + ), + ), + if (!widget.isCompact) + Padding( + padding: const EdgeInsets.only(left: 8, top: 2), + child: Icon( + Icons.chevron_right_rounded, + size: 20, + color: _getSarMarkerBorderColor( + context, + isDarkMode, + ), + ), + ), + ], + ), + if (message.sarGpsCoordinates != null) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + decoration: BoxDecoration( + color: Colors.black.withValues( + alpha: isDarkMode ? 0.18 : 0.05, + ), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + Icon( + Icons.place_outlined, + size: 15, + color: _getSarMarkerBorderColor( + context, + isDarkMode, + ), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + '${message.sarGpsCoordinates!.latitude.toStringAsFixed(5)}, ${message.sarGpsCoordinates!.longitude.toStringAsFixed(5)}', + style: Theme.of(context).textTheme.labelMedium + ?.copyWith( + fontFamily: 'monospace', + fontWeight: FontWeight.w700, + letterSpacing: 0.15, + ), + ), + ), + ], ), ), - if (!widget.isCompact) - Icon( - Icons.chevron_right, - size: 18, - color: Theme.of(context).colorScheme.primary, - ), ], - ), - if (message.sarGpsCoordinates != null) ...[ - const SizedBox(height: 6), - Text( - '${message.sarGpsCoordinates!.latitude.toStringAsFixed(5)}, ${message.sarGpsCoordinates!.longitude.toStringAsFixed(5)}', - style: Theme.of(context).textTheme.labelMedium - ?.copyWith(fontFamily: 'monospace'), - ), ], - ], + ), ), ] // Drawing message content (skip in compact mode - drawings hidden) @@ -2610,15 +2762,30 @@ class _MessageBubbleState extends State { ), ); + final bubbleWithMeta = Column( + crossAxisAlignment: isOwnMessage + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + bubble, + _buildBubbleMetaFooter( + context, + message: message, + isOwnMessage: isOwnMessage, + isSarMarker: isSarMarker, + ), + ], + ); + if (!shouldFloatBubble) { - return bubble; + return bubbleWithMeta; } return Row( mainAxisAlignment: isOwnMessage ? MainAxisAlignment.end : MainAxisAlignment.start, - children: [Flexible(child: bubble)], + children: [Flexible(child: bubbleWithMeta)], ); } } diff --git a/lib/widgets/messages/message_trace_sheet.dart b/lib/widgets/messages/message_trace_sheet.dart index 606719a..6adfe7c 100644 --- a/lib/widgets/messages/message_trace_sheet.dart +++ b/lib/widgets/messages/message_trace_sheet.dart @@ -30,7 +30,9 @@ class _MessageTraceSheetState extends State { Future<_TraceResult> _loadTrace() async { final connectionProvider = context.read(); - final nodes = await MeshMapNodesService.fetchNodes(); + final nodes = await MeshMapNodesService.fetchNodes( + cacheTtl: MeshMapNodesService.traceCacheTtl, + ); final packetPath = _extractPathFromPacketLogs( logs: connectionProvider.bleService.packetLogs, message: widget.message, @@ -166,23 +168,31 @@ class _MessageTraceSheetState extends State { child: hasMapPath ? flutter_map.FlutterMap( options: flutter_map.MapOptions( - initialCameraFit: flutter_map.CameraFit.bounds( - bounds: flutter_map.LatLngBounds.fromPoints(mapPoints), - padding: const EdgeInsets.all(28), - ), + initialCameraFit: + flutter_map.CameraFit.bounds( + bounds: + flutter_map + .LatLngBounds.fromPoints( + mapPoints, + ), + padding: const EdgeInsets.all(28), + ), ), children: [ flutter_map.TileLayer( urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - userAgentPackageName: 'com.meshcore.sar', + userAgentPackageName: + 'com.meshcore.sar', ), flutter_map.PolylineLayer( polylines: [ flutter_map.Polyline( points: mapPoints, strokeWidth: 4, - color: Theme.of(context).colorScheme.primary, + color: Theme.of( + context, + ).colorScheme.primary, ), ], ), @@ -202,12 +212,14 @@ class _MessageTraceSheetState extends State { height: 34, child: CircleAvatar( radius: 16, - backgroundColor: entry.key == 0 + backgroundColor: + entry.key == 0 ? Colors.green : (entry.key == - trace - .matchedPathNodes - .whereType() + trace.matchedPathNodes + .whereType< + MeshMapNode + >() .length - 1 ? Colors.red @@ -216,7 +228,8 @@ class _MessageTraceSheetState extends State { '${entry.key + 1}', style: const TextStyle( color: Colors.white, - fontWeight: FontWeight.bold, + fontWeight: + FontWeight.bold, fontSize: 11, ), ), @@ -228,7 +241,9 @@ class _MessageTraceSheetState extends State { ], ) : const Center( - child: Text('Not enough geolocated nodes to draw path'), + child: Text( + 'Not enough geolocated nodes to draw path', + ), ), ), ), @@ -244,8 +259,13 @@ class _MessageTraceSheetState extends State { ), if (relayNodes.isEmpty) const Padding( - padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Text('No relay nodes could be matched for this message.'), + padding: EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Text( + 'No relay nodes could be matched for this message.', + ), ), ...relayNodes.map( (node) => ListTile( @@ -286,10 +306,9 @@ class _MessageTraceSheetState extends State { MeshMapNode? _bestNodeForPrefix(List nodes, String? prefixHex) { if (prefixHex == null || prefixHex.isEmpty) return null; - final matches = nodes - .where((n) => n.publicKey.startsWith(prefixHex)) - .toList() - ..sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs)); + final matches = + nodes.where((n) => n.publicKey.startsWith(prefixHex)).toList() + ..sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs)); return matches.isEmpty ? null : matches.first; } @@ -298,7 +317,9 @@ class _MessageTraceSheetState extends State { required Message message, }) { if (message.pathLen <= 0 || message.pathLen >= 255) return null; - final expectedPayloadType = message.messageType == MessageType.channel ? 0x05 : 0x02; + final expectedPayloadType = message.messageType == MessageType.channel + ? 0x05 + : 0x02; BlePacketLog? bestLog; var bestDeltaMs = 999999999; @@ -313,7 +334,8 @@ class _MessageTraceSheetState extends State { if (pathLen != message.pathLen) continue; if (raw.length < 5 + pathLen) continue; - final deltaMs = (log.timestamp.difference(message.receivedAt).inMilliseconds).abs(); + final deltaMs = + (log.timestamp.difference(message.receivedAt).inMilliseconds).abs(); if (deltaMs < bestDeltaMs) { bestDeltaMs = deltaMs; bestLog = log; @@ -335,7 +357,9 @@ class _MessageTraceSheetState extends State { final result = []; for (var i = 0; i < pathHashes.length; i++) { final hashHex = pathHashes[i].toRadixString(16).padLeft(2, '0'); - final candidates = nodes.where((n) => n.publicKey.startsWith(hashHex)).toList(); + final candidates = nodes + .where((n) => n.publicKey.startsWith(hashHex)) + .toList(); if (candidates.isEmpty) { result.add(null); continue; @@ -343,7 +367,9 @@ class _MessageTraceSheetState extends State { List filtered = candidates; if (i == 0 && senderPrefix != null) { - final senderMatches = filtered.where((n) => n.publicKey.startsWith(senderPrefix)).toList(); + final senderMatches = filtered + .where((n) => n.publicKey.startsWith(senderPrefix)) + .toList(); if (senderMatches.isNotEmpty) filtered = senderMatches; } else if (i == pathHashes.length - 1 && recipientPrefix != null) { final recipientMatches = filtered @@ -366,7 +392,8 @@ class _MessageTraceSheetState extends State { }) { if (relayCount <= 0 || sender == null || recipient == null) return const []; final candidates = nodes.where((n) { - if (sender.publicKey == n.publicKey || recipient.publicKey == n.publicKey) { + if (sender.publicKey == n.publicKey || + recipient.publicKey == n.publicKey) { return false; } return true; diff --git a/test/providers/contacts_provider_test.dart b/test/providers/contacts_provider_test.dart index 0fa2e92..0c5961b 100644 --- a/test/providers/contacts_provider_test.dart +++ b/test/providers/contacts_provider_test.dart @@ -194,5 +194,43 @@ void main() { } }, ); + + test('builds message snapshot from latest valid telemetry', () { + final telemetryData = CayenneLppParser.createGpsData( + latitude: 45.0001, + longitude: 13.9999, + ); + + provider.updateTelemetry(publicKey.sublist(0, 6), telemetryData); + final contact = provider.findContactByKey(publicKey)!; + final snapshot = provider.buildMessageContactLocationSnapshot( + contact, + capturedAt: DateTime.now(), + ); + + expect(snapshot, isNotNull); + expect(snapshot!.source, equals('telemetry')); + expect(snapshot.location.latitude, closeTo(45.0001, 0.0001)); + expect(snapshot.location.longitude, closeTo(13.9999, 0.0001)); + }); + + test('builds message snapshot from advert when telemetry is invalid', () { + final invalidTelemetry = CayenneLppParser.createGpsData( + latitude: 0.0, + longitude: 0.0, + ); + + provider.updateTelemetry(publicKey.sublist(0, 6), invalidTelemetry); + final contact = provider.findContactByKey(publicKey)!; + final snapshot = provider.buildMessageContactLocationSnapshot( + contact, + capturedAt: DateTime.now(), + ); + + expect(snapshot, isNotNull); + expect(snapshot!.source, equals('advert')); + expect(snapshot.location.latitude, closeTo(46.0569, 0.000001)); + expect(snapshot.location.longitude, closeTo(14.5058, 0.000001)); + }); }); } diff --git a/test/providers/messages_provider_voice_test.dart b/test/providers/messages_provider_voice_test.dart index ee82816..d6c5b45 100644 --- a/test/providers/messages_provider_voice_test.dart +++ b/test/providers/messages_provider_voice_test.dart @@ -1,13 +1,20 @@ import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:meshcore_sar_app/models/message.dart'; +import 'package:meshcore_sar_app/models/message_contact_location.dart'; import 'package:meshcore_sar_app/providers/messages_provider.dart'; import 'package:meshcore_sar_app/utils/voice_message_parser.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:shared_preferences/shared_preferences.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('MessagesProvider voice detection', () { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + test('marks VE2 envelope messages as voice', () { final provider = MessagesProvider(); final envelope = VoiceEnvelope( @@ -65,5 +72,39 @@ void main() { expect(stored.isVoice, isTrue); expect(stored.voiceId, equals('00112233')); }); + + test('persists received contact location snapshots', () async { + final provider = MessagesProvider(); + final message = Message( + id: 'm3', + messageType: MessageType.contact, + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700000002, + text: 'status update', + receivedAt: DateTime.now(), + senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]), + ); + + provider.addMessage( + message, + contactLocationSnapshot: MessageContactLocation( + location: const LatLng(46.0569, 14.5058), + source: 'advert', + capturedAt: DateTime.now(), + sourceTimestamp: DateTime.now(), + ), + ); + await Future.delayed(const Duration(milliseconds: 50)); + + final restoredProvider = MessagesProvider(); + await restoredProvider.initialize(); + final snapshot = restoredProvider.getMessageContactLocation('m3'); + + expect(snapshot, isNotNull); + expect(snapshot!.source, equals('advert')); + expect(snapshot.location.latitude, closeTo(46.0569, 0.000001)); + expect(snapshot.location.longitude, closeTo(14.5058, 0.000001)); + }); }); }