mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 08:20:36 +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) {
|
||||
// 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
|
||||
|
||||
@@ -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<Contact> 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);
|
||||
|
||||
@@ -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<String, MessageContactLocation> _messageContactLocations = {};
|
||||
|
||||
// Track pending sent messages by expected ACK/TAG
|
||||
final Map<int, Message> _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<void> _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();
|
||||
}
|
||||
|
||||
@@ -403,25 +403,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
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<ContactsProvider>(
|
||||
context,
|
||||
@@ -433,7 +425,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
);
|
||||
|
||||
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<SettingsScreen> {
|
||||
AppLocalizations.of(context)!.loadedSampleData(
|
||||
teamCount,
|
||||
channelCount,
|
||||
sarMessages.length,
|
||||
channelMessages.length,
|
||||
sampleMessages.messages.where((m) => m.isSarMarker).length,
|
||||
sampleMessages.messages.length,
|
||||
),
|
||||
),
|
||||
backgroundColor: Colors.green,
|
||||
|
||||
@@ -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<MeshMapNode>? _cachedNodes;
|
||||
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();
|
||||
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<String, dynamic>>()
|
||||
.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;
|
||||
|
||||
@@ -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<void> saveMessages(List<Message> messages) async {
|
||||
Future<void> saveMessages(
|
||||
List<Message> messages, {
|
||||
Map<String, MessageContactLocation> 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 = <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(
|
||||
'✅ [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
|
||||
Future<List<Message>> 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');
|
||||
|
||||
@@ -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<Contact> generateContacts({
|
||||
required LatLng centerLocation,
|
||||
@@ -113,7 +167,7 @@ class SampleDataGenerator {
|
||||
}
|
||||
|
||||
/// Generate sample SAR markers around a center location
|
||||
static List<Message> generateSarMarkerMessages({
|
||||
static SampleMessageBatch generateSarMarkerMessages({
|
||||
required LatLng centerLocation,
|
||||
required AppLocalizations l10n,
|
||||
int foundPersonCount = 2,
|
||||
@@ -122,6 +176,7 @@ class SampleDataGenerator {
|
||||
int objectCount = 1,
|
||||
}) {
|
||||
final messages = <Message>[];
|
||||
final contactLocations = <String, MessageContactLocation>{};
|
||||
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<Message> 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 = <Message>[];
|
||||
final contactLocations = <String, MessageContactLocation>{};
|
||||
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<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/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<MessageBubble> {
|
||||
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 = <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
|
||||
void didUpdateWidget(MessageBubble oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
@@ -356,6 +455,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
final radioSf = connectionProvider.deviceInfo.radioSf;
|
||||
final radioCr = connectionProvider.deviceInfo.radioCr;
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
final messagesProvider = context.read<MessagesProvider>();
|
||||
final voiceProvider = context.read<VoiceProvider>();
|
||||
final imageProvider = context.read<ip.ImageProvider>();
|
||||
final selfPublicKey = connectionProvider.deviceInfo.publicKey;
|
||||
@@ -397,6 +497,10 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
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<MessageBubble> {
|
||||
'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<MessageBubble> {
|
||||
? 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<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,
|
||||
),
|
||||
),
|
||||
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<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),
|
||||
|
||||
// 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<MessageBubble> {
|
||||
),
|
||||
);
|
||||
|
||||
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)],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
|
||||
Future<_TraceResult> _loadTrace() async {
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
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<MessageTraceSheet> {
|
||||
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<MessageTraceSheet> {
|
||||
height: 34,
|
||||
child: CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: entry.key == 0
|
||||
backgroundColor:
|
||||
entry.key == 0
|
||||
? Colors.green
|
||||
: (entry.key ==
|
||||
trace
|
||||
.matchedPathNodes
|
||||
.whereType<MeshMapNode>()
|
||||
trace.matchedPathNodes
|
||||
.whereType<
|
||||
MeshMapNode
|
||||
>()
|
||||
.length -
|
||||
1
|
||||
? Colors.red
|
||||
@@ -216,7 +228,8 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
'${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<MessageTraceSheet> {
|
||||
],
|
||||
)
|
||||
: 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)
|
||||
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<MessageTraceSheet> {
|
||||
|
||||
MeshMapNode? _bestNodeForPrefix(List<MeshMapNode> 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<MessageTraceSheet> {
|
||||
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<MessageTraceSheet> {
|
||||
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<MessageTraceSheet> {
|
||||
final result = <MeshMapNode?>[];
|
||||
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<MessageTraceSheet> {
|
||||
|
||||
List<MeshMapNode> 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<MessageTraceSheet> {
|
||||
}) {
|
||||
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;
|
||||
|
||||
@@ -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 '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<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