diff --git a/lib/models/message_reception_details.dart b/lib/models/message_reception_details.dart index 40cc70d..d689058 100644 --- a/lib/models/message_reception_details.dart +++ b/lib/models/message_reception_details.dart @@ -30,6 +30,7 @@ class MessageReceptionDetails { final int? senderToReceiptMs; final int? estimatedTransmitMs; final int? postTransmitDelayMs; + final int receivedCopies; const MessageReceptionDetails({ required this.capturedAt, @@ -40,6 +41,7 @@ class MessageReceptionDetails { this.senderToReceiptMs, this.estimatedTransmitMs, this.postTransmitDelayMs, + this.receivedCopies = 1, }); String? get pathBytesHex => pathBytes == null || pathBytes!.isEmpty @@ -56,9 +58,60 @@ class MessageReceptionDetails { 'senderToReceiptMs': senderToReceiptMs, 'estimatedTransmitMs': estimatedTransmitMs, 'postTransmitDelayMs': postTransmitDelayMs, + 'receivedCopies': receivedCopies, }; } + MessageReceptionDetails copyWith({ + DateTime? capturedAt, + DateTime? packetLoggedAt, + int? rssiDbm, + double? snrDb, + List? pathBytes, + int? senderToReceiptMs, + int? estimatedTransmitMs, + int? postTransmitDelayMs, + int? receivedCopies, + }) { + return MessageReceptionDetails( + capturedAt: capturedAt ?? this.capturedAt, + packetLoggedAt: packetLoggedAt ?? this.packetLoggedAt, + rssiDbm: rssiDbm ?? this.rssiDbm, + snrDb: snrDb ?? this.snrDb, + pathBytes: pathBytes ?? this.pathBytes, + senderToReceiptMs: senderToReceiptMs ?? this.senderToReceiptMs, + estimatedTransmitMs: estimatedTransmitMs ?? this.estimatedTransmitMs, + postTransmitDelayMs: postTransmitDelayMs ?? this.postTransmitDelayMs, + receivedCopies: receivedCopies ?? this.receivedCopies, + ); + } + + static MessageReceptionDetails mergeDuplicate({ + MessageReceptionDetails? existing, + MessageReceptionDetails? incoming, + }) { + final base = + existing ?? + incoming ?? + MessageReceptionDetails(capturedAt: DateTime.now()); + + return base.copyWith( + capturedAt: incoming?.capturedAt ?? existing?.capturedAt, + packetLoggedAt: incoming?.packetLoggedAt ?? existing?.packetLoggedAt, + rssiDbm: incoming?.rssiDbm ?? existing?.rssiDbm, + snrDb: incoming?.snrDb ?? existing?.snrDb, + pathBytes: incoming?.pathBytes ?? existing?.pathBytes, + senderToReceiptMs: + incoming?.senderToReceiptMs ?? existing?.senderToReceiptMs, + estimatedTransmitMs: + incoming?.estimatedTransmitMs ?? existing?.estimatedTransmitMs, + postTransmitDelayMs: + incoming?.postTransmitDelayMs ?? existing?.postTransmitDelayMs, + receivedCopies: + (existing?.receivedCopies ?? 1) + (incoming?.receivedCopies ?? 1), + ); + } + static MessageReceptionDetails? fromJson(Map json) { final capturedAtMillis = json['capturedAtMillis']; if (capturedAtMillis is! int) { @@ -81,6 +134,7 @@ class MessageReceptionDetails { senderToReceiptMs: json['senderToReceiptMs'] as int?, estimatedTransmitMs: json['estimatedTransmitMs'] as int?, postTransmitDelayMs: json['postTransmitDelayMs'] as int?, + receivedCopies: json['receivedCopies'] as int? ?? 1, ); } } diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 77e488a..5e8125c 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -17,6 +17,7 @@ import '../services/nearest_router_selector.dart'; import '../services/packet_capture_storage_service.dart'; import '../services/path_history_service.dart'; import '../services/route_hash_preferences.dart'; +import '../services/notification_service.dart'; import '../models/contact.dart'; import '../models/message.dart'; import '../models/ble_packet_log.dart'; @@ -59,6 +60,9 @@ class _DirectMessageRouteSession { /// Main App Provider - coordinates all other providers class AppProvider with ChangeNotifier { static const int _maxDirectPayloadHops = 3; + static const double _lowBatteryThresholdPercent = 30.0; + static const double _lowBatteryResetThresholdPercent = 35.0; + static const Duration _lowBatteryCheckInterval = Duration(minutes: 5); @visibleForTesting static bool isDeletedChannelInfo( int channelIdx, @@ -88,6 +92,7 @@ class AppProvider with ChangeNotifier { LocationTrackingService(); final PacketCaptureStorageService packetCaptureStorageService = PacketCaptureStorageService(); + final NotificationService _notificationService = NotificationService(); bool _isInitialized = false; bool get isInitialized => _isInitialized; @@ -117,8 +122,6 @@ class AppProvider with ChangeNotifier { bool get isVoiceNoiseSuppressionEnabled => _isVoiceNoiseSuppressionEnabled; double _messageFontScale = 1.0; double get messageFontScale => _messageFontScale; - bool _autoAddDiscoveredContacts = false; - bool get autoAddDiscoveredContacts => _autoAddDiscoveredContacts; bool _autoRouteRotationEnabled = MessagingRoutePreferences.defaultAutoRouteRotationEnabled; bool get autoRouteRotationEnabled => _autoRouteRotationEnabled; @@ -150,11 +153,13 @@ class AppProvider with ChangeNotifier { _pendingMediaSwarmResponses = {}; bool _fastLocationScreenActive = false; Timer? _packetCaptureFlushTimer; + Timer? _lowBatteryCheckTimer; String? _lastPersistedPacketSignature; bool _isPersistingPacketCapture = false; bool _wasDeviceConnected = false; bool _hasCompletedConnectionBootstrap = false; bool _isReconnectSyncInProgress = false; + final Set _lowBatteryNotifiedNodeIds = {}; AppProvider({ required this.connectionProvider, @@ -181,10 +186,10 @@ class AppProvider with ChangeNotifier { _loadVoiceEchoCancellationEnabled(); _loadVoiceNoiseSuppressionEnabled(); _loadMessageFontScale(); - _loadAutoAddDiscoveredContacts(); _loadMessagingRouteSettings(); unawaited(_pathHistoryService.initialize()); _startPacketCapturePersistence(); + _startLowBatteryWatcher(); _syncDrawingsOnStartup(); // Sync drawings immediately after providers load _isInitialized = true; } @@ -197,6 +202,86 @@ class AppProvider with ChangeNotifier { unawaited(_flushPacketCaptureLogs()); } + void _startLowBatteryWatcher() { + _lowBatteryCheckTimer?.cancel(); + _lowBatteryCheckTimer = Timer.periodic(_lowBatteryCheckInterval, (_) { + unawaited(_checkLowBatteryAlerts()); + }); + unawaited(_checkLowBatteryAlerts()); + } + + Future _checkLowBatteryAlerts() async { + final recoveredIds = {}; + + final deviceBattery = connectionProvider.deviceInfo.batteryPercent; + if (deviceBattery != null && + deviceBattery > _lowBatteryResetThresholdPercent) { + recoveredIds.add('device'); + } + + for (final contact in contactsProvider.contacts) { + if (contact.isChannel) continue; + final battery = contact.displayBattery; + if (battery == null) continue; + if (battery > _lowBatteryResetThresholdPercent) { + recoveredIds.add(contact.publicKeyHex); + } + } + + if (recoveredIds.isNotEmpty) { + _lowBatteryNotifiedNodeIds.removeAll(recoveredIds); + } + + if (connectionProvider.deviceInfo.isConnected && deviceBattery != null) { + await _notifyLowBatteryIfNeeded( + nodeId: 'device', + nodeName: + connectionProvider.deviceInfo.selfName?.trim().isNotEmpty == true + ? connectionProvider.deviceInfo.selfName!.trim() + : (connectionProvider.deviceInfo.displayName ?? 'Connected device'), + batteryPercent: deviceBattery, + isCurrentDevice: true, + ); + } + + for (final contact in contactsProvider.contacts) { + if (contact.isChannel) continue; + final battery = contact.displayBattery; + if (battery == null) continue; + + await _notifyLowBatteryIfNeeded( + nodeId: contact.publicKeyHex, + nodeName: contact.displayName, + batteryPercent: battery, + isCurrentDevice: false, + ); + } + } + + Future _notifyLowBatteryIfNeeded({ + required String nodeId, + required String nodeName, + required double batteryPercent, + required bool isCurrentDevice, + }) async { + if (batteryPercent >= _lowBatteryThresholdPercent) { + return; + } + if (_lowBatteryNotifiedNodeIds.contains(nodeId)) { + return; + } + + final shown = await _notificationService.showLowBatteryNotification( + nodeId: nodeId, + nodeName: nodeName, + batteryPercent: batteryPercent, + isCurrentDevice: isCurrentDevice, + ); + if (shown) { + _lowBatteryNotifiedNodeIds.add(nodeId); + } + } + String _packetLogSignature(BlePacketLog log) { final prefix = log.rawData.length <= 12 ? log.rawData @@ -251,7 +336,8 @@ class AppProvider with ChangeNotifier { Future _syncDrawingsOnStartup() async { // Wait for both MessagesProvider and DrawingProvider to finish initializing int attempts = 0; - while ((!messagesProvider.isInitialized || !drawingProvider.isInitialized) && + while ((!messagesProvider.isInitialized || + !drawingProvider.isInitialized) && attempts < 40) { await Future.delayed(const Duration(milliseconds: 50)); attempts++; @@ -649,30 +735,6 @@ class AppProvider with ChangeNotifier { } } - /// Load auto-add discovered contacts setting from shared preferences. - Future _loadAutoAddDiscoveredContacts() async { - try { - final prefs = await SharedPreferences.getInstance(); - _autoAddDiscoveredContacts = - prefs.getBool('auto_add_discovered_contacts') ?? false; - notifyListeners(); - } catch (e) { - debugPrint('Error loading auto-add discovered contacts setting: $e'); - } - } - - /// Toggle auto-add discovered contacts on/off. - Future toggleAutoAddDiscoveredContacts(bool enabled) async { - try { - _autoAddDiscoveredContacts = enabled; - final prefs = await SharedPreferences.getInstance(); - await prefs.setBool('auto_add_discovered_contacts', enabled); - notifyListeners(); - } catch (e) { - debugPrint('Error saving auto-add discovered contacts setting: $e'); - } - } - Future _loadMessagingRouteSettings() async { try { _autoRouteRotationEnabled = @@ -1432,20 +1494,22 @@ class AppProvider with ChangeNotifier { } }); } else { - if (_autoAddDiscoveredContacts) { - debugPrint(' Unknown contact - auto-add enabled, fetching details'); - Future.delayed(const Duration(milliseconds: 100), () { - if (connectionProvider.deviceInfo.isConnected) { - connectionProvider.getContact(publicKey); - } - }); - } else { - contactsProvider.addPendingAdvert( - publicKey, - devicePublicKey: connectionProvider.deviceInfo.publicKey, - ); - debugPrint( - ' Unknown contact - added to pending adverts list and waiting for details', + final isNewPendingAdvert = contactsProvider.addPendingAdvert( + publicKey, + devicePublicKey: connectionProvider.deviceInfo.publicKey, + ); + debugPrint( + ' Unknown contact - added to pending adverts list and waiting for manual resolution', + ); + if (isNewPendingAdvert) { + final keyHex = publicKey + .take(6) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + unawaited( + _notificationService.showContactDiscoveredNotification( + contactKey: keyHex, + ), ); } } @@ -3074,6 +3138,7 @@ class AppProvider with ChangeNotifier { _imageMissingRetryAttempts.clear(); _voiceSessionSenderKey6.clear(); _imageSessionSenderKey6.clear(); + _lowBatteryNotifiedNodeIds.clear(); notifyListeners(); } @@ -3094,6 +3159,7 @@ class AppProvider with ChangeNotifier { @override void dispose() { _packetCaptureFlushTimer?.cancel(); + _lowBatteryCheckTimer?.cancel(); unawaited(_flushPacketCaptureLogs()); // Remove connection state listener connectionProvider.removeListener(_handleConnectionStateChange); diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index 81ab02e..4f8e70c 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -2255,7 +2255,7 @@ class ConnectionProvider with ChangeNotifier { if (!_activeService.isConnected) { _error = 'Not connected to device'; notifyListeners(); - return; + throw Exception(_error); } try { @@ -2263,6 +2263,7 @@ class ConnectionProvider with ChangeNotifier { } catch (e) { _error = 'Failed to remove contact: $e'; notifyListeners(); + rethrow; } } diff --git a/lib/providers/contacts_provider.dart b/lib/providers/contacts_provider.dart index d53c7c7..0d81377 100644 --- a/lib/providers/contacts_provider.dart +++ b/lib/providers/contacts_provider.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:latlong2/latlong.dart'; @@ -66,6 +67,8 @@ class ContactsProvider with ChangeNotifier { bool _isInitialized = false; bool _isPersisting = false; bool _persistRequested = false; + bool _isPersistingPendingAdverts = false; + bool _persistPendingAdvertsRequested = false; // Add default public channel on initialization ContactsProvider() { @@ -85,6 +88,7 @@ class ContactsProvider with ChangeNotifier { ); final storedContacts = await _storageService.loadContacts(); final storedGroups = await _storageService.loadContactGroups(); + final storedPendingAdverts = await _storageService.loadPendingAdverts(); // Add stored contacts (excluding any with all-zeros public key) const publicChannelKey = @@ -101,6 +105,7 @@ class ContactsProvider with ChangeNotifier { _savedContactGroups ..clear() ..addAll(storedGroups); + _restorePendingAdverts(storedPendingAdverts); debugPrint( '✅ [ContactsProvider] Early loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups', ); @@ -133,6 +138,7 @@ class ContactsProvider with ChangeNotifier { excludePublicKey: devicePublicKey, ); final storedGroups = await _storageService.loadContactGroups(); + final storedPendingAdverts = await _storageService.loadPendingAdverts(); // Add stored contacts (excluding any with all-zeros public key) const publicChannelKey = @@ -149,6 +155,10 @@ class ContactsProvider with ChangeNotifier { _savedContactGroups ..clear() ..addAll(storedGroups); + _restorePendingAdverts( + storedPendingAdverts, + devicePublicKey: devicePublicKey, + ); debugPrint( '✅ [ContactsProvider] Loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups', ); @@ -212,14 +222,7 @@ class ContactsProvider with ChangeNotifier { try { while (_persistRequested) { _persistRequested = false; - // Don't persist the public channel pseudo-contact (all zeros key) - const publicChannelKey = - '0000000000000000000000000000000000000000000000000000000000000000'; - final contactsToSave = _contacts.entries - .where((entry) => entry.key != publicChannelKey) - .map((entry) => entry.value) - .toList(); - await _storageService.saveContacts(contactsToSave); + await _storageService.saveContacts(_contactsForStorage()); } } catch (e) { debugPrint('❌ [ContactsProvider] Error persisting contacts: $e'); @@ -228,6 +231,24 @@ class ContactsProvider with ChangeNotifier { } } + Future _persistPendingAdverts() async { + _persistPendingAdvertsRequested = true; + if (_isPersistingPendingAdverts) return; + _isPersistingPendingAdverts = true; + try { + while (_persistPendingAdvertsRequested) { + _persistPendingAdvertsRequested = false; + await _storageService.savePendingAdverts( + _pendingAdverts.values.map(_pendingAdvertToJson).toList(), + ); + } + } catch (e) { + debugPrint('❌ [ContactsProvider] Error persisting pending adverts: $e'); + } finally { + _isPersistingPendingAdverts = false; + } + } + List get contacts => _contacts.values.toList(); List get savedContactGroups => List.from(_savedContactGroups) @@ -450,6 +471,7 @@ class ContactsProvider with ChangeNotifier { ' ✅ Contact added/updated. Total contacts: ${_contacts.length}, channels: ${channels.length}', ); _persistContacts(); + _persistPendingAdverts(); notifyListeners(); debugPrint(' 🔔 notifyListeners() called'); } @@ -481,6 +503,7 @@ class ContactsProvider with ChangeNotifier { ); } _persistContacts(); + _persistPendingAdverts(); notifyListeners(); } @@ -1073,9 +1096,9 @@ class ContactsProvider with ChangeNotifier { /// Add or refresh a pending advert entry from PUSH_CODE_ADVERT (0x80). /// Excludes self key and existing contacts. - void addPendingAdvert(Uint8List publicKey, {Uint8List? devicePublicKey}) { + bool addPendingAdvert(Uint8List publicKey, {Uint8List? devicePublicKey}) { if (devicePublicKey != null && publicKey.matches(devicePublicKey)) { - return; + return false; } final keyHex = publicKey @@ -1083,20 +1106,26 @@ class ContactsProvider with ChangeNotifier { .join(''); if (_contacts.containsKey(keyHex)) { _pendingAdverts.remove(keyHex); - return; + _persistPendingAdverts(); + return false; } final existing = _pendingAdverts[keyHex]; final now = DateTime.now(); if (existing != null) { _pendingAdverts[keyHex] = existing.copyWith(receivedAt: now); + _persistPendingAdverts(); + notifyListeners(); + return false; } else { _pendingAdverts[keyHex] = PendingAdvert( publicKey: Uint8List.fromList(publicKey), receivedAt: now, ); + _persistPendingAdverts(); + notifyListeners(); + return true; } - notifyListeners(); } /// Find contact by name @@ -1155,6 +1184,7 @@ class ContactsProvider with ChangeNotifier { _pendingAdverts.clear(); _ensurePublicChannelExists(); _persistContacts(); + _persistPendingAdverts(); notifyListeners(); } @@ -1177,6 +1207,7 @@ class ContactsProvider with ChangeNotifier { _contacts.remove(publicKeyHex); _pendingAdverts.remove(publicKeyHex); _persistContacts(); + _persistPendingAdverts(); notifyListeners(); } @@ -1185,6 +1216,74 @@ class ContactsProvider with ChangeNotifier { return await _storageService.getStorageStats(); } + Future persistNow() async { + await _storageService.saveContacts(_contactsForStorage()); + } + + List _contactsForStorage() { + // Don't persist the public channel pseudo-contact (all zeros key) + const publicChannelKey = + '0000000000000000000000000000000000000000000000000000000000000000'; + return _contacts.entries + .where((entry) => entry.key != publicChannelKey) + .map((entry) => entry.value) + .toList(); + } + + void _restorePendingAdverts( + List> storedPendingAdverts, { + Uint8List? devicePublicKey, + }) { + _pendingAdverts.clear(); + for (final json in storedPendingAdverts) { + final advert = _pendingAdvertFromJson(json); + if (advert == null) continue; + if (devicePublicKey != null && + advert.publicKey.matches(devicePublicKey)) { + continue; + } + if (_contacts.containsKey(advert.publicKeyHex)) { + continue; + } + _pendingAdverts[advert.publicKeyHex] = advert; + } + } + + Map _pendingAdvertToJson(PendingAdvert advert) { + return { + 'publicKey': base64Encode(advert.publicKey), + 'receivedAtMillis': advert.receivedAt.millisecondsSinceEpoch, + 'signedEncodedPathLen': advert.signedEncodedPathLen, + 'paddedPathBytes': advert.paddedPathBytes == null + ? null + : base64Encode(advert.paddedPathBytes!), + }; + } + + PendingAdvert? _pendingAdvertFromJson(Map json) { + try { + return PendingAdvert( + publicKey: Uint8List.fromList( + base64Decode(json['publicKey'] as String), + ), + receivedAt: DateTime.fromMillisecondsSinceEpoch( + json['receivedAtMillis'] as int, + ), + signedEncodedPathLen: json['signedEncodedPathLen'] as int?, + paddedPathBytes: json['paddedPathBytes'] == null + ? null + : Uint8List.fromList( + base64Decode(json['paddedPathBytes'] as String), + ), + ); + } catch (e) { + debugPrint( + '❌ [ContactsProvider] Error parsing pending advert from JSON: $e', + ); + return null; + } + } + /// Get contact count by type Map get contactCounts { return { diff --git a/lib/providers/messages_provider.dart b/lib/providers/messages_provider.dart index 36e2fae..017d1d2 100644 --- a/lib/providers/messages_provider.dart +++ b/lib/providers/messages_provider.dart @@ -18,6 +18,8 @@ import '../utils/image_message_parser.dart'; import '../l10n/app_localizations.dart'; import 'helpers/message_retry_manager.dart'; +typedef DisplayMessageEntry = ({Message message, int occurrenceCount}); + /// Messages Provider - manages message history and SAR markers class MessagesProvider with ChangeNotifier { static const Duration _channelEchoWarningDelay = Duration(seconds: 12); @@ -591,8 +593,12 @@ class MessagesProvider with ChangeNotifier { if (contactLocationSnapshot != null) { _messageContactLocations[existingId] = contactLocationSnapshot; } + _messageReceptionDetails[existingId] = + MessageReceptionDetails.mergeDuplicate( + existing: _messageReceptionDetails[existingId], + incoming: receptionDetailsSnapshot, + ); if (receptionDetailsSnapshot != null) { - _messageReceptionDetails[existingId] = receptionDetailsSnapshot; final existingMessage = _messages[duplicateIndex]; final duplicatePathBytes = receptionDetailsSnapshot.pathBytes; if (existingMessage.pathBytes == null && @@ -775,7 +781,13 @@ class MessagesProvider with ChangeNotifier { enhancedMessage = _resolveSenderNameIfNeeded(enhancedMessage); // Check for duplicates - if (_findDuplicateMessageIndex(enhancedMessage) != -1) { + final duplicateIndex = _findDuplicateMessageIndex(enhancedMessage); + if (duplicateIndex != -1) { + final existingId = _messages[duplicateIndex].id; + _messageReceptionDetails[existingId] = + MessageReceptionDetails.mergeDuplicate( + existing: _messageReceptionDetails[existingId], + ); duplicateCount++; continue; // Skip duplicate } @@ -1026,7 +1038,9 @@ class MessagesProvider with ChangeNotifier { /// overlapping serialization and redundant SharedPreferences writes. Future _persistMessages() async { _persistRequested = true; - if (_isPersisting) return; // A write is in flight; it will pick up our changes. + if (_isPersisting) { + return; // A write is in flight; it will pick up our changes. + } _isPersisting = true; try { while (_persistRequested) { @@ -1121,6 +1135,35 @@ class MessagesProvider with ChangeNotifier { return sorted.take(count).toList(); } + List buildDisplayMessages(Iterable messages) { + final entries = []; + + for (final message in messages) { + final occurrenceCount = _messageOccurrenceCount(message); + final existingIndex = entries.indexWhere( + (entry) => + entry.message.text == message.text && + _matchesDuplicateScope(entry.message, message), + ); + + if (existingIndex == -1) { + entries.add((message: message, occurrenceCount: occurrenceCount)); + continue; + } + + final existingEntry = entries[existingIndex]; + entries[existingIndex] = ( + message: existingEntry.message, + occurrenceCount: existingEntry.occurrenceCount + occurrenceCount, + ); + } + + return entries; + } + + int _messageOccurrenceCount(Message message) => + _messageReceptionDetails[message.id]?.receivedCopies ?? 1; + /// Get messages from last N hours List getMessagesSince(Duration duration) { final cutoff = DateTime.now().subtract(duration); diff --git a/lib/screens/contacts_tab.dart b/lib/screens/contacts_tab.dart index fea4164..ca747d2 100644 --- a/lib/screens/contacts_tab.dart +++ b/lib/screens/contacts_tab.dart @@ -34,8 +34,6 @@ class ContactsTab extends StatefulWidget { class _ContactsTabState extends State { Position? _currentPosition; - final Set _resolvingAdvertKeys = {}; - bool _isResolvingPendingBatch = false; final Map _sectionFilters = { ContactSection.teamMembers: '', ContactSection.repeaters: '', @@ -99,57 +97,6 @@ class _ContactsTabState extends State { await _getCurrentLocation(); } - Future _handleResolveAdvert(PendingAdvert advert) async { - final keyHex = advert.publicKeyHex; - if (_resolvingAdvertKeys.contains(keyHex)) return; - - setState(() { - _resolvingAdvertKeys.add(keyHex); - }); - - try { - await context.read().getContact(advert.publicKey); - } finally { - if (mounted) { - setState(() { - _resolvingAdvertKeys.remove(keyHex); - }); - } - } - } - - void _schedulePendingAdvertResolution( - List pendingAdverts, - ConnectionProvider connectionProvider, - ) { - if (_isResolvingPendingBatch || - !connectionProvider.deviceInfo.isConnected || - pendingAdverts.isEmpty) { - return; - } - - final advertsToResolve = pendingAdverts - .where((advert) => !_resolvingAdvertKeys.contains(advert.publicKeyHex)) - .toList(); - if (advertsToResolve.isEmpty) { - return; - } - - WidgetsBinding.instance.addPostFrameCallback((_) async { - if (!mounted || _isResolvingPendingBatch) return; - - _isResolvingPendingBatch = true; - try { - for (final advert in advertsToResolve) { - if (!mounted) break; - await _handleResolveAdvert(advert); - } - } finally { - _isResolvingPendingBatch = false; - } - }); - } - /// Calculate distance between two points in meters double _calculateDistanceInMeters( double lat1, @@ -181,15 +128,6 @@ class _ContactsTabState extends State { } } - String _formatRelativeTime(BuildContext context, DateTime when) { - final l10n = AppLocalizations.of(context)!; - final diff = DateTime.now().difference(when); - if (diff.inMinutes < 1) return l10n.justNow; - if (diff.inMinutes < 60) return l10n.minutesAgo(diff.inMinutes); - if (diff.inHours < 24) return l10n.hoursAgo(diff.inHours); - return l10n.daysAgo(diff.inDays); - } - List _filterContactsForSection( List contacts, ContactSection section, @@ -593,7 +531,6 @@ class _ContactsTabState extends State { body: Consumer( builder: (context, contactsProvider, child) { final messagesProvider = context.watch(); - final connectionProvider = context.watch(); final allChatContacts = _sortContacts( contactsProvider.chatContacts, ContactSection.teamMembers, @@ -673,17 +610,12 @@ class _ContactsTabState extends State { final showRepeatersSection = allRepeaters.isNotEmpty; final showRoomsSection = allRooms.isNotEmpty; final showChannelsSection = allChannels.isNotEmpty; - final pendingAdverts = contactsProvider.pendingAdverts; - - _schedulePendingAdvertResolution(pendingAdverts, connectionProvider); - // Check if there are any displayable contacts final hasDisplayableContacts = allChatContacts.isNotEmpty || allRepeaters.isNotEmpty || allRooms.isNotEmpty || - allChannels.isNotEmpty || - pendingAdverts.isNotEmpty; + allChannels.isNotEmpty; if (!hasDisplayableContacts) { return Center( @@ -838,27 +770,6 @@ class _ContactsTabState extends State { const Divider(height: 32), ], - // Pending adverts are kept below resolved sections while we load details. - if (pendingAdverts.isNotEmpty) ...[ - _SectionHeader( - title: l10n.pending, - count: pendingAdverts.length, - icon: Icons.person_search, - ), - ...pendingAdverts.map( - (advert) => _PendingAdvertTile( - advert: advert, - subtitle: - '${l10n.publicKey}: ${advert.shortDisplayKey}\n${l10n.lastSeen}: ${_formatRelativeTime(context, advert.receivedAt)}', - isResolving: _resolvingAdvertKeys.contains( - advert.publicKeyHex, - ), - onResolve: () => _handleResolveAdvert(advert), - ), - ), - const Divider(height: 32), - ], - // Channels (visible in both simple and advanced mode) if (showChannelsSection) ...[ _SectionHeader( @@ -1263,46 +1174,6 @@ enum ContactSortMode { lastSeen, distance } enum ContactSection { teamMembers, repeaters, rooms, channels } -class _PendingAdvertTile extends StatelessWidget { - final PendingAdvert advert; - final String subtitle; - final bool isResolving; - final VoidCallback onResolve; - - const _PendingAdvertTile({ - required this.advert, - required this.subtitle, - required this.isResolving, - required this.onResolve, - }); - - @override - Widget build(BuildContext context) { - return Card( - margin: const EdgeInsets.only(bottom: 8), - child: ListTile( - leading: const CircleAvatar(child: Icon(Icons.campaign_outlined)), - title: Text( - advert.shortDisplayKey, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - subtitle: Text(subtitle), - trailing: isResolving - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : IconButton( - icon: const Icon(Icons.person_add_alt_1), - tooltip: 'Resolve contact', - onPressed: onResolve, - ), - ), - ); - } -} - class _RenderedSavedGroup { final SavedContactGroup group; final List contacts; diff --git a/lib/screens/device_config_screen.dart b/lib/screens/device_config_screen.dart index c2b2a1e..6e57c6a 100644 --- a/lib/screens/device_config_screen.dart +++ b/lib/screens/device_config_screen.dart @@ -1,8 +1,14 @@ +import 'dart:typed_data'; + import 'package:flutter/material.dart'; import 'package:geolocator/geolocator.dart'; import 'package:provider/provider.dart'; import '../models/device_info.dart'; +import '../models/channel.dart'; +import '../models/contact.dart'; +import '../providers/channels_provider.dart'; import '../providers/connection_provider.dart'; +import '../providers/contacts_provider.dart'; import '../services/validation_service.dart'; import '../l10n/app_localizations.dart'; @@ -14,6 +20,10 @@ class DeviceConfigScreen extends StatefulWidget { } class _DeviceConfigScreenState extends State { + static const int _bulkDeleteBatchSize = 8; + static const Duration _bulkDeleteInterItemDelay = Duration(milliseconds: 120); + static const Duration _bulkDeleteBatchDelay = Duration(milliseconds: 700); + static const Duration _bulkDeleteFinalSyncDelay = Duration(milliseconds: 900); static const List<_RadioPreset> _radioPresets = [ _RadioPreset( id: 'australia', @@ -169,9 +179,12 @@ class _DeviceConfigScreenState extends State { bool _telemetryEnabled = false; bool _repeatEnabled = false; + bool _autoAddDiscoveredContactsEnabled = true; bool _showCustomRadioSettings = false; bool _isSavingPublicInfo = false; bool _isSavingRadioSettings = false; + bool _isClearingContacts = false; + bool _isClearingChannels = false; bool _publicInfoSaved = false; bool _radioSettingsSaved = false; String? _publicInfoError; @@ -252,6 +265,8 @@ class _DeviceConfigScreenState extends State { // Initialize repeat mode from device info (firmware v9+) _repeatEnabled = deviceInfo.clientRepeat ?? false; + _autoAddDiscoveredContactsEnabled = + !(deviceInfo.manualAddContacts ?? false); // Fetch allowed repeat frequencies on open if device supports repeat mode if (deviceInfo.clientRepeat != null && @@ -377,7 +392,6 @@ class _DeviceConfigScreenState extends State { Future _savePublicInfo() async { final connectionProvider = context.read(); - final deviceInfo = connectionProvider.deviceInfo; final validator = ValidationService(); setState(() { @@ -387,6 +401,8 @@ class _DeviceConfigScreenState extends State { }); try { + final manualAddContacts = _autoAddDiscoveredContactsEnabled ? 0 : 1; + // Save name if (_nameController.text.isNotEmpty) { await connectionProvider.setAdvertName(_nameController.text); @@ -425,7 +441,7 @@ class _DeviceConfigScreenState extends State { // Set telemetry modes to "Allow All" (mode 2 for both base and location) final telemetryModes = 0x0A; // binary: 00001010 (base=2, location=2) await connectionProvider.setOtherParams( - manualAddContacts: deviceInfo.manualAddContacts == true ? 1 : 0, + manualAddContacts: manualAddContacts, telemetryModes: telemetryModes, advertLocationPolicy: 1, ); @@ -436,7 +452,7 @@ class _DeviceConfigScreenState extends State { // Set telemetry modes to "Deny" (mode 0) final telemetryModes = 0x00; await connectionProvider.setOtherParams( - manualAddContacts: deviceInfo.manualAddContacts == true ? 1 : 0, + manualAddContacts: manualAddContacts, telemetryModes: telemetryModes, advertLocationPolicy: 0, ); @@ -687,6 +703,205 @@ class _DeviceConfigScreenState extends State { } } + Future _confirmClearAllContacts() async { + final contacts = context + .read() + .contacts + .where((contact) => !contact.isChannel) + .toList(); + if (contacts.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('No device contacts to clear.')), + ); + return; + } + + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Clear all contacts'), + content: Text( + 'This will remove ${contacts.length} contact${contacts.length == 1 ? '' : 's'} from the connected device. Channels and radio settings will not be changed.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(AppLocalizations.of(context)!.cancel), + ), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + foregroundColor: Theme.of(context).colorScheme.onError, + ), + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('Clear contacts'), + ), + ], + ), + ); + + if (confirmed != true || !mounted) return; + + final messenger = ScaffoldMessenger.of(context); + final connectionProvider = context.read(); + final contactsProvider = context.read(); + connectionProvider.clearError(); + + setState(() { + _isClearingContacts = true; + }); + + try { + var processed = 0; + for (final contact in List.from(contacts)) { + await contactsProvider.removeContact( + contact.publicKeyHex, + onRemoveFromDevice: connectionProvider.removeContact, + ); + processed++; + await Future.delayed(_bulkDeleteInterItemDelay); + if (processed % _bulkDeleteBatchSize == 0) { + await Future.delayed(_bulkDeleteBatchDelay); + } + } + + // Flush the updated local contact set to storage immediately. + await contactsProvider.persistNow(); + await Future.delayed(_bulkDeleteFinalSyncDelay); + await connectionProvider.getContacts(); + if (connectionProvider.error != null) { + throw Exception(connectionProvider.error!); + } + + if (!mounted) return; + messenger.showSnackBar( + SnackBar( + content: Text( + 'Cleared ${contacts.length} contact${contacts.length == 1 ? '' : 's'} from the device.', + ), + ), + ); + } catch (e) { + if (!mounted) return; + messenger.showSnackBar( + SnackBar( + content: Text('Failed to clear contacts: $e'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } finally { + if (mounted) { + setState(() { + _isClearingContacts = false; + }); + } + } + } + + Future _confirmClearAllChannels() async { + final channels = context + .read() + .channels + .where((channel) => !channel.isPublicChannel && channel.name.isNotEmpty) + .toList(); + if (channels.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('No custom channels to clear.')), + ); + return; + } + + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Clear all channels'), + content: Text( + 'This will remove ${channels.length} custom channel${channels.length == 1 ? '' : 's'} from the connected device. Contacts and radio settings will not be changed.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(AppLocalizations.of(context)!.cancel), + ), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + foregroundColor: Theme.of(context).colorScheme.onError, + ), + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('Clear channels'), + ), + ], + ), + ); + + if (confirmed != true || !mounted) return; + + final messenger = ScaffoldMessenger.of(context); + final connectionProvider = context.read(); + final channelsProvider = context.read(); + final contactsProvider = context.read(); + connectionProvider.clearError(); + + setState(() { + _isClearingChannels = true; + }); + + try { + var processed = 0; + for (final channel in List.from(channels)) { + await connectionProvider.deleteChannel(channel.index); + processed++; + await Future.delayed(_bulkDeleteInterItemDelay); + if (processed % _bulkDeleteBatchSize == 0) { + await Future.delayed(_bulkDeleteBatchDelay); + } + } + + // Force local channel/contact cache cleanup before the device resync. + for (final channel in channels) { + channelsProvider.removeChannel(channel.index); + final publicKeyBytes = Uint8List(32); + publicKeyBytes[0] = 0xFF; + publicKeyBytes[1] = channel.index; + final publicKeyHex = publicKeyBytes + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(''); + await contactsProvider.removeContact(publicKeyHex); + } + + await contactsProvider.persistNow(); + await Future.delayed(_bulkDeleteFinalSyncDelay); + await connectionProvider.syncChannels(); + if (connectionProvider.error != null) { + throw Exception(connectionProvider.error!); + } + + if (!mounted) return; + messenger.showSnackBar( + SnackBar( + content: Text( + 'Cleared ${channels.length} custom channel${channels.length == 1 ? '' : 's'} from the device.', + ), + ), + ); + } catch (e) { + if (!mounted) return; + messenger.showSnackBar( + SnackBar( + content: Text('Failed to clear channels: $e'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } finally { + if (mounted) { + setState(() { + _isClearingChannels = false; + }); + } + } + } + @override Widget build(BuildContext context) { final deviceInfo = context.watch().deviceInfo; @@ -788,6 +1003,28 @@ class _DeviceConfigScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + _SettingHighlightCard( + icon: _autoAddDiscoveredContactsEnabled + ? Icons.person_add_alt_1 + : Icons.person_add_disabled, + title: 'Auto-add discovered contacts', + description: + 'Control whether the device automatically stores newly discovered contacts.', + accentColor: _autoAddDiscoveredContactsEnabled + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + trailing: Switch( + value: _autoAddDiscoveredContactsEnabled, + onChanged: (value) { + setState(() { + _autoAddDiscoveredContactsEnabled = value; + _publicInfoSaved = false; + _publicInfoError = null; + }); + }, + ), + ), + const SizedBox(height: 18), _SettingHighlightCard( icon: _telemetryEnabled ? Icons.travel_explore @@ -1230,10 +1467,60 @@ class _DeviceConfigScreenState extends State { ), ), const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _isClearingContacts || _isClearingChannels + ? null + : _confirmClearAllContacts, + style: OutlinedButton.styleFrom( + foregroundColor: colorScheme.error, + side: BorderSide(color: colorScheme.error), + minimumSize: const Size.fromHeight(52), + ), + icon: _isClearingContacts + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.people_alt_outlined), + label: const Text('Clear all contacts'), + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _isClearingContacts || _isClearingChannels + ? null + : _confirmClearAllChannels, + style: OutlinedButton.styleFrom( + foregroundColor: colorScheme.error, + side: BorderSide(color: colorScheme.error), + minimumSize: const Size.fromHeight(52), + ), + icon: _isClearingChannels + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.forum_outlined), + label: const Text('Clear all channels'), + ), + ), + const SizedBox(height: 12), SizedBox( width: double.infinity, child: FilledButton.icon( - onPressed: _confirmFactoryReset, + onPressed: _isClearingContacts || _isClearingChannels + ? null + : _confirmFactoryReset, style: FilledButton.styleFrom( backgroundColor: colorScheme.error, foregroundColor: colorScheme.onError, diff --git a/lib/screens/discovery_screen.dart b/lib/screens/discovery_screen.dart new file mode 100644 index 0000000..fc44f99 --- /dev/null +++ b/lib/screens/discovery_screen.dart @@ -0,0 +1,231 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../l10n/app_localizations.dart'; +import '../models/contact.dart'; +import '../providers/connection_provider.dart'; +import '../providers/contacts_provider.dart'; +import '../services/mesh_map_nodes_service.dart'; + +class DiscoveryScreen extends StatefulWidget { + const DiscoveryScreen({super.key}); + + @override + State createState() => _DiscoveryScreenState(); +} + +class _DiscoveryScreenState extends State { + final Set _resolvingAdvertKeys = {}; + bool _isResolvingAll = false; + late final Future> _cachedNodesFuture; + + @override + void initState() { + super.initState(); + _cachedNodesFuture = MeshMapNodesService.loadCachedNodes( + cacheTtl: MeshMapNodesService.traceCacheTtl, + ); + } + + Future _resolveAdvert(PendingAdvert advert) async { + final keyHex = advert.publicKeyHex; + if (_resolvingAdvertKeys.contains(keyHex)) return; + + setState(() { + _resolvingAdvertKeys.add(keyHex); + }); + + try { + await context.read().getContact(advert.publicKey); + } finally { + if (mounted) { + setState(() { + _resolvingAdvertKeys.remove(keyHex); + }); + } + } + } + + Future _resolveAll(List adverts) async { + if (_isResolvingAll || adverts.isEmpty) return; + + setState(() { + _isResolvingAll = true; + }); + + try { + for (final advert in adverts) { + if (!mounted) break; + await _resolveAdvert(advert); + } + } finally { + if (mounted) { + setState(() { + _isResolvingAll = false; + }); + } + } + } + + String _formatRelativeTime(BuildContext context, DateTime when) { + final l10n = AppLocalizations.of(context)!; + final diff = DateTime.now().difference(when); + if (diff.inMinutes < 1) return l10n.justNow; + if (diff.inMinutes < 60) return l10n.minutesAgo(diff.inMinutes); + if (diff.inHours < 24) return l10n.hoursAgo(diff.inHours); + return l10n.daysAgo(diff.inDays); + } + + String _displayNameForAdvert( + PendingAdvert advert, + ContactsProvider contactsProvider, + List cachedNodes, + ) { + Contact? existingMatch; + for (final contact in contactsProvider.contacts) { + if (contact.publicKeyHex == advert.publicKeyHex) { + existingMatch = contact; + break; + } + } + if (existingMatch != null && existingMatch.displayName.trim().isNotEmpty) { + return existingMatch.displayName; + } + + MeshMapNode? cachedMatch; + for (final node in cachedNodes) { + if (node.publicKey == advert.publicKeyHex.toLowerCase()) { + cachedMatch = node; + break; + } + } + if (cachedMatch != null && cachedMatch.name.trim().isNotEmpty) { + return cachedMatch.name; + } + + return advert.shortDisplayKey; + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + + return Scaffold( + appBar: AppBar(title: const Text('Discovery')), + body: FutureBuilder>( + future: _cachedNodesFuture, + builder: (context, nodesSnapshot) => + Consumer2( + builder: (context, contactsProvider, connectionProvider, child) { + final pendingAdverts = contactsProvider.pendingAdverts; + final isConnected = connectionProvider.deviceInfo.isConnected; + final cachedNodes = nodesSnapshot.data ?? const []; + + if (pendingAdverts.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.person_search_outlined, + size: 64, + color: Theme.of(context).disabledColor, + ), + const SizedBox(height: 16), + Text( + 'No pending discoveries', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + 'Unknown adverts will appear here until you choose to resolve them.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + ); + } + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Card( + child: ListTile( + leading: const Icon(Icons.person_search), + title: Text( + 'Pending discoveries (${pendingAdverts.length})', + ), + subtitle: const Text( + 'Resolve entries manually so they do not auto-populate contacts.', + ), + trailing: FilledButton.icon( + onPressed: isConnected && !_isResolvingAll + ? () => _resolveAll(pendingAdverts) + : null, + icon: _isResolvingAll + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.download_for_offline_outlined), + label: const Text('Resolve all'), + ), + ), + ), + const SizedBox(height: 12), + ...pendingAdverts.map((advert) { + final isResolving = _resolvingAdvertKeys.contains( + advert.publicKeyHex, + ); + final displayName = _displayNameForAdvert( + advert, + contactsProvider, + cachedNodes, + ); + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + leading: const CircleAvatar( + child: Icon(Icons.campaign_outlined), + ), + title: Text( + displayName, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text( + '${l10n.publicKey}: ${advert.shortDisplayKey}\n' + '${l10n.lastSeen}: ${_formatRelativeTime(context, advert.receivedAt)}', + ), + trailing: isResolving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : IconButton( + icon: const Icon(Icons.person_add_alt_1), + tooltip: 'Resolve contact', + onPressed: isConnected + ? () => _resolveAdvert(advert) + : null, + ), + ), + ); + }), + ], + ); + }, + ), + ), + ); + } +} diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 6d1e2ac..48fe9a7 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -12,6 +12,7 @@ import '../providers/contacts_provider.dart'; import '../theme/app_theme.dart'; import 'messages_tab.dart'; import 'contacts_tab.dart'; +import 'discovery_screen.dart'; import 'sensors_tab.dart'; import 'map_tab.dart'; import 'repeaters_map_screen.dart'; @@ -712,6 +713,39 @@ class _HomeScreenState extends State ), ); + items.add( + PopupMenuItem( + child: Row( + children: [ + const Icon(Icons.person_search), + const SizedBox(width: 8), + Consumer( + builder: (context, contactsProvider, child) { + final pendingCount = + contactsProvider.pendingAdverts.length; + return Text( + pendingCount > 0 + ? 'Discovery ($pendingCount)' + : 'Discovery', + ); + }, + ), + ], + ), + onTap: () { + final navigator = Navigator.of(context); + Future.delayed(Duration.zero, () { + if (!mounted) return; + navigator.push( + MaterialPageRoute( + builder: (context) => const DiscoveryScreen(), + ), + ); + }); + }, + ), + ); + items.add( PopupMenuItem( child: Row( diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index 1e42548..8c9bb55 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -163,9 +163,13 @@ class _MessagesTabState extends State { void _scrollToMessage(String messageId) { final messagesProvider = context.read(); - final messages = _getFilteredMessages(messagesProvider); + final messages = messagesProvider.buildDisplayMessages( + _getFilteredMessages(messagesProvider), + ); - final messageIndex = messages.indexWhere((m) => m.id == messageId); + final messageIndex = messages.indexWhere( + (entry) => entry.message.id == messageId, + ); if (messageIndex != -1 && _scrollController.hasClients) { // Calculate position - accounting for reverse list @@ -2019,7 +2023,9 @@ class _MessagesTabState extends State { return Consumer( builder: (context, messagesProvider, child) { _syncChannelAutoReadTimer(messagesProvider); - final messages = _getFilteredMessages(messagesProvider); + final messages = messagesProvider.buildDisplayMessages( + _getFilteredMessages(messagesProvider), + ); final bottomInset = MediaQuery.of(context).viewPadding.bottom; final composerBottomPadding = bottomInset > 0 ? 2.0 : 10.0; diff --git a/lib/screens/packet_log_screen.dart b/lib/screens/packet_log_screen.dart index ff06ec0..751a1ae 100644 --- a/lib/screens/packet_log_screen.dart +++ b/lib/screens/packet_log_screen.dart @@ -777,13 +777,7 @@ class _DecodedRouteSection extends StatelessWidget { ), ) .toList(); - final originalSender = resolvedPath.isEmpty ? null : resolvedPath.first; - - return _RouteSection( - route: decodedRoute, - path: resolvedPath, - originalSender: originalSender, - ); + return _RouteSection(route: decodedRoute, path: resolvedPath); }, ); } @@ -827,13 +821,8 @@ class _DecodedRouteSection extends StatelessWidget { class _RouteSection extends StatelessWidget { final DecodedLogRxRoute route; final List path; - final ResolvedNodeHash? originalSender; - const _RouteSection({ - required this.route, - required this.path, - required this.originalSender, - }); + const _RouteSection({required this.route, required this.path}); @override Widget build(BuildContext context) { @@ -879,12 +868,6 @@ class _RouteSection extends StatelessWidget { value: '${route.hashSize} byte${route.hashSize == 1 ? '' : 's'}', ), - if (originalSender != null) - _FactCard( - icon: Icons.person_pin_circle, - label: 'Original sender', - value: _nodeLabel(originalSender!), - ), ], ), const SizedBox(height: 12), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 7102274..f52edd9 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1130,19 +1130,6 @@ class _SettingsScreenState extends State { _buildSectionHeader('Messaging'), _buildSettingsCard([ - Consumer( - builder: (context, appProvider, child) => SwitchListTile( - secondary: const Icon(Icons.person_add_alt_1), - title: const Text('Auto-add discovered contacts'), - subtitle: const Text( - 'Automatically fetch and add new contacts when they are discovered', - ), - value: appProvider.autoAddDiscoveredContacts, - onChanged: (value) async { - await appProvider.toggleAutoAddDiscoveredContacts(value); - }, - ), - ), ListTile( leading: const Icon(Icons.alt_route), title: const Text('Route path byte size'), diff --git a/lib/services/contact_storage_service.dart b/lib/services/contact_storage_service.dart index 44dbde4..2589c4a 100644 --- a/lib/services/contact_storage_service.dart +++ b/lib/services/contact_storage_service.dart @@ -10,7 +10,9 @@ import 'package:latlong2/latlong.dart'; class ContactStorageService { static const String _contactsKey = 'stored_contacts'; static const String _contactGroupsKey = 'stored_contact_groups'; + static const String _pendingAdvertsKey = 'stored_pending_adverts'; static const int _maxStoredContacts = 500; // Store up to 500 contacts + static const int _maxStoredPendingAdverts = 500; /// Save contacts to persistent storage Future saveContacts(List contacts) async { @@ -126,6 +128,47 @@ class ContactStorageService { } } + Future savePendingAdverts(List> adverts) async { + try { + final prefs = await SharedPreferences.getInstance(); + final limitedList = adverts.length > _maxStoredPendingAdverts + ? adverts.sublist(adverts.length - _maxStoredPendingAdverts) + : adverts; + await prefs.setString(_pendingAdvertsKey, jsonEncode(limitedList)); + debugPrint( + '✅ [ContactStorage] Saved ${limitedList.length} pending adverts to storage', + ); + } catch (e) { + debugPrint('❌ [ContactStorage] Error saving pending adverts: $e'); + } + } + + Future>> loadPendingAdverts() async { + try { + final prefs = await SharedPreferences.getInstance(); + final jsonString = prefs.getString(_pendingAdvertsKey); + if (jsonString == null || jsonString.isEmpty) { + return const []; + } + + final jsonList = jsonDecode(jsonString) as List; + return jsonList.whereType>().toList(); + } catch (e) { + debugPrint('❌ [ContactStorage] Error loading pending adverts: $e'); + return const []; + } + } + + Future clearPendingAdverts() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_pendingAdvertsKey); + debugPrint('✅ [ContactStorage] Cleared all stored pending adverts'); + } catch (e) { + debugPrint('❌ [ContactStorage] Error clearing pending adverts: $e'); + } + } + /// Get storage statistics Future> getStorageStats() async { try { diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index 3cc1e0e..310b247 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -31,6 +31,8 @@ class NotificationService { static const int _sarNotificationId = 1000; static const int _messageNotificationId = 2000; static const int _updateNotificationId = 3000; + static const int _batteryNotificationId = 4000; + static const int _discoveryNotificationId = 5000; // Notification channels static const String _urgentChannelId = 'sar_urgent'; @@ -48,6 +50,16 @@ class NotificationService { static const String _updateChannelDescription = 'Notifications for available app updates'; + static const String _batteryChannelId = 'battery_alerts'; + static const String _batteryChannelName = 'Battery Alerts'; + static const String _batteryChannelDescription = + 'Notifications when a device or contact battery is low'; + + static const String _discoveryChannelId = 'discovery_alerts'; + static const String _discoveryChannelName = 'Discovery Alerts'; + static const String _discoveryChannelDescription = + 'Notifications when new contacts are discovered'; + bool get messageNotificationsEnabled => _messageNotificationsEnabled; bool get sarNotificationsEnabled => _sarNotificationsEnabled; bool get updateNotificationsEnabled => _updateNotificationsEnabled; @@ -237,9 +249,31 @@ class NotificationService { showBadge: true, ); + const batteryChannel = AndroidNotificationChannel( + _batteryChannelId, + _batteryChannelName, + description: _batteryChannelDescription, + importance: Importance.high, + playSound: true, + enableVibration: true, + showBadge: true, + ); + + const discoveryChannel = AndroidNotificationChannel( + _discoveryChannelId, + _discoveryChannelName, + description: _discoveryChannelDescription, + importance: Importance.high, + playSound: true, + enableVibration: true, + showBadge: true, + ); + await androidPlugin.createNotificationChannel(urgentChannel); await androidPlugin.createNotificationChannel(messagesChannel); await androidPlugin.createNotificationChannel(updateChannel); + await androidPlugin.createNotificationChannel(batteryChannel); + await androidPlugin.createNotificationChannel(discoveryChannel); debugPrint('✅ [NotificationService] Created notification channels'); } catch (e) { debugPrint('⚠️ [NotificationService] Error creating channels: $e'); @@ -707,6 +741,156 @@ class NotificationService { ); } } + + Future showLowBatteryNotification({ + required String nodeId, + required String nodeName, + required double batteryPercent, + required bool isCurrentDevice, + }) async { + if (!_isInitialized) { + debugPrint( + '⚠️ [NotificationService] Not initialized, skipping notification', + ); + return false; + } + + if (!_permissionGranted) { + debugPrint( + '⚠️ [NotificationService] Permission not granted, skipping notification', + ); + return false; + } + if (!_messageNotificationsEnabled) { + debugPrint('ℹ️ [NotificationService] Message notifications disabled'); + return false; + } + if (_shouldSuppressForegroundNotifications()) { + debugPrint( + 'ℹ️ [NotificationService] App in foreground, skipping low battery notification', + ); + return false; + } + + final roundedPercent = batteryPercent.round().clamp(0, 100); + final title = isCurrentDevice + ? 'Device battery low' + : 'Contact battery low'; + final body = isCurrentDevice + ? '$nodeName battery is at $roundedPercent%.' + : '$nodeName is at $roundedPercent% battery.'; + final notificationId = + _batteryNotificationId + ((nodeId.hashCode & 0x7fffffff) % 1000); + + try { + final androidDetails = AndroidNotificationDetails( + _batteryChannelId, + _batteryChannelName, + channelDescription: _batteryChannelDescription, + importance: Importance.high, + priority: Priority.high, + ticker: title, + playSound: true, + enableVibration: true, + showWhen: true, + when: DateTime.now().millisecondsSinceEpoch, + styleInformation: BigTextStyleInformation( + body, + contentTitle: title, + summaryText: '$roundedPercent%', + ), + ); + + final darwinDetails = DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: true, + sound: 'default', + threadIdentifier: 'battery_alerts', + subtitle: '$roundedPercent%', + ); + + final notificationDetails = NotificationDetails( + android: androidDetails, + iOS: darwinDetails, + ); + + await _notificationsPlugin.show( + id: notificationId, + title: title, + body: body, + notificationDetails: notificationDetails, + payload: 'battery:$nodeId', + ); + + debugPrint( + '✅ [NotificationService] Showed low battery notification for $nodeName ($roundedPercent%)', + ); + return true; + } catch (e) { + debugPrint( + '❌ [NotificationService] Error showing low battery notification: $e', + ); + return false; + } + } + + Future showContactDiscoveredNotification({ + required String contactKey, + }) async { + if (!_isInitialized) return false; + if (!_permissionGranted) return false; + if (!_messageNotificationsEnabled) return false; + if (_shouldSuppressForegroundNotifications()) return false; + + final shortKey = contactKey.length > 12 + ? contactKey.substring(0, 12).toUpperCase() + : contactKey.toUpperCase(); + final title = 'New contact discovered'; + final body = 'New contact $shortKey is available in Discovery.'; + final notificationId = + _discoveryNotificationId + ((contactKey.hashCode & 0x7fffffff) % 1000); + + try { + final androidDetails = AndroidNotificationDetails( + _discoveryChannelId, + _discoveryChannelName, + channelDescription: _discoveryChannelDescription, + importance: Importance.high, + priority: Priority.high, + ticker: title, + playSound: true, + enableVibration: true, + showWhen: true, + when: DateTime.now().millisecondsSinceEpoch, + ); + + final darwinDetails = DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: true, + sound: 'default', + threadIdentifier: 'discovery_alerts', + ); + + await _notificationsPlugin.show( + id: notificationId, + title: title, + body: body, + notificationDetails: NotificationDetails( + android: androidDetails, + iOS: darwinDetails, + ), + payload: 'discovery:$contactKey', + ); + return true; + } catch (e) { + debugPrint( + '❌ [NotificationService] Error showing discovery notification: $e', + ); + return false; + } + } } class _LifecycleObserver with WidgetsBindingObserver { diff --git a/lib/widgets/contacts/contact_route_dialog.dart b/lib/widgets/contacts/contact_route_dialog.dart index 2a309f9..d7a28ae 100644 --- a/lib/widgets/contacts/contact_route_dialog.dart +++ b/lib/widgets/contacts/contact_route_dialog.dart @@ -1,16 +1,17 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart' as flutter_map; import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; -import 'dart:math' as math; import '../../models/contact.dart'; -import '../../providers/connection_provider.dart'; +import '../../models/path_history.dart'; import '../../providers/app_provider.dart'; +import '../../providers/connection_provider.dart'; import '../../services/contact_route_resolver.dart'; import '../../services/path_history_service.dart'; import '../../services/route_hash_preferences.dart'; -import '../../models/path_history.dart'; class ContactRouteDialogResult { final ParsedContactRoute? route; @@ -53,19 +54,11 @@ class ContactRouteDialog extends StatefulWidget { required Contact contact, required List availableContacts, }) { - return showModalBottomSheet( - context: context, - isScrollControlled: true, - showDragHandle: true, - builder: (context) => SafeArea( - child: Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - ), - child: ContactRouteDialog( - contact: contact, - availableContacts: availableContacts, - ), + return Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ContactRouteDialog( + contact: contact, + availableContacts: availableContacts, ), ), ); @@ -77,14 +70,15 @@ class ContactRouteDialog extends StatefulWidget { class _ContactRouteDialogState extends State { late final TextEditingController _controller; + late final TextEditingController _relaySearchController; final PathHistoryService _pathHistoryService = PathHistoryService(); int _selectedHashSize = RouteHashPreferences.defaultHashSize; ParsedContactRoute? _parsedRoute; String? _errorText; bool _showRoutingInfo = false; + bool _showManualEditor = false; List _selectedMapHops = const []; ContactPathHistory? _pathHistory; - _RouteEntryMode _entryMode = _RouteEntryMode.map; @override void initState() { @@ -92,10 +86,9 @@ class _ContactRouteDialogState extends State { _controller = TextEditingController( text: widget.contact.routeCanonicalText, ); + _relaySearchController = TextEditingController(); _controller.addListener(_reparse); - _entryMode = widget.contact.routeCanonicalText.isNotEmpty - ? _RouteEntryMode.manual - : _RouteEntryMode.map; + _showManualEditor = widget.contact.routeCanonicalText.isNotEmpty; _loadHashSizePreference(); _loadPathHistory(); _reparse(); @@ -103,6 +96,7 @@ class _ContactRouteDialogState extends State { @override void dispose() { + _relaySearchController.dispose(); _controller ..removeListener(_reparse) ..dispose(); @@ -231,7 +225,7 @@ class _ContactRouteDialogState extends State { TextPosition(offset: _controller.text.length), ); _errorText = null; - _entryMode = _RouteEntryMode.map; + _showManualEditor = false; }); _reparse(); } @@ -247,7 +241,7 @@ class _ContactRouteDialogState extends State { TextPosition(offset: _controller.text.length), ); _errorText = null; - _entryMode = _RouteEntryMode.manual; + _showManualEditor = true; }); _reparse(); } @@ -416,30 +410,279 @@ class _ContactRouteDialogState extends State { } Widget _buildPreviewSection() { + final colorScheme = Theme.of(context).colorScheme; + return Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: colorScheme.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _parsedRoute == null ? 'Route preview' : _parsedRoute!.summary, + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 4), + Text( + _parsedRoute == null + ? 'Pick relays from the list below or open manual edit if you need exact hop tokens.' + : '${_parsedRoute!.byteLength} bytes • descriptor 0x${_parsedRoute!.encodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}', + style: Theme.of(context).textTheme.bodySmall, + ), + if (_parsedRoute != null) ...[ + const SizedBox(height: 10), + SelectableText( + _parsedRoute!.canonicalText, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'), + ), + ], + ], + ), + ); + } + + Widget _buildSelectedHopSection() { + if (_selectedMapHops.isEmpty) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Theme.of(context).dividerColor), + ), + child: Text( + 'No relays selected yet. Add repeaters below or let auto resolve build a route.', + style: Theme.of(context).textTheme.bodyMedium, + ), + ); + } + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - _parsedRoute == null - ? 'Preview: enter or pick a route to validate it.' - : 'Preview: ${_parsedRoute!.summary} • ${_parsedRoute!.byteLength} bytes • descriptor 0x${_parsedRoute!.encodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}', - style: Theme.of(context).textTheme.bodySmall, - ), - if (_parsedRoute != null) ...[ - const SizedBox(height: 4), - SelectableText( - _parsedRoute!.canonicalText, - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'), - ), - ], + Text('Selected relays', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 8), + ..._selectedMapHops.asMap().entries.map((entry) { + final index = entry.key; + final contact = entry.value; + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + leading: CircleAvatar(child: Text('${index + 1}')), + title: Text(contact.displayName), + subtitle: Text( + _tokenFor(contact, _selectedHashSize), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'), + ), + trailing: IconButton( + tooltip: 'Remove relay', + onPressed: () => _toggleHop(contact), + icon: const Icon(Icons.close), + ), + ), + ); + }), ], ); } - Widget _buildBuilderTab( - BuildContext context, { + Widget _buildRelayPicker(List routeCandidates) { + final query = _relaySearchController.text.trim().toLowerCase(); + final filteredCandidates = routeCandidates.where((contact) { + if (query.isEmpty) return true; + return contact.displayName.toLowerCase().contains(query) || + _tokenFor(contact, _selectedHashSize).toLowerCase().contains(query); + }).toList(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _relaySearchController, + onChanged: (_) => setState(() {}), + decoration: const InputDecoration( + prefixIcon: Icon(Icons.search), + labelText: 'Find relay', + hintText: 'Search by name or token', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + if (filteredCandidates.isEmpty) + Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Theme.of(context).dividerColor), + ), + child: Text( + 'No visible relays match this search.', + style: Theme.of(context).textTheme.bodyMedium, + ), + ) + else + ...filteredCandidates.map((candidate) { + final isSelected = _selectedMapHops.any( + (item) => item.publicKeyHex == candidate.publicKeyHex, + ); + final location = candidate.displayLocation; + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + leading: Icon( + isSelected + ? Icons.check_circle + : Icons.radio_button_unchecked, + color: isSelected + ? Theme.of(context).colorScheme.primary + : null, + ), + title: Text(candidate.displayName), + subtitle: Text( + [ + _tokenFor(candidate, _selectedHashSize), + if (location != null) + '${location.latitude.toStringAsFixed(4)}, ${location.longitude.toStringAsFixed(4)}', + ].join(' • '), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'), + ), + trailing: TextButton( + onPressed: () => _toggleHop(candidate), + child: Text(isSelected ? 'Remove' : 'Add'), + ), + ), + ); + }), + ], + ); + } + + Widget _buildManualEditor() { + return ExpansionTile( + tilePadding: EdgeInsets.zero, + childrenPadding: EdgeInsets.zero, + initiallyExpanded: _showManualEditor, + onExpansionChanged: (expanded) { + setState(() { + _showManualEditor = expanded; + }); + }, + title: const Text('Manual route edit'), + subtitle: const Text( + 'Use this when you need to paste or tweak hop tokens directly.', + ), + children: [ + TextField( + controller: _controller, + textCapitalization: TextCapitalization.characters, + decoration: InputDecoration( + labelText: 'Route', + hintText: _selectedHashSize == 1 + ? 'AA,BB,CC' + : _selectedHashSize == 2 + ? 'AABB,CCDD' + : 'AABBCC,DDEEFF', + helperText: + 'Comma-separated hops. Hash size follows global Settings. Colon form like AA:BB is also accepted.', + errorText: _errorText, + border: const OutlineInputBorder(), + ), + ), + ], + ); + } + + Widget _buildMapPreview({ + required List routeCandidates, + required List mapPoints, + required List routePoints, + }) { + final colorScheme = Theme.of(context).colorScheme; + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all(color: colorScheme.outlineVariant), + ), + clipBehavior: Clip.antiAlias, + child: SizedBox( + height: 220, + child: mapPoints.length < 2 + ? const Center( + child: Padding( + padding: EdgeInsets.all(16), + child: Text( + 'Map preview needs your advertised location, the contact location, and visible repeater locations.', + textAlign: TextAlign.center, + ), + ), + ) + : flutter_map.FlutterMap( + options: flutter_map.MapOptions( + initialCameraFit: flutter_map.CameraFit.bounds( + bounds: flutter_map.LatLngBounds.fromPoints(mapPoints), + padding: const EdgeInsets.all(32), + ), + ), + children: [ + flutter_map.TileLayer( + urlTemplate: + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'com.meshcore.sar', + ), + if (routePoints.length >= 2) + flutter_map.PolylineLayer( + polylines: [ + flutter_map.Polyline( + points: routePoints, + strokeWidth: 4, + color: colorScheme.primary, + ), + ], + ), + flutter_map.MarkerLayer( + markers: [ + ...routeCandidates.map((candidate) { + final isSelected = _selectedMapHops.any( + (item) => item.publicKeyHex == candidate.publicKeyHex, + ); + return flutter_map.Marker( + point: LatLng( + candidate.displayLocation!.latitude, + candidate.displayLocation!.longitude, + ), + width: 64, + height: 70, + child: GestureDetector( + onTap: () => _toggleHop(candidate), + child: _RouteMarkerDot( + label: _tokenFor(candidate, _selectedHashSize), + color: isSelected + ? colorScheme.primary + : Colors.blueGrey, + ), + ), + ); + }), + ], + ), + ], + ), + ), + ); + } + + Widget _buildBuilderTab({ required List routeCandidates, required List mapPoints, required List routePoints, @@ -447,174 +690,43 @@ class _ContactRouteDialogState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SegmentedButton<_RouteEntryMode>( - segments: const [ - ButtonSegment<_RouteEntryMode>( - value: _RouteEntryMode.map, - icon: Icon(Icons.map_outlined), - label: Text('Map'), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + FilledButton.tonalIcon( + onPressed: _resolvePathAutomatically, + icon: const Icon(Icons.auto_fix_high), + label: const Text('Auto resolve'), ), - ButtonSegment<_RouteEntryMode>( - value: _RouteEntryMode.manual, - icon: Icon(Icons.tune), - label: Text('Manual'), + OutlinedButton.icon( + onPressed: _selectedMapHops.isEmpty + ? null + : () { + setState(() { + _selectedMapHops = const []; + _syncControllerFromSelectedHops(); + }); + }, + icon: const Icon(Icons.clear_all), + label: const Text('Clear relays'), ), ], - selected: {_entryMode}, - onSelectionChanged: (selection) { - setState(() { - _entryMode = selection.first; - }); - }, ), const SizedBox(height: 16), - if (_entryMode == _RouteEntryMode.manual) ...[ - TextField( - controller: _controller, - textCapitalization: TextCapitalization.characters, - decoration: InputDecoration( - labelText: 'Route', - hintText: _selectedHashSize == 1 - ? 'AA,BB,CC' - : _selectedHashSize == 2 - ? 'AABB,CCDD' - : 'AABBCC,DDEEFF', - helperText: - 'Enter comma-separated hops. Path byte size comes from global Settings. Colon form like AA:BB is also accepted.', - errorText: _errorText, - border: const OutlineInputBorder(), - ), - ), - const SizedBox(height: 12), - _buildPreviewSection(), - ] else ...[ - Wrap( - spacing: 8, - runSpacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - OutlinedButton.icon( - onPressed: _resolvePathAutomatically, - icon: const Icon(Icons.auto_fix_high), - label: const Text('Resolve Path'), - ), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 280), - child: Text( - 'Tap repeaters on the map to build the path, then review the generated route below.', - style: Theme.of(context).textTheme.bodySmall, - ), - ), - ], - ), - const SizedBox(height: 16), - SizedBox( - height: 260, - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: DecoratedBox( - decoration: BoxDecoration( - border: Border.all(color: Theme.of(context).dividerColor), - ), - child: mapPoints.length < 2 - ? const Center( - child: Padding( - padding: EdgeInsets.all(16), - child: Text( - 'Map path builder needs your advertised location, the contact location, and visible repeater locations.', - textAlign: TextAlign.center, - ), - ), - ) - : flutter_map.FlutterMap( - options: flutter_map.MapOptions( - initialCameraFit: flutter_map.CameraFit.bounds( - bounds: flutter_map.LatLngBounds.fromPoints( - mapPoints, - ), - padding: const EdgeInsets.all(32), - ), - ), - children: [ - flutter_map.TileLayer( - urlTemplate: - 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - userAgentPackageName: 'com.meshcore.sar', - ), - if (routePoints.length >= 2) - flutter_map.PolylineLayer( - polylines: [ - flutter_map.Polyline( - points: routePoints, - strokeWidth: 4, - color: Theme.of(context).colorScheme.primary, - ), - ], - ), - flutter_map.MarkerLayer( - markers: [ - ...routeCandidates.map((candidate) { - final isSelected = _selectedMapHops.any( - (item) => - item.publicKeyHex == - candidate.publicKeyHex, - ); - return flutter_map.Marker( - point: LatLng( - candidate.displayLocation!.latitude, - candidate.displayLocation!.longitude, - ), - width: 64, - height: 70, - child: GestureDetector( - onTap: () => _toggleHop(candidate), - child: _RouteMarkerDot( - label: _tokenFor( - candidate, - _selectedHashSize, - ), - color: isSelected - ? Theme.of( - context, - ).colorScheme.primary - : Colors.blueGrey, - ), - ), - ); - }), - ], - ), - ], - ), - ), - ), - ), - const SizedBox(height: 12), - if (_selectedMapHops.isNotEmpty) - Wrap( - spacing: 8, - runSpacing: 8, - children: _selectedMapHops.map((contact) { - return InputChip( - label: Text(contact.displayName), - onDeleted: () => _toggleHop(contact), - ); - }).toList(), - ), - const SizedBox(height: 12), - TextField( - controller: _controller, - readOnly: true, - decoration: InputDecoration( - labelText: 'Generated route', - helperText: 'Switch to Manual if you want to edit the hop list.', - errorText: _errorText, - border: const OutlineInputBorder(), - ), - ), - const SizedBox(height: 12), - _buildPreviewSection(), - ], + _buildPreviewSection(), + const SizedBox(height: 16), + _buildSelectedHopSection(), + const SizedBox(height: 16), + _buildRelayPicker(routeCandidates), + const SizedBox(height: 16), + _buildMapPreview( + routeCandidates: routeCandidates, + mapPoints: mapPoints, + routePoints: routePoints, + ), + const SizedBox(height: 16), + _buildManualEditor(), ], ); } @@ -720,39 +832,33 @@ class _ContactRouteDialogState extends State { return DefaultTabController( length: 2, - child: FractionallySizedBox( - heightFactor: 0.85, - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Set Route for ${widget.contact.displayName}', - style: Theme.of(context).textTheme.headlineSmall, - ), - const SizedBox(height: 8), - Text( - 'Choose how to build the route, or reuse one from history.', - style: Theme.of(context).textTheme.bodyMedium, - ), - const SizedBox(height: 16), - const TabBar( - tabs: [ - Tab(text: 'Build'), - Tab(text: 'History'), - ], - ), - const SizedBox(height: 16), - Expanded( - child: TabBarView( - children: [ - SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Scaffold( + appBar: AppBar( + title: Text('Set Path for ${widget.contact.displayName}'), + bottom: const TabBar( + tabs: [ + Tab(text: 'Build'), + Tab(text: 'History'), + ], + ), + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Plan the route on its own screen, then save it once the preview looks right.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Expanded( + child: TabBarView( + children: [ + ListView( children: [ _buildBuilderTab( - context, routeCandidates: routeCandidates, mapPoints: mapPoints, routePoints: routePoints, @@ -772,44 +878,54 @@ class _ContactRouteDialogState extends State { clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry, ), + const SizedBox(height: 24), ], ), - ), - SingleChildScrollView(child: _buildHistoryTab()), - ], + ListView( + children: [ + _buildHistoryTab(), + const SizedBox(height: 24), + ], + ), + ], + ), ), - ), - OverflowBar( - alignment: MainAxisAlignment.spaceBetween, - spacing: 8, - overflowSpacing: 8, - children: [ + ], + ), + ), + ), + bottomNavigationBar: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: OverflowBar( + alignment: MainAxisAlignment.spaceBetween, + spacing: 8, + overflowSpacing: 8, + children: [ + if (widget.contact.routeHasPath) TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - if (widget.contact.routeHasPath) - TextButton( - onPressed: () => Navigator.of( - context, - ).pop(const ContactRouteDialogResult.clear()), - child: const Text('Clear Route'), - ), - FilledButton( - onPressed: _parsedRoute == null - ? null - : () => Navigator.of(context).pop( - ContactRouteDialogResult.setWithFallback( - _parsedRoute!, - inferredFallbackLocation: - _buildSyntheticFallbackLocation(), - ), + onPressed: () => Navigator.of( + context, + ).pop(const ContactRouteDialogResult.clear()), + child: const Text('Clear Route'), + ) + else + const SizedBox.shrink(), + FilledButton( + onPressed: _parsedRoute == null + ? null + : () => Navigator.of(context).pop( + ContactRouteDialogResult.setWithFallback( + _parsedRoute!, + inferredFallbackLocation: + _buildSyntheticFallbackLocation(), ), - child: const Text('Set Route'), - ), - ], - ), - ], + ), + child: const Text('Save Path'), + ), + ], + ), ), ), ), @@ -817,8 +933,6 @@ class _ContactRouteDialogState extends State { } } -enum _RouteEntryMode { map, manual } - class _RouteMarkerDot extends StatelessWidget { final String label; final Color color; diff --git a/lib/widgets/contacts/contact_tile.dart b/lib/widgets/contacts/contact_tile.dart index 0dc8d1b..51f3315 100644 --- a/lib/widgets/contacts/contact_tile.dart +++ b/lib/widgets/contacts/contact_tile.dart @@ -325,7 +325,9 @@ class ContactTile extends StatelessWidget { contact.type == ContactType.room || contact.type == ContactType.channel; final canSetPath = - contact.type == ContactType.chat || contact.type == ContactType.room; + contact.type == ContactType.chat || + contact.type == ContactType.room || + contact.type == ContactType.repeater; final canAddToSensors = contact.type == ContactType.chat || contact.type == ContactType.repeater; diff --git a/lib/widgets/contacts/contact_trace_sheet.dart b/lib/widgets/contacts/contact_trace_sheet.dart index 9d9883c..663a0a8 100644 --- a/lib/widgets/contacts/contact_trace_sheet.dart +++ b/lib/widgets/contacts/contact_trace_sheet.dart @@ -131,7 +131,7 @@ class _ContactTraceSheetState extends State { padding: const EdgeInsets.symmetric(horizontal: 16), child: Text( trace.routeHashes.isEmpty - ? 'Direct route to ${widget.contact.displayName}' + ? 'No relay path saved for ${widget.contact.displayName}' : 'Route from saved contact path (${trace.routeHashes.length} hop${trace.routeHashes.length == 1 ? '' : 's'})', style: Theme.of(context).textTheme.bodySmall, ), @@ -199,14 +199,7 @@ class _ContactTraceSheetState extends State { child: CircleAvatar( radius: 16, backgroundColor: - entry.key == 0 - ? Colors.green - : (entry.key == - concreteNodes - .length - - 1 - ? Colors.red - : Colors.blue), + Colors.blue, child: Text( '${entry.key + 1}', style: const TextStyle( @@ -236,7 +229,7 @@ class _ContactTraceSheetState extends State { Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Text( - 'Route', + 'Relay path', style: Theme.of(context).textTheme.titleMedium, ), ), @@ -263,11 +256,7 @@ class _ContactTraceSheetState extends State { : null, leading: CircleAvatar( radius: 14, - backgroundColor: entry.key == 0 - ? Colors.green - : (entry.key == routeEntries.length - 1 - ? Colors.red - : Colors.blue), + backgroundColor: Colors.blue, child: Text( '${entry.key + 1}', style: const TextStyle( @@ -279,7 +268,7 @@ class _ContactTraceSheetState extends State { ), title: Text(entry.value.label), subtitle: Text( - '${_routeRoleLabel(entry.key, routeEntries.length)}${entry.value.keyLabel == null ? '' : ' • ${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : ' • ${entry.value.matchSummary}'}${entry.value.resolved.cycleSummary == null ? '' : ' • ${entry.value.resolved.cycleSummary}'}', + 'Relay${entry.value.keyLabel == null ? '' : ' • ${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : ' • ${entry.value.matchSummary}'}${entry.value.resolved.cycleSummary == null ? '' : ' • ${entry.value.resolved.cycleSummary}'}', ), trailing: entry.value.resolved.canCycle ? const Icon(Icons.sync_alt) @@ -326,44 +315,18 @@ class _ContactTraceSheetState extends State { } List<_RouteDisplayEntry> _displayRouteEntries(_ContactTraceResult trace) { - final entries = <_RouteDisplayEntry>[]; - if (trace.sender.node != null) { - entries.add( - _RouteDisplayEntry.fromResolved( - trace.sender, - target: const _RouteEntryTarget.sender(), - ), + return trace.matchedRelayNodes.asMap().entries.map((entry) { + final resolved = entry.value; + final node = resolved.node; + final hashHex = trace.routeHashes[entry.key].toUpperCase(); + return _RouteDisplayEntry( + resolved: resolved, + label: node?.name ?? 'Unknown', + keyLabel: node != null ? _prefixKeyLabel(node.publicKey) : hashHex, + matchSummary: resolved.matchSummary, + target: _RouteEntryTarget.relayNode(entry.key), ); - } - entries.addAll( - trace.matchedRelayNodes.asMap().entries.map((entry) { - final resolved = entry.value; - final node = resolved.node; - final hashHex = trace.routeHashes[entry.key].toUpperCase(); - return _RouteDisplayEntry( - resolved: resolved, - label: node?.name ?? 'Unknown', - keyLabel: node != null ? _prefixKeyLabel(node.publicKey) : hashHex, - matchSummary: resolved.matchSummary, - target: _RouteEntryTarget.relayNode(entry.key), - ); - }), - ); - if (trace.recipient.node != null) { - entries.add( - _RouteDisplayEntry.fromResolved( - trace.recipient, - target: const _RouteEntryTarget.recipient(), - ), - ); - } - return entries; - } - - String _routeRoleLabel(int index, int total) { - if (index == 0) return 'Sender'; - if (index == total - 1) return 'Recipient'; - return 'Relay'; + }).toList(); } String _prefixKeyLabel(String publicKey) => @@ -527,20 +490,6 @@ class _ContactTraceResult { _ContactTraceResult cycleEntry(_RouteEntryTarget target) { switch (target.kind) { - case _RouteEntryKind.sender: - return _ContactTraceResult( - sender: sender.cycle(), - recipient: recipient, - routeHashes: routeHashes, - matchedRelayNodes: matchedRelayNodes, - ); - case _RouteEntryKind.recipient: - return _ContactTraceResult( - sender: sender, - recipient: recipient.cycle(), - routeHashes: routeHashes, - matchedRelayNodes: matchedRelayNodes, - ); case _RouteEntryKind.relayNode: final updated = matchedRelayNodes.toList(); updated[target.index] = updated[target.index].cycle(); @@ -570,26 +519,9 @@ class _RouteDisplayEntry { }); MeshMapNode? get node => resolved.node; - - factory _RouteDisplayEntry.fromResolved( - ResolvedTraceNode resolved, { - required _RouteEntryTarget target, - }) { - final node = resolved.node!; - return _RouteDisplayEntry( - resolved: resolved, - label: node.name, - keyLabel: node.publicKey.substring( - 0, - math.min(12, node.publicKey.length), - ), - matchSummary: resolved.matchSummary, - target: target, - ); - } } -enum _RouteEntryKind { sender, recipient, relayNode } +enum _RouteEntryKind { relayNode } class _RouteEntryTarget { final _RouteEntryKind kind; @@ -597,8 +529,6 @@ class _RouteEntryTarget { const _RouteEntryTarget._(this.kind, [this.index = 0]); - const _RouteEntryTarget.sender() : this._(_RouteEntryKind.sender); - const _RouteEntryTarget.recipient() : this._(_RouteEntryKind.recipient); const _RouteEntryTarget.relayNode(int index) : this._(_RouteEntryKind.relayNode, index); } diff --git a/lib/widgets/messages/message_bubble.dart b/lib/widgets/messages/message_bubble.dart index 8011604..ee613e3 100644 --- a/lib/widgets/messages/message_bubble.dart +++ b/lib/widgets/messages/message_bubble.dart @@ -45,6 +45,7 @@ import 'system_message_bubble.dart'; /// - Grouped messages (expandable recipient list) class MessageBubble extends StatefulWidget { final Message message; + final int receivedCopies; final VoidCallback? onTap; final VoidCallback? onReply; final bool isHighlighted; @@ -56,6 +57,7 @@ class MessageBubble extends StatefulWidget { const MessageBubble({ super.key, required this.message, + this.receivedCopies = 1, this.onTap, this.onReply, this.isHighlighted = false, @@ -576,6 +578,7 @@ class _MessageBubbleState extends State { 'Sender to receipt ms: ${receptionDetails?.senderToReceiptMs ?? '-'}', 'Estimated transmit ms: ${receptionDetails?.estimatedTransmitMs ?? '-'}', 'Post-transmit delay ms: ${receptionDetails?.postTransmitDelayMs ?? '-'}', + 'Received copies: ${widget.receivedCopies}', 'Expected ACK tag: ${widget.message.expectedAckTag ?? '-'}', 'Suggested timeout ms: ${widget.message.suggestedTimeoutMs ?? '-'}', 'Round-trip ms: ${widget.message.roundTripTimeMs ?? '-'}', @@ -846,6 +849,12 @@ class _MessageBubbleState extends State { receptionDetails!.postTransmitDelayMs!, ), ), + if (widget.receivedCopies > 1) + _detailRow( + context, + label: 'Received copies', + value: '${widget.receivedCopies}', + ), if (widget.message.suggestedTimeoutMs != null) _detailRow( context, diff --git a/lib/widgets/messages/message_trace_sheet.dart b/lib/widgets/messages/message_trace_sheet.dart index 8b90577..449161b 100644 --- a/lib/widgets/messages/message_trace_sheet.dart +++ b/lib/widgets/messages/message_trace_sheet.dart @@ -154,8 +154,8 @@ class _MessageTraceSheetState extends State { padding: const EdgeInsets.symmetric(horizontal: 16), child: Text( trace.mode == TraceMode.packetPath - ? 'Route from packet path bytes' - : 'Route inferred from hop count (${widget.message.pathLen})', + ? 'Relay path from packet path bytes' + : 'Relay path inferred from hop count (${widget.message.pathLen})', style: Theme.of(context).textTheme.bodySmall, ), ), @@ -222,14 +222,7 @@ class _MessageTraceSheetState extends State { child: CircleAvatar( radius: 16, backgroundColor: - entry.key == 0 - ? Colors.green - : (entry.key == - concretePathNodes - .length - - 1 - ? Colors.red - : Colors.blue), + Colors.blue, child: Text( '${entry.key + 1}', style: const TextStyle( @@ -259,7 +252,7 @@ class _MessageTraceSheetState extends State { Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Text( - 'Route', + 'Relay path', style: Theme.of(context).textTheme.titleMedium, ), ), @@ -286,11 +279,7 @@ class _MessageTraceSheetState extends State { : null, leading: CircleAvatar( radius: 14, - backgroundColor: entry.key == 0 - ? Colors.green - : (entry.key == routeEntries.length - 1 - ? Colors.red - : Colors.blue), + backgroundColor: Colors.blue, child: Text( '${entry.key + 1}', style: const TextStyle( @@ -302,7 +291,7 @@ class _MessageTraceSheetState extends State { ), title: Text(entry.value.label), subtitle: Text( - '${_routeRoleLabel(entry.key, routeEntries.length)}${entry.value.keyLabel == null ? '' : ' • ${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : ' • ${entry.value.matchSummary}'}${entry.value.resolved.cycleSummary == null ? '' : ' • ${entry.value.resolved.cycleSummary}'}', + 'Relay${entry.value.keyLabel == null ? '' : ' • ${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : ' • ${entry.value.matchSummary}'}${entry.value.resolved.cycleSummary == null ? '' : ' • ${entry.value.resolved.cycleSummary}'}', ), trailing: entry.value.resolved.canCycle ? const Icon(Icons.sync_alt) @@ -365,28 +354,8 @@ class _MessageTraceSheetState extends State { } List<_RouteDisplayEntry> _displayRouteEntries(_TraceResult trace) { - final pathNodes = trace.matchedPathNodes - .map((entry) => entry.node) - .whereType() - .toList(); - if (pathNodes.isEmpty) { - return [ - if (trace.sender.node != null) - _RouteDisplayEntry.fromResolved( - trace.sender, - target: const _RouteEntryTarget.sender(), - ), - if (trace.recipient.node != null && - trace.recipient.node!.publicKey != trace.sender.node?.publicKey) - _RouteDisplayEntry.fromResolved( - trace.recipient, - target: const _RouteEntryTarget.recipient(), - ), - ]; - } - if (trace.mode == TraceMode.packetPath) { - final entries = trace.matchedPathNodes.asMap().entries.map((entry) { + return trace.matchedPathNodes.asMap().entries.map((entry) { final hashHex = trace.pathHashes[entry.key].toUpperCase(); return _RouteDisplayEntry( resolved: entry.value, @@ -398,54 +367,24 @@ class _MessageTraceSheetState extends State { target: _RouteEntryTarget.pathNode(entry.key), ); }).toList(); - final lastKey = pathNodes.last.publicKey; - return [ - ...entries, - if (trace.recipient.node != null && - trace.recipient.node!.publicKey != lastKey) - _RouteDisplayEntry.fromResolved( - trace.recipient, - target: const _RouteEntryTarget.recipient(), - ), - ]; } - final firstKey = pathNodes.first.publicKey; - final lastKey = pathNodes.last.publicKey; - return [ - if (trace.sender.node != null && trace.sender.node!.publicKey != firstKey) - _RouteDisplayEntry.fromResolved( - trace.sender, - target: const _RouteEntryTarget.sender(), - ), - ...trace.matchedPathNodes - .asMap() - .entries - .where((entry) => entry.value.node != null) - .map( - (entry) => _RouteDisplayEntry.fromResolved( - entry.value, - target: _RouteEntryTarget.pathNode(entry.key), - ), + return trace.matchedPathNodes + .asMap() + .entries + .where((entry) => entry.value.node != null) + .map( + (entry) => _RouteDisplayEntry.fromResolved( + entry.value, + target: _RouteEntryTarget.pathNode(entry.key), ), - if (trace.recipient.node != null && - trace.recipient.node!.publicKey != lastKey) - _RouteDisplayEntry.fromResolved( - trace.recipient, - target: const _RouteEntryTarget.recipient(), - ), - ]; + ) + .toList(); } String _prefixKeyLabel(String publicKey) => publicKey.substring(0, math.min(12, publicKey.length)); - String _routeRoleLabel(int index, int total) { - if (index == 0) return 'Sender'; - if (index == total - 1) return 'Recipient'; - return 'Relay'; - } - String? _toPrefixHex(List? key) { if (key == null || key.isEmpty) return null; final take = key.length < 6 ? key.length : 6; @@ -521,7 +460,7 @@ class _MessageTraceSheetState extends State { final hopHashes = LogRxRouteDecoder.splitHopHashes( packetPath, hashSize: hashSize, - ).reversed.toList(); + ); final matched = _matchNodesFromPathHashes( nodes: nodes, localPublicKeys: localPublicKeys, @@ -731,22 +670,6 @@ class _TraceResult { _TraceResult cycleEntry(_RouteEntryTarget target) { switch (target.kind) { - case _RouteEntryKind.sender: - return _TraceResult( - mode: mode, - sender: sender.cycle(), - recipient: recipient, - pathHashes: pathHashes, - matchedPathNodes: matchedPathNodes, - ); - case _RouteEntryKind.recipient: - return _TraceResult( - mode: mode, - sender: sender, - recipient: recipient.cycle(), - pathHashes: pathHashes, - matchedPathNodes: matchedPathNodes, - ); case _RouteEntryKind.pathNode: final updated = matchedPathNodes.toList(); updated[target.index] = updated[target.index].cycle(); @@ -796,7 +719,7 @@ class _RouteDisplayEntry { } } -enum _RouteEntryKind { sender, recipient, pathNode } +enum _RouteEntryKind { pathNode } class _RouteEntryTarget { final _RouteEntryKind kind; @@ -804,8 +727,6 @@ class _RouteEntryTarget { const _RouteEntryTarget._(this.kind, [this.index = 0]); - const _RouteEntryTarget.sender() : this._(_RouteEntryKind.sender); - const _RouteEntryTarget.recipient() : this._(_RouteEntryKind.recipient); const _RouteEntryTarget.pathNode(int index) : this._(_RouteEntryKind.pathNode, index); } diff --git a/lib/widgets/messages/messages_content.dart b/lib/widgets/messages/messages_content.dart index d212721..c0be707 100644 --- a/lib/widgets/messages/messages_content.dart +++ b/lib/widgets/messages/messages_content.dart @@ -2,13 +2,14 @@ import 'package:flutter/material.dart'; import '../../l10n/app_localizations.dart'; import '../../models/message.dart'; +import '../../providers/messages_provider.dart'; import '../common/bidirectional_refresh.dart'; import '../../widgets/messages/message_bubble.dart'; class MessagesContent extends StatelessWidget { static const double defaultPadding = 8; - final List messages; + final List messages; final ScrollController scrollController; final String? highlightedMessageId; final double bottomContentPadding; @@ -84,11 +85,13 @@ class MessagesContent extends StatelessWidget { ), itemCount: messages.length, itemBuilder: (context, index) { - final message = messages[index]; + final entry = messages[index]; + final message = entry.message; return MessageBubble( key: ValueKey(message.id), message: message, + receivedCopies: entry.occurrenceCount, isHighlighted: message.id == highlightedMessageId, onNavigateToMap: onNavigateToMap, onTap: onMessageTap == null diff --git a/test/providers/messages_provider_retransmission_test.dart b/test/providers/messages_provider_retransmission_test.dart index 2355555..33354e7 100644 --- a/test/providers/messages_provider_retransmission_test.dart +++ b/test/providers/messages_provider_retransmission_test.dart @@ -4,6 +4,7 @@ import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:meshcore_sar_app/models/contact.dart'; import 'package:meshcore_sar_app/models/message.dart'; +import 'package:meshcore_sar_app/models/message_reception_details.dart'; import 'package:meshcore_sar_app/models/path_selection.dart'; import 'package:meshcore_sar_app/providers/helpers/message_retry_manager.dart'; import 'package:meshcore_sar_app/providers/messages_provider.dart'; @@ -347,6 +348,88 @@ void main() { expect(provider.messages.single.id, equals('c-prefix')); }); + test('duplicate incoming message increments received copy count', () { + final provider = MessagesProvider(); + final sender = Uint8List.fromList([7, 7, 4, 1, 7, 2]); + + provider.addMessage( + Message( + id: 'copy-1', + messageType: MessageType.contact, + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700000600, + text: 'same payload', + receivedAt: DateTime.now(), + senderPublicKeyPrefix: sender, + ), + receptionDetailsSnapshot: MessageReceptionDetails( + capturedAt: DateTime.now(), + ), + ); + provider.addMessage( + Message( + id: 'copy-2', + messageType: MessageType.contact, + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700000601, + text: 'same payload', + receivedAt: DateTime.now(), + senderPublicKeyPrefix: sender, + ), + receptionDetailsSnapshot: MessageReceptionDetails( + capturedAt: DateTime.now(), + ), + ); + + expect(provider.messages, hasLength(1)); + expect( + provider.getMessageReceptionDetails('copy-1')?.receivedCopies, + equals(2), + ); + }); + + test('display list collapses stored duplicates and sums copy counts', () { + final provider = MessagesProvider(); + final sender = Uint8List.fromList([5, 4, 3, 2, 1, 0]); + + provider.addMessages([ + Message( + id: 'dup-a', + messageType: MessageType.channel, + channelIdx: 0, + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700000705, + text: 'same payload', + senderName: 'Klemen', + receivedAt: DateTime.now(), + senderPublicKeyPrefix: sender, + ), + Message( + id: 'dup-b', + messageType: MessageType.channel, + channelIdx: 0, + pathLen: 2, + textType: MessageTextType.plain, + senderTimestamp: 1700000700, + text: 'same payload', + senderName: 'Klemen', + receivedAt: DateTime.now(), + senderPublicKeyPrefix: sender, + ), + ]); + + final display = provider.buildDisplayMessages( + provider.getRecentMessages(), + ); + + expect(display, hasLength(1)); + expect(display.single.message.id, equals('dup-a')); + expect(display.single.occurrenceCount, equals(2)); + }); + test('missing ACK schedules a delayed retransmission', () { fakeAsync((async) { final provider = MessagesProvider(); diff --git a/test/services/message_storage_service_test.dart b/test/services/message_storage_service_test.dart index c16beb7..80eb917 100644 --- a/test/services/message_storage_service_test.dart +++ b/test/services/message_storage_service_test.dart @@ -78,6 +78,7 @@ void main() { senderToReceiptMs: 1200, estimatedTransmitMs: 800, postTransmitDelayMs: 400, + receivedCopies: 3, ), }, ); @@ -95,6 +96,7 @@ void main() { expect(restored.senderToReceiptMs, 1200); expect(restored.estimatedTransmitMs, 800); expect(restored.postTransmitDelayMs, 400); + expect(restored.receivedCopies, 3); expect( restored.packetLoggedAt, DateTime.fromMillisecondsSinceEpoch(1700000100400),