mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Log contact data without revealing
This commit is contained in:
63
lib/models/message_contact_location.dart
Normal file
63
lib/models/message_contact_location.dart
Normal file
@@ -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<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'latitude': location.latitude,
|
||||||
|
'longitude': location.longitude,
|
||||||
|
'source': source,
|
||||||
|
'capturedAtMillis': capturedAt.millisecondsSinceEpoch,
|
||||||
|
'sourceTimestampMillis': sourceTimestamp?.millisecondsSinceEpoch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static MessageContactLocation? fromJson(Map<String, dynamic> 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -607,14 +607,30 @@ class AppProvider with ChangeNotifier {
|
|||||||
connectionProvider.onMessageReceived = (message) {
|
connectionProvider.onMessageReceived = (message) {
|
||||||
// Enrich message with sender name from contacts first
|
// Enrich message with sender name from contacts first
|
||||||
Message enrichedMessage = message;
|
Message enrichedMessage = message;
|
||||||
|
Contact? senderContact;
|
||||||
if (message.senderPublicKeyPrefix != null && message.senderName == null) {
|
if (message.senderPublicKeyPrefix != null && message.senderName == null) {
|
||||||
final contact = contactsProvider.findContactByKey(
|
final contact = contactsProvider.findContactByKey(
|
||||||
message.senderPublicKeyPrefix!,
|
message.senderPublicKeyPrefix!,
|
||||||
);
|
);
|
||||||
if (contact != null) {
|
if (contact != null) {
|
||||||
|
senderContact = contact;
|
||||||
enrichedMessage = message.copyWith(senderName: contact.advName);
|
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
|
// Check if message is a drawing broadcast
|
||||||
if (DrawingMessageParser.isDrawingMessage(enrichedMessage.text)) {
|
if (DrawingMessageParser.isDrawingMessage(enrichedMessage.text)) {
|
||||||
@@ -645,6 +661,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
messagesProvider.addMessage(
|
messagesProvider.addMessage(
|
||||||
updatedMessage,
|
updatedMessage,
|
||||||
contactLookup: (name) => '',
|
contactLookup: (name) => '',
|
||||||
|
contactLocationSnapshot: contactLocationSnapshot,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Broadcast drawing message to SSE clients if server is running
|
// Broadcast drawing message to SSE clients if server is running
|
||||||
@@ -680,6 +697,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
contactLocationSnapshot: contactLocationSnapshot,
|
||||||
);
|
);
|
||||||
connectionProvider.broadcastMessageToSseClients(enrichedMessage);
|
connectionProvider.broadcastMessageToSseClients(enrichedMessage);
|
||||||
return;
|
return;
|
||||||
@@ -707,6 +725,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
contactLocationSnapshot: contactLocationSnapshot,
|
||||||
);
|
);
|
||||||
connectionProvider.broadcastMessageToSseClients(enrichedMessage);
|
connectionProvider.broadcastMessageToSseClients(enrichedMessage);
|
||||||
return;
|
return;
|
||||||
@@ -743,6 +762,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
contactLocationSnapshot: contactLocationSnapshot,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Broadcast message to SSE clients if server is running
|
// Broadcast message to SSE clients if server is running
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
|
import '../models/message_contact_location.dart';
|
||||||
import '../services/cayenne_lpp_parser.dart';
|
import '../services/cayenne_lpp_parser.dart';
|
||||||
import '../services/contact_storage_service.dart';
|
import '../services/contact_storage_service.dart';
|
||||||
import '../utils/key_comparison.dart';
|
import '../utils/key_comparison.dart';
|
||||||
@@ -214,6 +215,52 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
List<Contact> get chatContactsWithLocation =>
|
List<Contact> get chatContactsWithLocation =>
|
||||||
chatContacts.where((c) => c.displayLocation != null).toList();
|
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)
|
/// Sort contacts by last seen (most recent first)
|
||||||
int _sortByLastSeen(Contact a, Contact b) {
|
int _sortByLastSeen(Contact a, Contact b) {
|
||||||
return b.lastSeenTime.compareTo(a.lastSeenTime);
|
return b.lastSeenTime.compareTo(a.lastSeenTime);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import '../models/message.dart';
|
import '../models/message.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
|
import '../models/message_contact_location.dart';
|
||||||
import '../models/sar_marker.dart';
|
import '../models/sar_marker.dart';
|
||||||
import '../models/map_drawing.dart';
|
import '../models/map_drawing.dart';
|
||||||
import '../services/message_storage_service.dart';
|
import '../services/message_storage_service.dart';
|
||||||
@@ -20,6 +21,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
final NotificationService _notificationService = NotificationService();
|
final NotificationService _notificationService = NotificationService();
|
||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
AppLocalizations? _localizations;
|
AppLocalizations? _localizations;
|
||||||
|
final Map<String, MessageContactLocation> _messageContactLocations = {};
|
||||||
|
|
||||||
// Track pending sent messages by expected ACK/TAG
|
// Track pending sent messages by expected ACK/TAG
|
||||||
final Map<int, Message> _pendingSentMessages = {};
|
final Map<int, Message> _pendingSentMessages = {};
|
||||||
@@ -104,6 +106,9 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
|
|
||||||
String? get targetMessageId => _targetMessageId;
|
String? get targetMessageId => _targetMessageId;
|
||||||
|
|
||||||
|
MessageContactLocation? getMessageContactLocation(String messageId) =>
|
||||||
|
_messageContactLocations[messageId];
|
||||||
|
|
||||||
/// Set localizations for notifications
|
/// Set localizations for notifications
|
||||||
void setLocalizations(AppLocalizations localizations) {
|
void setLocalizations(AppLocalizations localizations) {
|
||||||
_localizations = localizations;
|
_localizations = localizations;
|
||||||
@@ -132,6 +137,11 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
debugPrint('📦 [MessagesProvider] Loading persisted messages...');
|
debugPrint('📦 [MessagesProvider] Loading persisted messages...');
|
||||||
final storedMessages = await _storageService.loadMessages();
|
final storedMessages = await _storageService.loadMessages();
|
||||||
|
final storedContactLocations = await _storageService
|
||||||
|
.loadMessageContactLocations();
|
||||||
|
_messageContactLocations
|
||||||
|
..clear()
|
||||||
|
..addAll(storedContactLocations);
|
||||||
|
|
||||||
// Add stored messages with enhancement to ensure SAR detection
|
// Add stored messages with enhancement to ensure SAR detection
|
||||||
for (final message in storedMessages) {
|
for (final message in storedMessages) {
|
||||||
@@ -294,6 +304,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
void addMessage(
|
void addMessage(
|
||||||
Message message, {
|
Message message, {
|
||||||
String Function(String name)? contactLookup,
|
String Function(String name)? contactLookup,
|
||||||
|
MessageContactLocation? contactLocationSnapshot,
|
||||||
}) {
|
}) {
|
||||||
// Always enhance message with SAR parser to detect SAR markers
|
// Always enhance message with SAR parser to detect SAR markers
|
||||||
var enhancedMessage = SarMessageParser.enhanceMessage(message);
|
var enhancedMessage = SarMessageParser.enhanceMessage(message);
|
||||||
@@ -384,6 +395,9 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_messages.add(finalMessage);
|
_messages.add(finalMessage);
|
||||||
|
if (contactLocationSnapshot != null) {
|
||||||
|
_messageContactLocations[finalMessage.id] = contactLocationSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
// If it's a SAR marker message, extract and store the marker
|
// If it's a SAR marker message, extract and store the marker
|
||||||
if (finalMessage.isSarMarker) {
|
if (finalMessage.isSarMarker) {
|
||||||
@@ -570,7 +584,10 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
/// Persist messages to storage (async, non-blocking)
|
/// Persist messages to storage (async, non-blocking)
|
||||||
Future<void> _persistMessages() async {
|
Future<void> _persistMessages() async {
|
||||||
try {
|
try {
|
||||||
await _storageService.saveMessages(_messages);
|
await _storageService.saveMessages(
|
||||||
|
_messages,
|
||||||
|
messageContactLocations: _messageContactLocations,
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [MessagesProvider] Error persisting messages: $e');
|
debugPrint('❌ [MessagesProvider] Error persisting messages: $e');
|
||||||
}
|
}
|
||||||
@@ -685,6 +702,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
_messageContactMap.remove(messageId);
|
_messageContactMap.remove(messageId);
|
||||||
_groupedMessageMapping.remove(messageId);
|
_groupedMessageMapping.remove(messageId);
|
||||||
|
_messageContactLocations.remove(messageId);
|
||||||
|
|
||||||
debugPrint('🗑️ [MessagesProvider] Message $messageId deleted');
|
debugPrint('🗑️ [MessagesProvider] Message $messageId deleted');
|
||||||
|
|
||||||
@@ -713,6 +731,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
void clearMessages() {
|
void clearMessages() {
|
||||||
_messages.clear();
|
_messages.clear();
|
||||||
_sarMarkers.clear();
|
_sarMarkers.clear();
|
||||||
|
_messageContactLocations.clear();
|
||||||
_persistMessages();
|
_persistMessages();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
@@ -727,6 +746,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
void clearAll() {
|
void clearAll() {
|
||||||
_messages.clear();
|
_messages.clear();
|
||||||
_sarMarkers.clear();
|
_sarMarkers.clear();
|
||||||
|
_messageContactLocations.clear();
|
||||||
_persistMessages();
|
_persistMessages();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -403,25 +403,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
channelCount: 2,
|
channelCount: 2,
|
||||||
);
|
);
|
||||||
|
|
||||||
final sarMessages = SampleDataGenerator.generateSarMarkerMessages(
|
final sampleMessages = SampleDataGenerator.generateAllMessages(
|
||||||
centerLocation: centerLocation,
|
centerLocation: centerLocation,
|
||||||
l10n: l10n,
|
l10n: l10n,
|
||||||
foundPersonCount: 2,
|
foundPersonCount: 2,
|
||||||
fireCount: 1,
|
fireCount: 1,
|
||||||
stagingCount: 1,
|
stagingCount: 1,
|
||||||
objectCount: 1,
|
objectCount: 1,
|
||||||
);
|
|
||||||
|
|
||||||
final channelMessages = SampleDataGenerator.generateChannelMessages(
|
|
||||||
centerLocation: centerLocation,
|
|
||||||
l10n: l10n,
|
|
||||||
generalChannelMessages: 8,
|
generalChannelMessages: 8,
|
||||||
emergencyChannelMessages: 5,
|
emergencyChannelMessages: 5,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Combine all messages
|
|
||||||
final allMessages = [...sarMessages, ...channelMessages];
|
|
||||||
|
|
||||||
// Add to providers
|
// Add to providers
|
||||||
final contactsProvider = Provider.of<ContactsProvider>(
|
final contactsProvider = Provider.of<ContactsProvider>(
|
||||||
context,
|
context,
|
||||||
@@ -433,7 +425,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
contactsProvider.addContacts(contacts);
|
contactsProvider.addContacts(contacts);
|
||||||
messagesProvider.addMessages(allMessages);
|
for (final message in sampleMessages.messages) {
|
||||||
|
messagesProvider.addMessage(
|
||||||
|
message,
|
||||||
|
contactLocationSnapshot: sampleMessages.contactLocations[message.id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
@@ -446,8 +443,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
AppLocalizations.of(context)!.loadedSampleData(
|
AppLocalizations.of(context)!.loadedSampleData(
|
||||||
teamCount,
|
teamCount,
|
||||||
channelCount,
|
channelCount,
|
||||||
sarMessages.length,
|
sampleMessages.messages.where((m) => m.isSarMarker).length,
|
||||||
channelMessages.length,
|
sampleMessages.messages.length,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: Colors.green,
|
||||||
|
|||||||
@@ -31,17 +31,22 @@ class MeshMapNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class MeshMapNodesService {
|
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 _cacheTtl = Duration(minutes: 2);
|
||||||
|
static const Duration traceCacheTtl = Duration(minutes: 10);
|
||||||
static List<MeshMapNode>? _cachedNodes;
|
static List<MeshMapNode>? _cachedNodes;
|
||||||
static DateTime? _cachedAt;
|
static DateTime? _cachedAt;
|
||||||
|
|
||||||
static Future<List<MeshMapNode>> fetchNodes({bool forceRefresh = false}) async {
|
static Future<List<MeshMapNode>> fetchNodes({
|
||||||
|
bool forceRefresh = false,
|
||||||
|
Duration cacheTtl = _cacheTtl,
|
||||||
|
}) async {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
if (!forceRefresh &&
|
if (!forceRefresh &&
|
||||||
_cachedNodes != null &&
|
_cachedNodes != null &&
|
||||||
_cachedAt != null &&
|
_cachedAt != null &&
|
||||||
now.difference(_cachedAt!) < _cacheTtl) {
|
now.difference(_cachedAt!) < cacheTtl) {
|
||||||
return _cachedNodes!;
|
return _cachedNodes!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +63,9 @@ class MeshMapNodesService {
|
|||||||
final nodes = nodesRaw
|
final nodes = nodesRaw
|
||||||
.whereType<Map<String, dynamic>>()
|
.whereType<Map<String, dynamic>>()
|
||||||
.map(MeshMapNode.fromJson)
|
.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();
|
.toList();
|
||||||
|
|
||||||
_cachedNodes = nodes;
|
_cachedNodes = nodes;
|
||||||
|
|||||||
@@ -2,15 +2,21 @@ import 'dart:convert';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../models/message.dart';
|
import '../models/message.dart';
|
||||||
|
import '../models/message_contact_location.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
/// Service for persisting messages to local storage
|
/// Service for persisting messages to local storage
|
||||||
class MessageStorageService {
|
class MessageStorageService {
|
||||||
static const String _messagesKey = 'stored_messages';
|
static const String _messagesKey = 'stored_messages';
|
||||||
|
static const String _messageContactLocationsKey =
|
||||||
|
'stored_message_contact_locations';
|
||||||
static const int _maxStoredMessages = 1000; // Store up to 1000 messages
|
static const int _maxStoredMessages = 1000; // Store up to 1000 messages
|
||||||
|
|
||||||
/// Save messages to persistent storage
|
/// Save messages to persistent storage
|
||||||
Future<void> saveMessages(List<Message> messages) async {
|
Future<void> saveMessages(
|
||||||
|
List<Message> messages, {
|
||||||
|
Map<String, MessageContactLocation> messageContactLocations = const {},
|
||||||
|
}) async {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
@@ -24,6 +30,19 @@ class MessageStorageService {
|
|||||||
|
|
||||||
final jsonString = jsonEncode(limitedList);
|
final jsonString = jsonEncode(limitedList);
|
||||||
await prefs.setString(_messagesKey, jsonString);
|
await prefs.setString(_messagesKey, jsonString);
|
||||||
|
final retainedMessageIds = limitedList
|
||||||
|
.map((entry) => entry['id'] as String)
|
||||||
|
.toSet();
|
||||||
|
final locationJson = <String, dynamic>{};
|
||||||
|
for (final entry in messageContactLocations.entries) {
|
||||||
|
if (retainedMessageIds.contains(entry.key)) {
|
||||||
|
locationJson[entry.key] = entry.value.toJson();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await prefs.setString(
|
||||||
|
_messageContactLocationsKey,
|
||||||
|
jsonEncode(locationJson),
|
||||||
|
);
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'✅ [MessageStorage] Saved ${limitedList.length} messages to storage',
|
'✅ [MessageStorage] Saved ${limitedList.length} messages to storage',
|
||||||
@@ -33,6 +52,36 @@ class MessageStorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Map<String, MessageContactLocation>> 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<String, dynamic>) {
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = <String, MessageContactLocation>{};
|
||||||
|
for (final entry in decoded.entries) {
|
||||||
|
final value = entry.value;
|
||||||
|
if (value is! Map<String, dynamic>) 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
|
/// Load messages from persistent storage
|
||||||
Future<List<Message>> loadMessages() async {
|
Future<List<Message>> loadMessages() async {
|
||||||
try {
|
try {
|
||||||
@@ -66,6 +115,7 @@ class MessageStorageService {
|
|||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.remove(_messagesKey);
|
await prefs.remove(_messagesKey);
|
||||||
|
await prefs.remove(_messageContactLocationsKey);
|
||||||
debugPrint('✅ [MessageStorage] Cleared all stored messages');
|
debugPrint('✅ [MessageStorage] Cleared all stored messages');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [MessageStorage] Error clearing messages: $e');
|
debugPrint('❌ [MessageStorage] Error clearing messages: $e');
|
||||||
|
|||||||
@@ -4,12 +4,66 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
import '../models/message.dart';
|
import '../models/message.dart';
|
||||||
|
import '../models/message_contact_location.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
||||||
/// Generates sample data for testing/demo purposes
|
/// Generates sample data for testing/demo purposes
|
||||||
class SampleDataGenerator {
|
class SampleDataGenerator {
|
||||||
static final Random _random = Random();
|
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
|
/// Generate sample contacts around a center location
|
||||||
static List<Contact> generateContacts({
|
static List<Contact> generateContacts({
|
||||||
required LatLng centerLocation,
|
required LatLng centerLocation,
|
||||||
@@ -113,7 +167,7 @@ class SampleDataGenerator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Generate sample SAR markers around a center location
|
/// Generate sample SAR markers around a center location
|
||||||
static List<Message> generateSarMarkerMessages({
|
static SampleMessageBatch generateSarMarkerMessages({
|
||||||
required LatLng centerLocation,
|
required LatLng centerLocation,
|
||||||
required AppLocalizations l10n,
|
required AppLocalizations l10n,
|
||||||
int foundPersonCount = 2,
|
int foundPersonCount = 2,
|
||||||
@@ -122,6 +176,7 @@ class SampleDataGenerator {
|
|||||||
int objectCount = 1,
|
int objectCount = 1,
|
||||||
}) {
|
}) {
|
||||||
final messages = <Message>[];
|
final messages = <Message>[];
|
||||||
|
final contactLocations = <String, MessageContactLocation>{};
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
int messageId = 1;
|
int messageId = 1;
|
||||||
|
|
||||||
@@ -137,7 +192,7 @@ class SampleDataGenerator {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final timestamp = now.subtract(Duration(minutes: 10 + i * 5));
|
final timestamp = now.subtract(Duration(minutes: 10 + i * 5));
|
||||||
messages.add(Message(
|
final message = Message(
|
||||||
id: 'sample_fp_$messageId',
|
id: 'sample_fp_$messageId',
|
||||||
messageType: MessageType.contact,
|
messageType: MessageType.contact,
|
||||||
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
||||||
@@ -150,7 +205,13 @@ class SampleDataGenerator {
|
|||||||
sarGpsCoordinates: LatLng(lat, lon),
|
sarGpsCoordinates: LatLng(lat, lon),
|
||||||
sarCustomEmoji: '🧑',
|
sarCustomEmoji: '🧑',
|
||||||
senderName: l10n.sampleTeamMember,
|
senderName: l10n.sampleTeamMember,
|
||||||
));
|
);
|
||||||
|
messages.add(message);
|
||||||
|
contactLocations[message.id] = _sampleSnapshot(
|
||||||
|
location: _randomNearbyLocation(message.sarGpsCoordinates!, 0.006),
|
||||||
|
receivedAt: timestamp,
|
||||||
|
source: 'telemetry',
|
||||||
|
);
|
||||||
messageId++;
|
messageId++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +227,7 @@ class SampleDataGenerator {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final timestamp = now.subtract(Duration(minutes: 20 + i * 5));
|
final timestamp = now.subtract(Duration(minutes: 20 + i * 5));
|
||||||
messages.add(Message(
|
final message = Message(
|
||||||
id: 'sample_fire_$messageId',
|
id: 'sample_fire_$messageId',
|
||||||
messageType: MessageType.contact,
|
messageType: MessageType.contact,
|
||||||
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
||||||
@@ -179,7 +240,13 @@ class SampleDataGenerator {
|
|||||||
sarGpsCoordinates: LatLng(lat, lon),
|
sarGpsCoordinates: LatLng(lat, lon),
|
||||||
sarCustomEmoji: '🔥',
|
sarCustomEmoji: '🔥',
|
||||||
senderName: l10n.sampleScout,
|
senderName: l10n.sampleScout,
|
||||||
));
|
);
|
||||||
|
messages.add(message);
|
||||||
|
contactLocations[message.id] = _sampleSnapshot(
|
||||||
|
location: _randomNearbyLocation(message.sarGpsCoordinates!, 0.008),
|
||||||
|
receivedAt: timestamp,
|
||||||
|
source: 'advert',
|
||||||
|
);
|
||||||
messageId++;
|
messageId++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +262,7 @@ class SampleDataGenerator {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final timestamp = now.subtract(Duration(minutes: 30 + i * 5));
|
final timestamp = now.subtract(Duration(minutes: 30 + i * 5));
|
||||||
messages.add(Message(
|
final message = Message(
|
||||||
id: 'sample_staging_$messageId',
|
id: 'sample_staging_$messageId',
|
||||||
messageType: MessageType.contact,
|
messageType: MessageType.contact,
|
||||||
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
||||||
@@ -208,7 +275,13 @@ class SampleDataGenerator {
|
|||||||
sarGpsCoordinates: LatLng(lat, lon),
|
sarGpsCoordinates: LatLng(lat, lon),
|
||||||
sarCustomEmoji: '🏕️',
|
sarCustomEmoji: '🏕️',
|
||||||
senderName: l10n.sampleBase,
|
senderName: l10n.sampleBase,
|
||||||
));
|
);
|
||||||
|
messages.add(message);
|
||||||
|
contactLocations[message.id] = _sampleSnapshot(
|
||||||
|
location: _randomNearbyLocation(message.sarGpsCoordinates!, 0.01),
|
||||||
|
receivedAt: timestamp,
|
||||||
|
source: 'advert',
|
||||||
|
);
|
||||||
messageId++;
|
messageId++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,24 +304,34 @@ class SampleDataGenerator {
|
|||||||
l10n.sampleObjectTrailMarker,
|
l10n.sampleObjectTrailMarker,
|
||||||
];
|
];
|
||||||
|
|
||||||
messages.add(Message(
|
final message = Message(
|
||||||
id: 'sample_object_$messageId',
|
id: 'sample_object_$messageId',
|
||||||
messageType: MessageType.contact,
|
messageType: MessageType.contact,
|
||||||
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
||||||
pathLen: 1,
|
pathLen: 1,
|
||||||
textType: MessageTextType.plain,
|
textType: MessageTextType.plain,
|
||||||
senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000,
|
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,
|
receivedAt: timestamp,
|
||||||
isSarMarker: true,
|
isSarMarker: true,
|
||||||
sarGpsCoordinates: LatLng(lat, lon),
|
sarGpsCoordinates: LatLng(lat, lon),
|
||||||
sarCustomEmoji: '📦',
|
sarCustomEmoji: '📦',
|
||||||
senderName: l10n.sampleSearcher,
|
senderName: l10n.sampleSearcher,
|
||||||
));
|
);
|
||||||
|
messages.add(message);
|
||||||
|
contactLocations[message.id] = _sampleSnapshot(
|
||||||
|
location: _randomNearbyLocation(message.sarGpsCoordinates!, 0.007),
|
||||||
|
receivedAt: timestamp,
|
||||||
|
source: 'telemetry',
|
||||||
|
);
|
||||||
messageId++;
|
messageId++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return messages;
|
return SampleMessageBatch(
|
||||||
|
messages: messages,
|
||||||
|
contactLocations: contactLocations,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate sample map drawings
|
/// Generate sample map drawings
|
||||||
@@ -272,7 +355,9 @@ class SampleDataGenerator {
|
|||||||
'id': 'sample_line_${now.millisecondsSinceEpoch}',
|
'id': 'sample_line_${now.millisecondsSinceEpoch}',
|
||||||
'color': Colors.blue.toARGB32(),
|
'color': Colors.blue.toARGB32(),
|
||||||
'createdAt': now.subtract(const Duration(minutes: 15)).toIso8601String(),
|
'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,
|
'sender': l10n.sampleTeamMember,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -297,7 +382,7 @@ class SampleDataGenerator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Generate sample channel messages for public channels
|
/// Generate sample channel messages for public channels
|
||||||
static List<Message> generateChannelMessages({
|
static SampleMessageBatch generateChannelMessages({
|
||||||
LatLng? centerLocation,
|
LatLng? centerLocation,
|
||||||
required AppLocalizations l10n,
|
required AppLocalizations l10n,
|
||||||
int generalChannelMessages = 8,
|
int generalChannelMessages = 8,
|
||||||
@@ -306,6 +391,7 @@ class SampleDataGenerator {
|
|||||||
// Use provided location or default to Ljubljana, Slovenia
|
// Use provided location or default to Ljubljana, Slovenia
|
||||||
final center = centerLocation ?? const LatLng(46.0569, 14.5058);
|
final center = centerLocation ?? const LatLng(46.0569, 14.5058);
|
||||||
final messages = <Message>[];
|
final messages = <Message>[];
|
||||||
|
final contactLocations = <String, MessageContactLocation>{};
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
int messageId = 1000; // Start with high ID to avoid conflicts
|
int messageId = 1000; // Start with high ID to avoid conflicts
|
||||||
|
|
||||||
@@ -352,7 +438,11 @@ class SampleDataGenerator {
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Generate General channel messages
|
// 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(
|
final senderKey = Uint8List.fromList(
|
||||||
List.generate(32, (_) => _random.nextInt(256)),
|
List.generate(32, (_) => _random.nextInt(256)),
|
||||||
);
|
);
|
||||||
@@ -361,7 +451,7 @@ class SampleDataGenerator {
|
|||||||
final minutesAgo = 120 - (i * 15) - _random.nextInt(10);
|
final minutesAgo = 120 - (i * 15) - _random.nextInt(10);
|
||||||
final timestamp = now.subtract(Duration(minutes: minutesAgo));
|
final timestamp = now.subtract(Duration(minutes: minutesAgo));
|
||||||
|
|
||||||
messages.add(Message(
|
final message = Message(
|
||||||
id: 'sample_general_$messageId',
|
id: 'sample_general_$messageId',
|
||||||
messageType: MessageType.channel,
|
messageType: MessageType.channel,
|
||||||
channelIdx: 0, // General channel
|
channelIdx: 0, // General channel
|
||||||
@@ -372,12 +462,22 @@ class SampleDataGenerator {
|
|||||||
text: generalMessages[i],
|
text: generalMessages[i],
|
||||||
receivedAt: timestamp,
|
receivedAt: timestamp,
|
||||||
senderName: teamNames[_random.nextInt(teamNames.length)],
|
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++;
|
messageId++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate Emergency channel messages
|
// 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(
|
final senderKey = Uint8List.fromList(
|
||||||
List.generate(32, (_) => _random.nextInt(256)),
|
List.generate(32, (_) => _random.nextInt(256)),
|
||||||
);
|
);
|
||||||
@@ -386,7 +486,7 @@ class SampleDataGenerator {
|
|||||||
final minutesAgo = 60 - (i * 10) - _random.nextInt(5);
|
final minutesAgo = 60 - (i * 10) - _random.nextInt(5);
|
||||||
final timestamp = now.subtract(Duration(minutes: minutesAgo));
|
final timestamp = now.subtract(Duration(minutes: minutesAgo));
|
||||||
|
|
||||||
messages.add(Message(
|
final message = Message(
|
||||||
id: 'sample_emergency_$messageId',
|
id: 'sample_emergency_$messageId',
|
||||||
messageType: MessageType.channel,
|
messageType: MessageType.channel,
|
||||||
channelIdx: 1, // Emergency channel
|
channelIdx: 1, // Emergency channel
|
||||||
@@ -397,10 +497,29 @@ class SampleDataGenerator {
|
|||||||
text: emergencyMessages[i],
|
text: emergencyMessages[i],
|
||||||
receivedAt: timestamp,
|
receivedAt: timestamp,
|
||||||
senderName: teamNames[_random.nextInt(teamNames.length)],
|
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++;
|
messageId++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return messages;
|
return SampleMessageBatch(
|
||||||
|
messages: messages,
|
||||||
|
contactLocations: contactLocations,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class SampleMessageBatch {
|
||||||
|
final List<Message> messages;
|
||||||
|
final Map<String, MessageContactLocation> contactLocations;
|
||||||
|
|
||||||
|
const SampleMessageBatch({
|
||||||
|
required this.messages,
|
||||||
|
required this.contactLocations,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,8 +23,10 @@ import '../../utils/key_comparison.dart';
|
|||||||
import '../../utils/voice_message_parser.dart';
|
import '../../utils/voice_message_parser.dart';
|
||||||
import '../../utils/image_message_parser.dart';
|
import '../../utils/image_message_parser.dart';
|
||||||
import '../../utils/tictactoe_message_parser.dart';
|
import '../../utils/tictactoe_message_parser.dart';
|
||||||
|
import '../../utils/avatar_label_helper.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../../utils/message_extensions.dart';
|
import '../../utils/message_extensions.dart';
|
||||||
|
import '../common/contact_avatar.dart';
|
||||||
import 'voice_message_bubble.dart';
|
import 'voice_message_bubble.dart';
|
||||||
import 'image_message_bubble.dart';
|
import 'image_message_bubble.dart';
|
||||||
import 'tictactoe_message_bubble.dart';
|
import 'tictactoe_message_bubble.dart';
|
||||||
@@ -62,6 +64,103 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
bool _isExpanded = false;
|
bool _isExpanded = false;
|
||||||
bool _showReceivedStats = 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 = <Widget>[
|
||||||
|
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
|
@override
|
||||||
void didUpdateWidget(MessageBubble oldWidget) {
|
void didUpdateWidget(MessageBubble oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
@@ -356,6 +455,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
final radioSf = connectionProvider.deviceInfo.radioSf;
|
final radioSf = connectionProvider.deviceInfo.radioSf;
|
||||||
final radioCr = connectionProvider.deviceInfo.radioCr;
|
final radioCr = connectionProvider.deviceInfo.radioCr;
|
||||||
final contactsProvider = context.read<ContactsProvider>();
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
|
final messagesProvider = context.read<MessagesProvider>();
|
||||||
final voiceProvider = context.read<VoiceProvider>();
|
final voiceProvider = context.read<VoiceProvider>();
|
||||||
final imageProvider = context.read<ip.ImageProvider>();
|
final imageProvider = context.read<ip.ImageProvider>();
|
||||||
final selfPublicKey = connectionProvider.deviceInfo.publicKey;
|
final selfPublicKey = connectionProvider.deviceInfo.publicKey;
|
||||||
@@ -397,6 +497,10 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
recipientName = recipientContact?.advName;
|
recipientName = recipientContact?.advName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final senderLocationSnapshot = messagesProvider.getMessageContactLocation(
|
||||||
|
widget.message.id,
|
||||||
|
);
|
||||||
|
|
||||||
final envelope = VoiceEnvelope.tryParseText(widget.message.text);
|
final envelope = VoiceEnvelope.tryParseText(widget.message.text);
|
||||||
final legacyVoicePacket = VoicePacket.tryParseText(widget.message.text);
|
final legacyVoicePacket = VoicePacket.tryParseText(widget.message.text);
|
||||||
final voiceSession = widget.message.voiceId != null
|
final voiceSession = widget.message.voiceId != null
|
||||||
@@ -496,6 +600,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
'Used flood fallback: ${widget.message.usedFloodFallback}',
|
'Used flood fallback: ${widget.message.usedFloodFallback}',
|
||||||
'Sender key prefix: ${senderPrefixHex ?? '-'}',
|
'Sender key prefix: ${senderPrefixHex ?? '-'}',
|
||||||
'Sender name: ${senderName ?? widget.message.senderName ?? '-'}',
|
'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 key prefix: ${recipientPrefixHex ?? '-'}',
|
||||||
'Recipient name: ${recipientName ?? '-'}',
|
'Recipient name: ${recipientName ?? '-'}',
|
||||||
'Drawing flag: ${widget.message.isDrawing}',
|
'Drawing flag: ${widget.message.isDrawing}',
|
||||||
@@ -1850,7 +1957,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
? null
|
? null
|
||||||
: () => _showMessageOptions(context),
|
: () => _showMessageOptions(context),
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
margin: EdgeInsets.zero,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: widget.isHighlighted
|
color: widget.isHighlighted
|
||||||
@@ -1993,15 +2100,6 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
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<MessageBubble> {
|
|||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (isOwnMessage)
|
_buildHeaderAvatar(
|
||||||
Icon(
|
context,
|
||||||
Icons.account_circle,
|
isOwnMessage: isOwnMessage,
|
||||||
size: 16,
|
isChannelMessage: message.isChannelMessage,
|
||||||
color: Theme.of(context).colorScheme.primary,
|
senderContact: senderContact,
|
||||||
)
|
displayName: displayName,
|
||||||
else if (message.isChannelMessage)
|
),
|
||||||
const Icon(Icons.tag, size: 16)
|
|
||||||
else
|
|
||||||
const Icon(Icons.person, size: 16),
|
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -2151,83 +2246,140 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// 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),
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
// SAR marker content
|
// SAR marker content
|
||||||
if (isSarMarker && message.sarMarkerType != null) ...[
|
if (isSarMarker && message.sarMarkerType != null) ...[
|
||||||
Column(
|
Container(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
width: double.infinity,
|
||||||
children: [
|
padding: const EdgeInsets.all(12),
|
||||||
Row(
|
decoration: BoxDecoration(
|
||||||
children: [
|
color: Colors.white.withValues(
|
||||||
Text(
|
alpha: isDarkMode ? 0.06 : 0.42,
|
||||||
message.sarCustomEmoji ??
|
),
|
||||||
message.sarMarkerType!.emoji,
|
borderRadius: BorderRadius.circular(14),
|
||||||
style: const TextStyle(fontSize: 32),
|
border: Border.all(
|
||||||
),
|
color: _getSarMarkerBorderColor(
|
||||||
const SizedBox(width: 10),
|
context,
|
||||||
Expanded(
|
isDarkMode,
|
||||||
child: Text(
|
).withValues(alpha: 0.22),
|
||||||
message.sarNotes != null &&
|
),
|
||||||
message.sarNotes!.isNotEmpty
|
),
|
||||||
? message.sarNotes!
|
child: Column(
|
||||||
: message.sarMarkerType!.getLocalizedName(
|
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,
|
context,
|
||||||
),
|
),
|
||||||
style: Theme.of(context).textTheme.titleSmall
|
style: Theme.of(context).textTheme.titleMedium
|
||||||
?.copyWith(fontWeight: FontWeight.bold),
|
?.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)
|
// Drawing message content (skip in compact mode - drawings hidden)
|
||||||
@@ -2610,15 +2762,30 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final bubbleWithMeta = Column(
|
||||||
|
crossAxisAlignment: isOwnMessage
|
||||||
|
? CrossAxisAlignment.end
|
||||||
|
: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
bubble,
|
||||||
|
_buildBubbleMetaFooter(
|
||||||
|
context,
|
||||||
|
message: message,
|
||||||
|
isOwnMessage: isOwnMessage,
|
||||||
|
isSarMarker: isSarMarker,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
if (!shouldFloatBubble) {
|
if (!shouldFloatBubble) {
|
||||||
return bubble;
|
return bubbleWithMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisAlignment: isOwnMessage
|
mainAxisAlignment: isOwnMessage
|
||||||
? MainAxisAlignment.end
|
? MainAxisAlignment.end
|
||||||
: MainAxisAlignment.start,
|
: MainAxisAlignment.start,
|
||||||
children: [Flexible(child: bubble)],
|
children: [Flexible(child: bubbleWithMeta)],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
|
|
||||||
Future<_TraceResult> _loadTrace() async {
|
Future<_TraceResult> _loadTrace() async {
|
||||||
final connectionProvider = context.read<ConnectionProvider>();
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
final nodes = await MeshMapNodesService.fetchNodes();
|
final nodes = await MeshMapNodesService.fetchNodes(
|
||||||
|
cacheTtl: MeshMapNodesService.traceCacheTtl,
|
||||||
|
);
|
||||||
final packetPath = _extractPathFromPacketLogs(
|
final packetPath = _extractPathFromPacketLogs(
|
||||||
logs: connectionProvider.bleService.packetLogs,
|
logs: connectionProvider.bleService.packetLogs,
|
||||||
message: widget.message,
|
message: widget.message,
|
||||||
@@ -166,23 +168,31 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
child: hasMapPath
|
child: hasMapPath
|
||||||
? flutter_map.FlutterMap(
|
? flutter_map.FlutterMap(
|
||||||
options: flutter_map.MapOptions(
|
options: flutter_map.MapOptions(
|
||||||
initialCameraFit: flutter_map.CameraFit.bounds(
|
initialCameraFit:
|
||||||
bounds: flutter_map.LatLngBounds.fromPoints(mapPoints),
|
flutter_map.CameraFit.bounds(
|
||||||
padding: const EdgeInsets.all(28),
|
bounds:
|
||||||
),
|
flutter_map
|
||||||
|
.LatLngBounds.fromPoints(
|
||||||
|
mapPoints,
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(28),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
flutter_map.TileLayer(
|
flutter_map.TileLayer(
|
||||||
urlTemplate:
|
urlTemplate:
|
||||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
userAgentPackageName: 'com.meshcore.sar',
|
userAgentPackageName:
|
||||||
|
'com.meshcore.sar',
|
||||||
),
|
),
|
||||||
flutter_map.PolylineLayer(
|
flutter_map.PolylineLayer(
|
||||||
polylines: [
|
polylines: [
|
||||||
flutter_map.Polyline(
|
flutter_map.Polyline(
|
||||||
points: mapPoints,
|
points: mapPoints,
|
||||||
strokeWidth: 4,
|
strokeWidth: 4,
|
||||||
color: Theme.of(context).colorScheme.primary,
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.primary,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -202,12 +212,14 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
height: 34,
|
height: 34,
|
||||||
child: CircleAvatar(
|
child: CircleAvatar(
|
||||||
radius: 16,
|
radius: 16,
|
||||||
backgroundColor: entry.key == 0
|
backgroundColor:
|
||||||
|
entry.key == 0
|
||||||
? Colors.green
|
? Colors.green
|
||||||
: (entry.key ==
|
: (entry.key ==
|
||||||
trace
|
trace.matchedPathNodes
|
||||||
.matchedPathNodes
|
.whereType<
|
||||||
.whereType<MeshMapNode>()
|
MeshMapNode
|
||||||
|
>()
|
||||||
.length -
|
.length -
|
||||||
1
|
1
|
||||||
? Colors.red
|
? Colors.red
|
||||||
@@ -216,7 +228,8 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
'${entry.key + 1}',
|
'${entry.key + 1}',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight:
|
||||||
|
FontWeight.bold,
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -228,7 +241,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
: const Center(
|
: 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<MessageTraceSheet> {
|
|||||||
),
|
),
|
||||||
if (relayNodes.isEmpty)
|
if (relayNodes.isEmpty)
|
||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: EdgeInsets.symmetric(
|
||||||
child: Text('No relay nodes could be matched for this message.'),
|
horizontal: 16,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'No relay nodes could be matched for this message.',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
...relayNodes.map(
|
...relayNodes.map(
|
||||||
(node) => ListTile(
|
(node) => ListTile(
|
||||||
@@ -286,10 +306,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
|
|
||||||
MeshMapNode? _bestNodeForPrefix(List<MeshMapNode> nodes, String? prefixHex) {
|
MeshMapNode? _bestNodeForPrefix(List<MeshMapNode> nodes, String? prefixHex) {
|
||||||
if (prefixHex == null || prefixHex.isEmpty) return null;
|
if (prefixHex == null || prefixHex.isEmpty) return null;
|
||||||
final matches = nodes
|
final matches =
|
||||||
.where((n) => n.publicKey.startsWith(prefixHex))
|
nodes.where((n) => n.publicKey.startsWith(prefixHex)).toList()
|
||||||
.toList()
|
..sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs));
|
||||||
..sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs));
|
|
||||||
return matches.isEmpty ? null : matches.first;
|
return matches.isEmpty ? null : matches.first;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,7 +317,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
required Message message,
|
required Message message,
|
||||||
}) {
|
}) {
|
||||||
if (message.pathLen <= 0 || message.pathLen >= 255) return null;
|
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;
|
BlePacketLog? bestLog;
|
||||||
var bestDeltaMs = 999999999;
|
var bestDeltaMs = 999999999;
|
||||||
|
|
||||||
@@ -313,7 +334,8 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
if (pathLen != message.pathLen) continue;
|
if (pathLen != message.pathLen) continue;
|
||||||
if (raw.length < 5 + 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) {
|
if (deltaMs < bestDeltaMs) {
|
||||||
bestDeltaMs = deltaMs;
|
bestDeltaMs = deltaMs;
|
||||||
bestLog = log;
|
bestLog = log;
|
||||||
@@ -335,7 +357,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
final result = <MeshMapNode?>[];
|
final result = <MeshMapNode?>[];
|
||||||
for (var i = 0; i < pathHashes.length; i++) {
|
for (var i = 0; i < pathHashes.length; i++) {
|
||||||
final hashHex = pathHashes[i].toRadixString(16).padLeft(2, '0');
|
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) {
|
if (candidates.isEmpty) {
|
||||||
result.add(null);
|
result.add(null);
|
||||||
continue;
|
continue;
|
||||||
@@ -343,7 +367,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
|
|
||||||
List<MeshMapNode> filtered = candidates;
|
List<MeshMapNode> filtered = candidates;
|
||||||
if (i == 0 && senderPrefix != null) {
|
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;
|
if (senderMatches.isNotEmpty) filtered = senderMatches;
|
||||||
} else if (i == pathHashes.length - 1 && recipientPrefix != null) {
|
} else if (i == pathHashes.length - 1 && recipientPrefix != null) {
|
||||||
final recipientMatches = filtered
|
final recipientMatches = filtered
|
||||||
@@ -366,7 +392,8 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
}) {
|
}) {
|
||||||
if (relayCount <= 0 || sender == null || recipient == null) return const [];
|
if (relayCount <= 0 || sender == null || recipient == null) return const [];
|
||||||
final candidates = nodes.where((n) {
|
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 false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:meshcore_sar_app/models/message.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/providers/messages_provider.dart';
|
||||||
import 'package:meshcore_sar_app/utils/voice_message_parser.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() {
|
void main() {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
group('MessagesProvider voice detection', () {
|
group('MessagesProvider voice detection', () {
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
});
|
||||||
|
|
||||||
test('marks VE2 envelope messages as voice', () {
|
test('marks VE2 envelope messages as voice', () {
|
||||||
final provider = MessagesProvider();
|
final provider = MessagesProvider();
|
||||||
final envelope = VoiceEnvelope(
|
final envelope = VoiceEnvelope(
|
||||||
@@ -65,5 +72,39 @@ void main() {
|
|||||||
expect(stored.isVoice, isTrue);
|
expect(stored.isVoice, isTrue);
|
||||||
expect(stored.voiceId, equals('00112233'));
|
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<void>.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));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user