diff --git a/ios/fastlane/report.xml b/ios/fastlane/report.xml index 098041f..fdaa206 100644 --- a/ios/fastlane/report.xml +++ b/ios/fastlane/report.xml @@ -5,22 +5,22 @@ - + - + - + - + diff --git a/lib/models/contact_group.dart b/lib/models/contact_group.dart index 46621ad..74326a1 100644 --- a/lib/models/contact_group.dart +++ b/lib/models/contact_group.dart @@ -4,6 +4,8 @@ class SavedContactGroup { final String label; final String query; final DateTime createdAt; + final List? matchPrefixes; + final bool isAutoGroup; const SavedContactGroup({ required this.id, @@ -11,6 +13,8 @@ class SavedContactGroup { required this.label, required this.query, required this.createdAt, + this.matchPrefixes, + this.isAutoGroup = false, }); SavedContactGroup copyWith({ @@ -19,6 +23,8 @@ class SavedContactGroup { String? label, String? query, DateTime? createdAt, + List? matchPrefixes, + bool? isAutoGroup, }) { return SavedContactGroup( id: id ?? this.id, @@ -26,6 +32,8 @@ class SavedContactGroup { label: label ?? this.label, query: query ?? this.query, createdAt: createdAt ?? this.createdAt, + matchPrefixes: matchPrefixes ?? this.matchPrefixes, + isAutoGroup: isAutoGroup ?? this.isAutoGroup, ); } } diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index 89f439f..24abd64 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -1627,6 +1627,22 @@ class ConnectionProvider with ChangeNotifier { } } + Future factoryResetDevice() async { + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + try { + _error = null; + await _activeService.factoryReset(); + } catch (e) { + _error = 'Failed to wipe device data: $e'; + notifyListeners(); + } + } + /// Set advertised name Future setAdvertName(String name) async { if (!_activeService.isConnected) { diff --git a/lib/providers/contacts_provider.dart b/lib/providers/contacts_provider.dart index 3c6bdf7..979222e 100644 --- a/lib/providers/contacts_provider.dart +++ b/lib/providers/contacts_provider.dart @@ -58,6 +58,7 @@ class _RetainedRoute { /// Contacts Provider - manages contact list and telemetry class ContactsProvider with ChangeNotifier { static const double _firstHopFallbackOffsetMeters = 100.0; + static const String autoGroupIdPrefix = 'auto_group_'; final Map _contacts = {}; final List _savedContactGroups = []; final Map _pendingAdverts = {}; @@ -248,6 +249,8 @@ class ContactsProvider with ChangeNotifier { String sectionKey, String query, { String? label, + List? matchPrefixes, + bool isAutoGroup = false, }) async { final normalizedQuery = _normalizeGroupQuery(query); if (normalizedQuery.isEmpty || @@ -262,6 +265,8 @@ class ContactsProvider with ChangeNotifier { label: (label ?? query).trim(), query: query.trim(), createdAt: DateTime.now(), + matchPrefixes: matchPrefixes, + isAutoGroup: isAutoGroup, ), ); @@ -299,6 +304,18 @@ class ContactsProvider with ChangeNotifier { notifyListeners(); } + Future replaceAutoGroupsForSection( + String sectionKey, + List groups, + ) async { + _savedContactGroups.removeWhere( + (group) => group.sectionKey == sectionKey && group.isAutoGroup, + ); + _savedContactGroups.addAll(groups); + await _persistSavedGroups(); + notifyListeners(); + } + Future _persistSavedGroups() async { try { await _storageService.saveContactGroups(_savedContactGroups); @@ -486,6 +503,7 @@ class ContactsProvider with ChangeNotifier { if (existingContact == null) { var newContact = incomingContact.copyWith( isNew: true, + nameOverride: existingContact?.nameOverride, telemetry: mergedTelemetry, outPathLen: retainedRoute?.signedEncodedPathLen ?? incomingContact.outPathLen, @@ -514,6 +532,7 @@ class ContactsProvider with ChangeNotifier { var updatedContact = incomingContact.copyWith( isNew: false, + nameOverride: existingContact.nameOverride, advertHistory: existingContact.advertHistory, telemetry: mergedTelemetry, outPathLen: @@ -1023,6 +1042,26 @@ class ContactsProvider with ChangeNotifier { notifyListeners(); } + void setContactNameOverride(String publicKeyHex, String? overrideName) { + final contact = _contacts[publicKeyHex]; + if (contact == null) { + return; + } + + final normalizedOverride = overrideName?.trim(); + final nextOverride = + (normalizedOverride == null || normalizedOverride.isEmpty) + ? null + : normalizedOverride; + if (contact.nameOverride == nextOverride) { + return; + } + + _contacts[publicKeyHex] = contact.copyWith(nameOverride: nextOverride); + _persistContacts(); + notifyListeners(); + } + /// Add or refresh a pending advert entry from PUSH_CODE_ADVERT (0x80). /// Excludes self key and existing contacts. void addPendingAdvert(Uint8List publicKey, {Uint8List? devicePublicKey}) { diff --git a/lib/providers/drawing_provider.dart b/lib/providers/drawing_provider.dart index 46aed16..f97c551 100644 --- a/lib/providers/drawing_provider.dart +++ b/lib/providers/drawing_provider.dart @@ -11,6 +11,8 @@ enum DrawingMode { none, line, rectangle, measure } /// Provider for managing map drawings class DrawingProvider with ChangeNotifier { static const String _storageKey = 'map_drawings'; + static const String _showReceivedDrawingsKey = 'map_show_received_drawings'; + static const String _showSarMarkersKey = 'map_show_sar_markers'; // Drawing state DrawingMode _drawingMode = DrawingMode.none; @@ -57,6 +59,7 @@ class DrawingProvider with ChangeNotifier { /// Initialize and load saved drawings Future initialize() async { + await _loadPreferences(); await _loadDrawings(); } @@ -77,15 +80,30 @@ class DrawingProvider with ChangeNotifier { } /// Toggle visibility of received drawings - void toggleReceivedDrawings() { + Future toggleReceivedDrawings() async { _showReceivedDrawings = !_showReceivedDrawings; notifyListeners(); + await _savePreferences(); } /// Toggle visibility of SAR markers - void toggleSarMarkers() { + Future toggleSarMarkers() async { _showSarMarkers = !_showSarMarkers; notifyListeners(); + await _savePreferences(); + } + + Future _loadPreferences() async { + final prefs = await SharedPreferences.getInstance(); + _showReceivedDrawings = prefs.getBool(_showReceivedDrawingsKey) ?? true; + _showSarMarkers = prefs.getBool(_showSarMarkersKey) ?? true; + notifyListeners(); + } + + Future _savePreferences() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_showReceivedDrawingsKey, _showReceivedDrawings); + await prefs.setBool(_showSarMarkersKey, _showSarMarkers); } /// Start drawing a line diff --git a/lib/providers/map_provider.dart b/lib/providers/map_provider.dart index 7dfb763..2a4b3f9 100644 --- a/lib/providers/map_provider.dart +++ b/lib/providers/map_provider.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; @@ -6,6 +8,10 @@ import '../models/location_trail.dart'; import '../models/map_drawing.dart'; class MapProvider with ChangeNotifier { + MapProvider() { + unawaited(_loadInitialState()); + } + LatLng? _targetLocation; double? _targetZoom; bool _shouldAnimate = false; @@ -33,6 +39,7 @@ class MapProvider with ChangeNotifier { // Contact trail toggles bool _showAllContactTrails = true; // Default to showing all contact trails + bool _hideRepeatersOnMap = false; // Imported trail (from GPX) LocationTrail? _importedTrail; @@ -67,6 +74,7 @@ class MapProvider with ChangeNotifier { // Contact trail getters bool get showAllContactTrails => _showAllContactTrails; + bool get hideRepeatersOnMap => _hideRepeatersOnMap; // Imported trail getters LocationTrail? get importedTrail => _importedTrail; @@ -350,6 +358,11 @@ class MapProvider with ChangeNotifier { notifyListeners(); } + Future _loadInitialState() async { + await Future.wait([loadOverlayState(), loadTrailSettings()]); + await loadRepeaterVisibilitySettings(); + } + /// Save overlay state to SharedPreferences Future _saveOverlayState() async { final prefs = await SharedPreferences.getInstance(); @@ -386,6 +399,20 @@ class MapProvider with ChangeNotifier { await prefs.setBool('map_show_all_contact_trails', _showAllContactTrails); } + Future setHideRepeatersOnMap(bool hide) async { + if (_hideRepeatersOnMap == hide) return; + _hideRepeatersOnMap = hide; + notifyListeners(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('map_hide_repeaters', _hideRepeatersOnMap); + } + + Future loadRepeaterVisibilitySettings() async { + final prefs = await SharedPreferences.getInstance(); + _hideRepeatersOnMap = prefs.getBool('map_hide_repeaters') ?? false; + notifyListeners(); + } + /// Set imported trail (from GPX import) void setImportedTrail(LocationTrail trail) { _importedTrail = trail; diff --git a/lib/providers/messages_provider.dart b/lib/providers/messages_provider.dart index 1d9c8cd..04970c9 100644 --- a/lib/providers/messages_provider.dart +++ b/lib/providers/messages_provider.dart @@ -22,6 +22,7 @@ import 'helpers/message_retry_manager.dart'; class MessagesProvider with ChangeNotifier { final List _messages = []; final Map _sarMarkers = {}; + final Set _removedSarMarkerIds = {}; final MessageStorageService _storageService = MessageStorageService(); final NotificationService _notificationService = NotificationService(); bool _isInitialized = false; @@ -125,6 +126,7 @@ class MessagesProvider with ChangeNotifier { _messages.where((m) => m.isSystemMessage).toList(); List get sarMarkers => _sarMarkers.values.toList(); + Set get removedSarMarkerIds => Set.unmodifiable(_removedSarMarkerIds); List get foundPersonMarkers => sarMarkers.where((m) => m.type == SarMarkerType.foundPerson).toList(); @@ -298,6 +300,8 @@ class MessagesProvider with ChangeNotifier { .loadMessageTransferDetails(); final storedRouteMetadata = await _storageService .loadMessageRouteMetadata(); + final storedRemovedSarMarkerIds = await _storageService + .loadRemovedSarMarkerIds(); _messageContactLocations ..clear() ..addAll(storedContactLocations); @@ -310,6 +314,9 @@ class MessagesProvider with ChangeNotifier { _messageRouteMetadata ..clear() ..addAll(storedRouteMetadata); + _removedSarMarkerIds + ..clear() + ..addAll(storedRemovedSarMarkerIds); // Add stored messages with enhancement to ensure SAR detection for (final message in storedMessages) { @@ -357,7 +364,7 @@ class MessagesProvider with ChangeNotifier { // Extract SAR markers if (enhancedMessage.isSarMarker) { final marker = enhancedMessage.toSarMarker(); - if (marker != null) { + if (marker != null && !_removedSarMarkerIds.contains(marker.id)) { _sarMarkers[marker.id] = marker; } } @@ -537,29 +544,22 @@ class MessagesProvider with ChangeNotifier { // - Mesh network retransmissions // - Multiple paths in the network // - Syncing messages from device queue - if (_isDuplicate(finalMessage)) { + final duplicateIndex = _findDuplicateMessageIndex(finalMessage); + if (duplicateIndex != -1) { debugPrint( '⚠️ [MessagesProvider] Duplicate message detected, skipping: ${finalMessage.id}', ); debugPrint( ' Text: ${finalMessage.text.substring(0, finalMessage.text.length > 50 ? 50 : finalMessage.text.length)}...', ); - final existingIndex = _messages.indexWhere( - (existing) => - existing.messageType == finalMessage.messageType && - existing.senderTimestamp == finalMessage.senderTimestamp && - existing.text == finalMessage.text, - ); - if (existingIndex != -1) { - final existingId = _messages[existingIndex].id; - if (contactLocationSnapshot != null) { - _messageContactLocations[existingId] = contactLocationSnapshot; - } - if (receptionDetailsSnapshot != null) { - _messageReceptionDetails[existingId] = receptionDetailsSnapshot; - } - _persistMessages(); + final existingId = _messages[duplicateIndex].id; + if (contactLocationSnapshot != null) { + _messageContactLocations[existingId] = contactLocationSnapshot; } + if (receptionDetailsSnapshot != null) { + _messageReceptionDetails[existingId] = receptionDetailsSnapshot; + } + _persistMessages(); return; // Skip duplicate } @@ -574,7 +574,7 @@ class MessagesProvider with ChangeNotifier { // If it's a SAR marker message, extract and store the marker if (finalMessage.isSarMarker) { final marker = finalMessage.toSarMarker(); - if (marker != null) { + if (marker != null && !_removedSarMarkerIds.contains(marker.id)) { _sarMarkers[marker.id] = marker; // Trigger urgent notification for received SAR messages (not sent by user) @@ -598,50 +598,48 @@ class MessagesProvider with ChangeNotifier { /// Messages are considered duplicates if they have: /// 1. Same sender public key prefix (for contact messages) /// 2. Same channel index (for channel messages) - /// 3. Same sender timestamp - /// 4. Same text content + /// 3. Same text content /// /// Note: Sent messages (isSentMessage=true) are NEVER duplicates /// because they can be retried with different message IDs - bool _isDuplicate(Message message) { + int _findDuplicateMessageIndex(Message message) { // Sent messages (our own messages) should never be considered duplicates // They can be retried multiple times with different IDs if (message.isSentMessage) { + return -1; + } + + for (int index = 0; index < _messages.length; index++) { + final existing = _messages[index]; + if (!_matchesDuplicateScope(existing, message) || + existing.text != message.text) { + continue; + } + return index; + } + + return -1; + } + + bool _matchesDuplicateScope(Message existing, Message message) { + if (existing.messageType != message.messageType) { return false; } - return _messages.any((existing) { - // Check message type matches - if (existing.messageType != message.messageType) { + if (message.isContactMessage) { + return existing.senderKeyShort == message.senderKeyShort; + } + + if (message.isChannelMessage) { + if (existing.channelIdx != message.channelIdx) { return false; } + final existingSender = existing.senderKeyShort ?? existing.senderName; + final incomingSender = message.senderKeyShort ?? message.senderName; + return existingSender == incomingSender; + } - // Check sender matches - if (message.isContactMessage) { - // For contact messages, compare sender public key prefix - if (existing.senderKeyShort != message.senderKeyShort) { - return false; - } - } else if (message.isChannelMessage) { - // For channel messages, compare channel index - if (existing.channelIdx != message.channelIdx) { - return false; - } - } - - // Check timestamp matches (sender timestamp is the unique identifier from the sender) - if (existing.senderTimestamp != message.senderTimestamp) { - return false; - } - - // Check text content matches - if (existing.text != message.text) { - return false; - } - - // All criteria match - this is a duplicate - return true; - }); + return true; } /// Add multiple messages @@ -654,7 +652,7 @@ class MessagesProvider with ChangeNotifier { final enhancedMessage = SarMessageParser.enhanceMessage(message); // Check for duplicates - if (_isDuplicate(enhancedMessage)) { + if (_findDuplicateMessageIndex(enhancedMessage) != -1) { duplicateCount++; continue; // Skip duplicate } @@ -664,7 +662,7 @@ class MessagesProvider with ChangeNotifier { if (enhancedMessage.isSarMarker) { final marker = enhancedMessage.toSarMarker(); - if (marker != null) { + if (marker != null && !_removedSarMarkerIds.contains(marker.id)) { _sarMarkers[marker.id] = marker; } } @@ -886,17 +884,35 @@ class MessagesProvider with ChangeNotifier { return _sarMarkers[id]; } + Message? getMessageById(String id) { + final index = _messages.indexWhere((message) => message.id == id); + if (index == -1) return null; + return _messages[index]; + } + /// Get recent SAR markers (within last hour) List getRecentSarMarkers() { return sarMarkers.where((m) => m.isRecent).toList(); } /// Remove a SAR marker - void removeSarMarker(String id) { + Future removeSarMarker(String id) async { _sarMarkers.remove(id); + _removedSarMarkerIds.add(id); + await _storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds); notifyListeners(); } + Future removeSarMarkerPermanently(String id) async { + final hasBackingMessage = _messages.any((message) => message.id == id); + if (hasBackingMessage) { + deleteMessage(id); + return; + } + + await removeSarMarker(id); + } + /// Mark all messages as read void markAllAsRead() { bool hasChanges = false; @@ -984,6 +1000,7 @@ class MessagesProvider with ChangeNotifier { final marker = message.toSarMarker(); if (marker != null) { _sarMarkers.remove(marker.id); + _removedSarMarkerIds.add(marker.id); } } @@ -1006,6 +1023,7 @@ class MessagesProvider with ChangeNotifier { debugPrint('🗑️ [MessagesProvider] Message $messageId deleted'); _persistMessages(); + unawaited(_storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds)); notifyListeners(); } } @@ -1030,17 +1048,21 @@ class MessagesProvider with ChangeNotifier { void clearMessages() { _messages.clear(); _sarMarkers.clear(); + _removedSarMarkerIds.clear(); _messageContactLocations.clear(); _messageReceptionDetails.clear(); _messageTransferDetails.clear(); _messageRouteMetadata.clear(); _persistMessages(); + unawaited(_storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds)); notifyListeners(); } /// Clear all SAR markers - void clearSarMarkers() { + Future clearSarMarkers() async { + _removedSarMarkerIds.addAll(_sarMarkers.keys); _sarMarkers.clear(); + await _storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds); notifyListeners(); } @@ -1048,11 +1070,13 @@ class MessagesProvider with ChangeNotifier { void clearAll() { _messages.clear(); _sarMarkers.clear(); + _removedSarMarkerIds.clear(); _messageContactLocations.clear(); _messageReceptionDetails.clear(); _messageTransferDetails.clear(); _messageRouteMetadata.clear(); _persistMessages(); + unawaited(_storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds)); notifyListeners(); } @@ -1213,7 +1237,7 @@ class MessagesProvider with ChangeNotifier { } // Check for duplicates (shouldn't happen for sent messages, but be safe) - if (_isDuplicate(enhancedMessage)) { + if (_findDuplicateMessageIndex(enhancedMessage) != -1) { debugPrint( '⚠️ [MessagesProvider] Duplicate sent message detected, skipping: ${enhancedMessage.id}', ); @@ -1238,7 +1262,7 @@ class MessagesProvider with ChangeNotifier { // If it's a SAR marker message, extract and store the marker if (sendingMessage.isSarMarker) { final marker = sendingMessage.toSarMarker(); - if (marker != null) { + if (marker != null && !_removedSarMarkerIds.contains(marker.id)) { debugPrint(' ✅ SAR Marker created:'); debugPrint(' marker.id: ${marker.id}'); debugPrint(' marker.notes: "${marker.notes}"'); diff --git a/lib/screens/contacts_tab.dart b/lib/screens/contacts_tab.dart index 5cbc590..433d9ce 100644 --- a/lib/screens/contacts_tab.dart +++ b/lib/screens/contacts_tab.dart @@ -2,12 +2,14 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:geolocator/geolocator.dart'; +import 'package:latlong2/latlong.dart'; import '../l10n/app_localizations.dart'; import '../models/contact.dart'; import '../models/contact_group.dart'; import '../providers/contacts_provider.dart'; import '../providers/app_provider.dart'; import '../providers/connection_provider.dart'; +import '../providers/map_provider.dart'; import '../providers/messages_provider.dart'; import '../services/message_destination_preferences.dart'; import '../utils/contact_grouping.dart'; @@ -200,7 +202,9 @@ class _ContactsTabState extends State { return contacts.where((contact) { final name = contact.displayName.toLowerCase(); final advertisedName = contact.advName.toLowerCase(); - return name.contains(query) || advertisedName.contains(query); + return name.contains(query) || + advertisedName.contains(query) || + ContactGrouping.contactMatchesInferredGroupLabel(contact, query); }).toList(); } @@ -213,7 +217,19 @@ class _ContactsTabState extends State { final name = contact.displayName.toLowerCase(); final advertisedName = contact.advName.toLowerCase(); return name.contains(normalizedQuery) || - advertisedName.contains(normalizedQuery); + advertisedName.contains(normalizedQuery) || + ContactGrouping.contactMatchesInferredGroupLabel( + contact, + normalizedQuery, + ); + } + + bool _sectionHasActiveFilter(ContactSection section) { + return (_sectionFilters[section] ?? '').trim().isNotEmpty; + } + + bool _showSavedGroupsForSection(ContactSection section) { + return !_sectionHasActiveFilter(section); } List<_RenderedSavedGroup> _buildSavedGroupsForSection( @@ -225,7 +241,7 @@ class _ContactsTabState extends State { .savedGroupsForSection(section.name) .map((group) { final matches = contacts - .where((contact) => _contactMatchesFilter(contact, group.query)) + .where((contact) => _contactMatchesSavedGroup(contact, group)) .toList(); return _RenderedSavedGroup(group: group, contacts: matches); }) @@ -238,6 +254,23 @@ class _ContactsTabState extends State { ); } + bool _contactMatchesSavedGroup(Contact contact, SavedContactGroup group) { + final matchPrefixes = group.matchPrefixes; + if (matchPrefixes != null && matchPrefixes.isNotEmpty) { + final inferredLabel = ContactGrouping.inferredGroupLabelForContact( + contact, + )?.toLowerCase(); + if (inferredLabel == null) { + return false; + } + return matchPrefixes.any( + (prefix) => prefix.toLowerCase() == inferredLabel, + ); + } + + return _contactMatchesFilter(contact, group.query); + } + Future _toggleSavedGroupForSection( BuildContext context, ContactsProvider contactsProvider, @@ -278,6 +311,48 @@ class _ContactsTabState extends State { ); } + Future _createAutoGroupsForSection( + BuildContext context, + ContactsProvider contactsProvider, + ContactSection section, + List contacts, { + int? maxNamedGroups, + String? overflowGroupLabel, + String emptyMessage = 'No auto groups available', + String successMessage = 'Updated auto groups', + }) async { + final inferredGroups = ContactGrouping.inferGroups( + contacts, + maxNamedGroups: maxNamedGroups, + overflowGroupLabel: overflowGroupLabel, + ); + + final now = DateTime.now(); + final groups = inferredGroups + .map( + (group) => SavedContactGroup( + id: '${ContactsProvider.autoGroupIdPrefix}${section.name}_${group.key}', + sectionKey: section.name, + label: group.label, + query: group.label, + createdAt: now, + matchPrefixes: group.matchPrefixes, + isAutoGroup: true, + ), + ) + .toList(); + + await contactsProvider.replaceAutoGroupsForSection(section.name, groups); + + if (!context.mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(groups.isEmpty ? emptyMessage : successMessage)), + ); + } + List _sortContacts(List contacts, ContactSection section) { final sorted = List.from(contacts); if (section == ContactSection.channels) { @@ -365,6 +440,142 @@ class _ContactsTabState extends State { ); } + Future _showDeleteChannelDialog( + BuildContext context, + Contact channel, + ) async { + if (channel.isPublicChannel) { + return; + } + + final l10n = AppLocalizations.of(context)!; + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(l10n.deleteChannel), + content: Text(l10n.deleteChannelConfirmation(channel.advName)), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(l10n.cancel), + ), + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: Text(l10n.delete), + ), + ], + ), + ); + + if (confirmed != true || !context.mounted) { + return; + } + + try { + final channelIdx = channel.publicKey.length > 1 + ? channel.publicKey[1] + : 0; + await context.read().deleteChannel(channelIdx); + + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.channelDeletedSuccessfully), + backgroundColor: Colors.green, + ), + ); + } catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.channelDeletionFailed(e.toString())), + backgroundColor: Colors.red, + ), + ); + } + } + + Future _openMessagesForChannel( + BuildContext context, + Contact channel, + ) async { + final messagesProvider = context.read(); + await MessageDestinationPreferences.setDestination( + MessageDestinationPreferences.destinationTypeChannel, + recipientPublicKey: channel.publicKeyHex, + ); + messagesProvider.navigateToDestination( + MessageDestinationPreferences.destinationTypeChannel, + recipientPublicKeyHex: channel.publicKeyHex, + ); + widget.onNavigateToMessages?.call(); + } + + void _showChannelOnMap(BuildContext context, Contact channel) { + final location = channel.displayLocation; + if (location == null) { + return; + } + + context.read().navigateToLocation( + location: LatLng(location.latitude, location.longitude), + ); + widget.onNavigateToMap?.call(); + } + + void _showChannelActionSheet(BuildContext context, Contact channel) { + final l10n = AppLocalizations.of(context)!; + + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.message_outlined), + title: Text(l10n.messages), + onTap: () async { + Navigator.pop(sheetContext); + await _openMessagesForChannel(context, channel); + }, + ), + if (channel.displayLocation != null) + ListTile( + leading: const Icon(Icons.map_outlined), + title: Text(l10n.viewOnMap), + onTap: () { + Navigator.pop(sheetContext); + _showChannelOnMap(context, channel); + }, + ), + if (!channel.isPublicChannel) + ListTile( + leading: const Icon(Icons.delete, color: Colors.red), + title: Text( + l10n.deleteChannel, + style: const TextStyle(color: Colors.red), + ), + onTap: () async { + Navigator.pop(sheetContext); + await Future.delayed(Duration.zero); + if (!context.mounted) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) return; + _showDeleteChannelDialog(context, channel); + }); + }, + ), + ], + ), + ), + ); + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; @@ -399,6 +610,10 @@ class _ContactsTabState extends State { allChatContacts, ContactSection.teamMembers, ); + final visibleSavedTeamGroups = + _showSavedGroupsForSection(ContactSection.teamMembers) + ? savedTeamGroups + : const <_RenderedSavedGroup>[]; final repeaters = _filterContactsForSection( allRepeaters, ContactSection.repeaters, @@ -408,6 +623,10 @@ class _ContactsTabState extends State { allRepeaters, ContactSection.repeaters, ); + final visibleSavedRepeaterGroups = + _showSavedGroupsForSection(ContactSection.repeaters) + ? savedRepeaterGroups + : const <_RenderedSavedGroup>[]; final rooms = _filterContactsForSection( allRooms, ContactSection.rooms, @@ -417,6 +636,10 @@ class _ContactsTabState extends State { allRooms, ContactSection.rooms, ); + final visibleSavedRoomGroups = + _showSavedGroupsForSection(ContactSection.rooms) + ? savedRoomGroups + : const <_RenderedSavedGroup>[]; final filteredChannels = _filterContactsForSection( allChannels, ContactSection.channels, @@ -426,6 +649,29 @@ class _ContactsTabState extends State { allChannels, ContactSection.channels, ); + final visibleSavedChannelGroups = + _showSavedGroupsForSection(ContactSection.channels) + ? savedChannelGroups + : const <_RenderedSavedGroup>[]; + final showTeamMembersSection = + allChatContacts.isNotEmpty && + (!_sectionHasActiveFilter(ContactSection.teamMembers) || + chatContacts.isNotEmpty || + visibleSavedTeamGroups.isNotEmpty); + final showRepeatersSection = + allRepeaters.isNotEmpty && + (!_sectionHasActiveFilter(ContactSection.repeaters) || + repeaters.isNotEmpty || + visibleSavedRepeaterGroups.isNotEmpty); + final showRoomsSection = + allRooms.isNotEmpty && + (!_sectionHasActiveFilter(ContactSection.rooms) || + rooms.isNotEmpty || + visibleSavedRoomGroups.isNotEmpty); + final showChannelsSection = + !_sectionHasActiveFilter(ContactSection.channels) || + filteredChannels.isNotEmpty || + visibleSavedChannelGroups.isNotEmpty; final pendingAdverts = contactsProvider.pendingAdverts; _schedulePendingAdvertResolution(pendingAdverts, connectionProvider); @@ -470,7 +716,7 @@ class _ContactsTabState extends State { padding: const EdgeInsets.all(8), children: [ // Team Members (Chat contacts) - if (allChatContacts.isNotEmpty) ...[ + if (showTeamMembersSection) ...[ _SectionHeader( title: l10n.teamMembers, count: chatContacts.length, @@ -484,23 +730,32 @@ class _ContactsTabState extends State { context, ContactSection.teamMembers, contactsProvider, - ), - if (chatContacts.isEmpty) - _buildEmptyFilterState(context) - else ...[ - ..._buildSavedGroupCards( - savedTeamGroups, + onSecondaryAction: () => _createAutoGroupsForSection( + context, + contactsProvider, ContactSection.teamMembers, + allChatContacts, + emptyMessage: 'No contact auto groups available', + successMessage: 'Updated contact auto groups', ), - ..._buildContactSectionItems( - _excludeGroupedContacts(chatContacts, savedTeamGroups), + secondaryActionIcon: Icons.auto_awesome_outlined, + secondaryActionTooltip: 'Auto group', + ), + ..._buildSavedGroupCards( + visibleSavedTeamGroups, + ContactSection.teamMembers, + ), + ..._buildContactSectionItems( + _excludeGroupedContacts( + chatContacts, + visibleSavedTeamGroups, ), - ], + ), const Divider(height: 32), ], // Repeaters - if (allRepeaters.isNotEmpty) ...[ + if (showRepeatersSection) ...[ _SectionHeader( title: l10n.repeaters, count: repeaters.length, @@ -511,23 +766,34 @@ class _ContactsTabState extends State { context, ContactSection.repeaters, contactsProvider, - ), - if (repeaters.isEmpty) - _buildEmptyFilterState(context) - else ...[ - ..._buildSavedGroupCards( - savedRepeaterGroups, + onSecondaryAction: () => _createAutoGroupsForSection( + context, + contactsProvider, ContactSection.repeaters, + allRepeaters, + maxNamedGroups: 2, + overflowGroupLabel: 'Others', + emptyMessage: 'No repeater auto groups available', + successMessage: 'Updated repeater auto groups', ), - ..._buildContactSectionItems( - _excludeGroupedContacts(repeaters, savedRepeaterGroups), + secondaryActionIcon: Icons.auto_awesome_outlined, + secondaryActionTooltip: 'Auto group', + ), + ..._buildSavedGroupCards( + visibleSavedRepeaterGroups, + ContactSection.repeaters, + ), + ..._buildContactSectionItems( + _excludeGroupedContacts( + repeaters, + visibleSavedRepeaterGroups, ), - ], + ), const Divider(height: 32), ], // Rooms - if (allRooms.isNotEmpty) ...[ + if (showRoomsSection) ...[ _SectionHeader( title: l10n.rooms, count: rooms.length, @@ -539,17 +805,13 @@ class _ContactsTabState extends State { ContactSection.rooms, contactsProvider, ), - if (rooms.isEmpty) - _buildEmptyFilterState(context) - else ...[ - ..._buildSavedGroupCards( - savedRoomGroups, - ContactSection.rooms, - ), - ..._buildContactSectionItems( - _excludeGroupedContacts(rooms, savedRoomGroups), - ), - ], + ..._buildSavedGroupCards( + visibleSavedRoomGroups, + ContactSection.rooms, + ), + ..._buildContactSectionItems( + _excludeGroupedContacts(rooms, visibleSavedRoomGroups), + ), const Divider(height: 32), ], @@ -575,34 +837,34 @@ class _ContactsTabState extends State { ], // Channels (visible in both simple and advanced mode) - _SectionHeader( - title: l10n.channels, - count: filteredChannels.length, - icon: Icons.broadcast_on_personal, - ), - _buildSectionFilterField( - context, - ContactSection.channels, - contactsProvider, - ), - if (allChannels.isNotEmpty && filteredChannels.isEmpty) - _buildEmptyFilterState(context), - ..._buildSavedGroupCards( - savedChannelGroups, - ContactSection.channels, - ), - if (filteredChannels.isNotEmpty) ...[ - ..._excludeGroupedContacts( - filteredChannels, - savedChannelGroups, - ).map( - (channel) => _ChannelActivityCard( - channel: channel, - messagesProvider: messagesProvider, - contactsProvider: contactsProvider, - onNavigateToMessages: widget.onNavigateToMessages, - ), + if (showChannelsSection) ...[ + _SectionHeader( + title: l10n.channels, + count: filteredChannels.length, + icon: Icons.broadcast_on_personal, ), + _buildSectionFilterField( + context, + ContactSection.channels, + contactsProvider, + ), + ..._buildSavedGroupCards( + visibleSavedChannelGroups, + ContactSection.channels, + ), + if (filteredChannels.isNotEmpty) ...[ + ..._excludeGroupedContacts( + filteredChannels, + visibleSavedChannelGroups, + ).map( + (channel) => _ChannelActivityCard( + channel: channel, + messagesProvider: messagesProvider, + contactsProvider: contactsProvider, + onTap: () => _showChannelActionSheet(context, channel), + ), + ), + ], ], // Add Channel Button (visible in both simple and advanced mode, only show when connected) @@ -633,30 +895,18 @@ class _ContactsTabState extends State { } List _buildContactSectionItems(List contacts) { - final items = ContactGrouping.buildItemsFromSorted(contacts); - - return items.map((item) { - if (item.isGroup) { - return _InferredContactGroupCard( - label: item.group!.label, - contacts: item.group!.contacts, - currentPosition: _currentPosition, - calculateDistance: _calculateDistanceInMeters, - formatDistance: _formatDistance, - onNavigateToMap: widget.onNavigateToMap, - onNavigateToMessages: widget.onNavigateToMessages, - ); - } - - return ContactTile( - contact: item.contact!, - currentPosition: _currentPosition, - calculateDistance: _calculateDistanceInMeters, - formatDistance: _formatDistance, - onNavigateToMap: widget.onNavigateToMap, - onNavigateToMessages: widget.onNavigateToMessages, - ); - }).toList(); + return contacts + .map( + (contact) => ContactTile( + contact: contact, + currentPosition: _currentPosition, + calculateDistance: _calculateDistanceInMeters, + formatDistance: _formatDistance, + onNavigateToMap: widget.onNavigateToMap, + onNavigateToMessages: widget.onNavigateToMessages, + ), + ) + .toList(); } List _excludeGroupedContacts( @@ -690,7 +940,7 @@ class _ContactsTabState extends State { onDelete: () => context .read() .removeSavedGroupById(group.group.id), - kindLabel: 'Saved filter', + kindLabel: group.group.isAutoGroup ? 'Auto group' : 'Saved filter', ), ) .toList(); @@ -699,8 +949,11 @@ class _ContactsTabState extends State { Widget _buildSectionFilterField( BuildContext context, ContactSection section, - ContactsProvider contactsProvider, - ) { + ContactsProvider contactsProvider, { + VoidCallback? onSecondaryAction, + IconData? secondaryActionIcon, + String? secondaryActionTooltip, + }) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; final controller = _filterControllers[section]!; @@ -784,6 +1037,30 @@ class _ContactsTabState extends State { ), ), if (hasFilter) ...[ + if (onSecondaryAction != null && + secondaryActionIcon != null) + Padding( + padding: const EdgeInsets.only(right: 4), + child: Tooltip( + message: secondaryActionTooltip ?? '', + child: Material( + color: colorScheme.tertiary.withValues(alpha: 0.10), + shape: const CircleBorder(), + child: InkWell( + customBorder: const CircleBorder(), + onTap: onSecondaryAction, + child: Padding( + padding: const EdgeInsets.all(6), + child: Icon( + secondaryActionIcon, + size: 16, + color: colorScheme.tertiary, + ), + ), + ), + ), + ), + ), Padding( padding: const EdgeInsets.only(right: 4), child: Material( @@ -840,7 +1117,37 @@ class _ContactsTabState extends State { ), ), ] else - const SizedBox(width: 12), + Row( + children: [ + if (onSecondaryAction != null && + secondaryActionIcon != null) + Padding( + padding: const EdgeInsets.only(right: 6), + child: Tooltip( + message: secondaryActionTooltip ?? '', + child: Material( + color: colorScheme.tertiary.withValues( + alpha: 0.10, + ), + shape: const CircleBorder(), + child: InkWell( + customBorder: const CircleBorder(), + onTap: onSecondaryAction, + child: Padding( + padding: const EdgeInsets.all(6), + child: Icon( + secondaryActionIcon, + size: 16, + color: colorScheme.tertiary, + ), + ), + ), + ), + ), + ), + const SizedBox(width: 12), + ], + ), ], ), ), @@ -850,18 +1157,6 @@ class _ContactsTabState extends State { ); } - Widget _buildEmptyFilterState(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text( - 'No matches for this filter.', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ); - } - Widget _buildSortMenu(BuildContext context, ContactSection section) { final l10n = AppLocalizations.of(context)!; final selectedMode = _sortModes[section] ?? ContactSortMode.lastSeen; @@ -1137,13 +1432,13 @@ class _ChannelActivityCard extends StatelessWidget { final Contact channel; final MessagesProvider messagesProvider; final ContactsProvider contactsProvider; - final VoidCallback? onNavigateToMessages; + final VoidCallback? onTap; const _ChannelActivityCard({ required this.channel, required this.messagesProvider, required this.contactsProvider, - required this.onNavigateToMessages, + this.onTap, }); String _formatRelativeTime(BuildContext context, DateTime when) { @@ -1207,17 +1502,7 @@ class _ChannelActivityCard extends StatelessWidget { color: Colors.transparent, child: InkWell( borderRadius: BorderRadius.circular(18), - onTap: () async { - await MessageDestinationPreferences.setDestination( - MessageDestinationPreferences.destinationTypeChannel, - recipientPublicKey: channel.publicKeyHex, - ); - messagesProvider.navigateToDestination( - MessageDestinationPreferences.destinationTypeChannel, - recipientPublicKeyHex: channel.publicKeyHex, - ); - onNavigateToMessages?.call(); - }, + onTap: onTap, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: Row( diff --git a/lib/screens/device_config_screen.dart b/lib/screens/device_config_screen.dart index 706796f..d13cbca 100644 --- a/lib/screens/device_config_screen.dart +++ b/lib/screens/device_config_screen.dart @@ -610,6 +610,68 @@ class _DeviceConfigScreenState extends State { } } + Future _confirmFactoryReset() async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Wipe device data'), + content: const Text( + 'This will erase all data on the connected device, including contacts, keys, and saved settings. This cannot be undone.', + ), + 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('Wipe device'), + ), + ], + ), + ); + + if (confirmed != true || !mounted) return; + + final messenger = ScaffoldMessenger.of(context); + final connectionProvider = context.read(); + + try { + await connectionProvider.factoryResetDevice(); + if (!mounted) return; + + if (connectionProvider.error != null) { + messenger.showSnackBar( + SnackBar( + content: Text(connectionProvider.error!), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + return; + } + + messenger.showSnackBar( + const SnackBar( + content: Text( + 'Factory reset command sent. The device should reboot and disconnect shortly.', + ), + ), + ); + } catch (e) { + if (!mounted) return; + messenger.showSnackBar( + SnackBar( + content: Text('Failed to wipe device data: $e'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } + @override Widget build(BuildContext context) { final deviceInfo = context.watch().deviceInfo; @@ -1099,6 +1161,74 @@ class _DeviceConfigScreenState extends State { ], ), ), + const SizedBox(height: 20), + _ConfigSectionCard( + title: 'Danger zone', + subtitle: 'Destructive device actions.', + icon: Icons.warning_amber_rounded, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: colorScheme.errorContainer.withValues( + alpha: 0.55, + ), + borderRadius: BorderRadius.circular(22), + border: Border.all( + color: colorScheme.error.withValues(alpha: 0.28), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.delete_forever_rounded, + color: colorScheme.error, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Wipe data on device', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w800, + color: colorScheme.onErrorContainer, + ), + ), + const SizedBox(height: 4), + Text( + 'Erase contacts, keys, and radio settings from the connected MeshCore device and return it to factory defaults.', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onErrorContainer, + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _confirmFactoryReset, + style: FilledButton.styleFrom( + backgroundColor: colorScheme.error, + foregroundColor: colorScheme.onError, + minimumSize: const Size.fromHeight(52), + ), + icon: const Icon(Icons.delete_forever_rounded), + label: const Text('Wipe device data'), + ), + ), + ], + ), + ), ], ), ), diff --git a/lib/screens/live_traffic_screen.dart b/lib/screens/live_traffic_screen.dart index c7503ec..2a6b889 100644 --- a/lib/screens/live_traffic_screen.dart +++ b/lib/screens/live_traffic_screen.dart @@ -345,7 +345,7 @@ class _SummaryPanel extends StatelessWidget { label: 'RX packets', value: '${snapshot.rxCount}', subtitle: totalRxCount == null - ? 'Last 60 sec' + ? _windowSummaryLabel(snapshot.windowDuration) : 'Device total $totalRxCount', ), _MetricTile( @@ -504,12 +504,15 @@ class _FilterChip extends StatelessWidget { } String _windowLabel(Duration duration) { - if (duration.inMinutes >= 60) { - return '${duration.inMinutes} min'; - } return '${duration.inMinutes} min'; } +String _windowSummaryLabel(Duration duration) { + final minutes = duration.inMinutes; + if (minutes == 1) return 'Last 1 min'; + return 'Last $minutes min'; +} + class _MetricTile extends StatelessWidget { final String label; final String value; @@ -527,7 +530,7 @@ class _MetricTile extends StatelessWidget { Widget build(BuildContext context) { return Container( width: 160, - height: 128, + height: 136, padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.82), diff --git a/lib/screens/map_tab.dart b/lib/screens/map_tab.dart index c587810..b86994b 100644 --- a/lib/screens/map_tab.dart +++ b/lib/screens/map_tab.dart @@ -22,6 +22,7 @@ import '../models/message.dart'; import '../services/background_location_service.dart'; import '../services/location_tracking_service.dart'; import '../services/map_marker_service.dart'; +import '../services/message_destination_preferences.dart'; import '../services/trail_color_service.dart'; import '../widgets/map_debug_info.dart'; import '../widgets/map/compass_widget.dart'; @@ -816,6 +817,174 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { } } + Future _showSarMarkerActions( + SarMarker marker, + MessagesProvider messagesProvider, + ContactsProvider contactsProvider, + ) async { + final message = messagesProvider.getMessageById(marker.id); + + await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) { + final theme = Theme.of(sheetContext); + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text(marker.emoji, style: const TextStyle(fontSize: 24)), + const SizedBox(width: 10), + Expanded( + child: Text( + marker.displayName, + style: theme.textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + '${marker.location.latitude.toStringAsFixed(6)}, ${marker.location.longitude.toStringAsFixed(6)}', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 4), + Text( + marker.senderName != null + ? '${marker.timeAgo} • ${marker.senderName}' + : marker.timeAgo, + style: theme.textTheme.bodySmall, + ), + if (marker.notes != null && marker.notes!.isNotEmpty) ...[ + const SizedBox(height: 12), + Text(marker.notes!), + ], + const SizedBox(height: 16), + if (message != null) + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.chat_bubble_outline), + title: const Text('Open message'), + subtitle: const Text('Jump to the related SAR message'), + onTap: () async { + Navigator.pop(sheetContext); + await _openSarMarkerMessage( + message, + messagesProvider, + contactsProvider, + ); + }, + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.delete_outline, color: Colors.red), + title: Text( + 'Remove marker', + style: TextStyle(color: theme.colorScheme.error), + ), + subtitle: Text( + message != null + ? 'This also removes the linked SAR message.' + : 'Hide this marker from the map.', + ), + onTap: () async { + final confirmed = await _confirmSarMarkerRemoval( + hasMessage: message != null, + ); + if (!mounted || + !sheetContext.mounted || + confirmed != true) { + return; + } + Navigator.pop(sheetContext); + await messagesProvider.removeSarMarkerPermanently( + marker.id, + ); + }, + ), + ], + ), + ), + ); + }, + ); + } + + Future _confirmSarMarkerRemoval({required bool hasMessage}) { + return showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Remove SAR marker'), + content: Text( + hasMessage + ? 'This will remove the marker and its linked chat message.' + : 'This will hide the marker from the map, even if it is not visible in chat.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(AppLocalizations.of(dialogContext)!.cancel), + ), + TextButton( + onPressed: () => Navigator.pop(dialogContext, true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: Text(AppLocalizations.of(dialogContext)!.delete), + ), + ], + ), + ); + } + + Future _openSarMarkerMessage( + Message message, + MessagesProvider messagesProvider, + ContactsProvider contactsProvider, + ) async { + if (message.isChannelMessage) { + final channelContact = contactsProvider.channels.where((contact) { + return contact.publicKey.length > 1 && + contact.publicKey[1] == (message.channelIdx ?? 0); + }).firstOrNull; + + messagesProvider.navigateToDestination( + MessageDestinationPreferences.destinationTypeChannel, + recipientPublicKeyHex: channelContact?.publicKeyHex, + ); + } else { + Contact? destinationContact; + + if (message.recipientPublicKey != null) { + destinationContact = contactsProvider.contacts.where((contact) { + return contact.publicKey.length >= + message.recipientPublicKey!.length && + contact.publicKey.matches(message.recipientPublicKey!); + }).firstOrNull; + } else if (message.senderPublicKeyPrefix != null && + message.senderPublicKeyPrefix!.length >= 6) { + destinationContact = contactsProvider.findContactByPrefix( + message.senderPublicKeyPrefix!, + ); + } + + if (destinationContact != null) { + messagesProvider.navigateToDestination( + destinationContact.isRoom + ? MessageDestinationPreferences.destinationTypeRoom + : MessageDestinationPreferences.destinationTypeContact, + recipientPublicKeyHex: destinationContact.publicKeyHex, + ); + } + } + + messagesProvider.navigateToMessage(message.id); + widget.onNavigateToMessages?.call(); + } + /// Show SAR dialog with pre-populated location from map long press void _showSarDialogWithLocation(LatLng location) { // Create a Position object from the LatLng coordinates @@ -1119,9 +1288,26 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { Widget build(BuildContext context) { super.build(context); // Required for AutomaticKeepAliveClientMixin - return Consumer3( - builder: (context, contactsProvider, messagesProvider, drawingProvider, child) { - final contactsWithLocation = contactsProvider.contactsWithLocation; + return Consumer4< + ContactsProvider, + MessagesProvider, + DrawingProvider, + MapProvider + >( + builder: ( + context, + contactsProvider, + messagesProvider, + drawingProvider, + mapProvider, + child, + ) { + final allContactsWithLocation = contactsProvider.contactsWithLocation; + final contactsWithLocation = mapProvider.hideRepeatersOnMap + ? allContactsWithLocation + .where((contact) => !contact.isRepeater) + .toList() + : allContactsWithLocation; // Filter SAR markers based on visibility toggle final allSarMarkers = messagesProvider.sarMarkers; final sarMarkers = drawingProvider.showSarMarkers @@ -1719,7 +1905,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { onTap: (contact) { _showDetailedCompassWithContact( context, - contactsProvider.contactsWithLocation, + contactsWithLocation, messagesProvider.sarMarkers, contact, ); @@ -1731,9 +1917,11 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { context: context, mapRotation: _getMapRotation(), onTap: (marker) { - // Navigate to the corresponding message in Messages tab - messagesProvider.navigateToMessage(marker.id); - widget.onNavigateToMessages?.call(); + _showSarMarkerActions( + marker, + messagesProvider, + contactsProvider, + ); }, ), // User location marker with directional pointer @@ -2021,7 +2209,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { child: GestureDetector( onTap: () => _showDetailedCompass( context, - contactsProvider.contactsWithLocation, + contactsWithLocation, messagesProvider.sarMarkers, ), child: CompassWidget( diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index 6c95e43..beeee50 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -57,6 +57,9 @@ class _MessagesTabState extends State { int _messageByteCount = 0; String? _highlightedMessageId; Timer? _highlightTimer; // Timer for clearing message highlight + TextEditingValue _lastComposerValue = const TextEditingValue(); + bool _isMentionPickerOpen = false; + bool _suppressMentionTrigger = false; // Message destination state String _destinationType = @@ -85,7 +88,8 @@ class _MessagesTabState extends State { @override void initState() { super.initState(); - _textController.addListener(_updateCharacterCount); + _lastComposerValue = _textController.value; + _textController.addListener(_handleComposerChanged); // Load saved message destination _loadSavedDestination(); _loadVoiceSettings(); @@ -177,12 +181,73 @@ class _MessagesTabState extends State { } } + void _handleComposerChanged() { + final previousValue = _lastComposerValue; + final currentValue = _textController.value; + _lastComposerValue = currentValue; + + _updateCharacterCount(); + + if (_suppressMentionTrigger || _isMentionPickerOpen) { + return; + } + + final mentionTriggerRange = _getMentionTriggerRange( + previousValue: previousValue, + currentValue: currentValue, + ); + if (mentionTriggerRange == null) { + return; + } + + unawaited(_showMentionSelectorForRange(mentionTriggerRange)); + } + void _updateCharacterCount() { setState(() { _messageByteCount = utf8.encode(_textController.text).length; }); } + TextRange? _getMentionTriggerRange({ + required TextEditingValue previousValue, + required TextEditingValue currentValue, + }) { + if (!previousValue.selection.isValid || + !currentValue.selection.isValid || + !previousValue.selection.isCollapsed || + !currentValue.selection.isCollapsed) { + return null; + } + + final previousOffset = previousValue.selection.baseOffset; + final currentOffset = currentValue.selection.baseOffset; + if (previousOffset < 0 || currentOffset < 0) { + return null; + } + + if (currentValue.text.length != previousValue.text.length + 1 || + currentOffset != previousOffset + 1) { + return null; + } + + if (currentValue.text.substring(0, previousOffset) != + previousValue.text.substring(0, previousOffset)) { + return null; + } + + if (currentValue.text.substring(currentOffset) != + previousValue.text.substring(previousOffset)) { + return null; + } + + if (currentValue.text[previousOffset] != '@') { + return null; + } + + return TextRange(start: previousOffset, end: currentOffset); + } + int get _maxMessageBytes => _destinationType == MessageDestinationPreferences.destinationTypeChannel ? _maxChannelMessageBytes @@ -300,6 +365,56 @@ class _MessagesTabState extends State { ); } + Future _showMentionSelectorForRange(TextRange triggerRange) async { + final contactsProvider = context.read(); + final contacts = contactsProvider.contacts + .where((contact) => contact.type == ContactType.chat) + .toList(); + + if (contacts.isEmpty || !mounted) { + return; + } + + _isMentionPickerOpen = true; + Contact? selectedContact; + + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => RecipientSelectorSheet( + contacts: contacts, + rooms: const [], + channels: const [], + unreadCount: 0, + unreadCountsByPublicKey: { + for (final contact in contacts) contact.publicKeyHex: 0, + }, + currentDestinationType: null, + currentRecipientPublicKey: null, + showAllOption: false, + onSelect: (_, recipient) { + selectedContact = recipient; + }, + ), + ); + + _isMentionPickerOpen = false; + + if (!mounted) { + return; + } + + if (selectedContact != null) { + _insertReplyMention( + selectedContact!.displayName, + replacementRange: triggerRange, + ); + } + + _focusNode.requestFocus(); + } + /// Handle recipient selection Future _onRecipientSelected(String type, Contact? recipient) async { setState(() { @@ -338,13 +453,13 @@ class _MessagesTabState extends State { _focusNode.requestFocus(); } - void _insertReplyMention(String displayName) { + void _insertReplyMention(String displayName, {TextRange? replacementRange}) { final trimmedName = displayName.trim(); if (trimmedName.isEmpty) return; final mention = '@[$trimmedName] '; final value = _textController.value; - final selection = value.selection; + final selection = replacementRange ?? value.selection; final hasSelection = selection.isValid && selection.start >= 0 && @@ -355,11 +470,14 @@ class _MessagesTabState extends State { final nextText = value.text.replaceRange(start, end, mention); final nextOffset = start + mention.length; + _suppressMentionTrigger = true; _textController.value = value.copyWith( text: nextText, selection: TextSelection.collapsed(offset: nextOffset), composing: TextRange.empty, ); + _lastComposerValue = _textController.value; + _suppressMentionTrigger = false; _enforceMessageByteLimit(); } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index aa433b8..cb46ebd 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -14,6 +14,8 @@ import '../providers/contacts_provider.dart'; import '../providers/messages_provider.dart'; import '../providers/app_provider.dart'; import '../providers/connection_provider.dart'; +import '../providers/drawing_provider.dart'; +import '../providers/map_provider.dart'; import '../services/location_tracking_service.dart'; import '../services/locale_preferences.dart'; import '../services/mesh_map_nodes_service.dart'; @@ -72,6 +74,9 @@ class _SettingsScreenState extends State { bool _fastLocationUpdatesEnabled = false; double _fastLocationMovementThresholdMeters = 10.0; int _fastLocationActiveCadenceSeconds = 10; + bool _rotateMapWithHeading = false; + bool _showMapDebugInfo = false; + bool _openMapInFullscreen = false; bool _isDeveloperModeEnabled = false; DateTime? _onlineTraceCacheUpdatedAt; bool _isClearingOnlineTraceCache = false; @@ -93,6 +98,7 @@ class _SettingsScreenState extends State { _loadFastLocationSettings(); _loadDeveloperMode(); _loadOnlineTraceCacheStatus(); + _loadMapPreferences(); } @override @@ -177,6 +183,22 @@ class _SettingsScreenState extends State { await prefs.setBool('show_rx_tx_indicators', value); } + Future _loadMapPreferences() async { + final prefs = await SharedPreferences.getInstance(); + if (!mounted) return; + setState(() { + _rotateMapWithHeading = + prefs.getBool('map_rotate_with_heading') ?? false; + _showMapDebugInfo = prefs.getBool('map_show_debug_info') ?? false; + _openMapInFullscreen = prefs.getBool('map_fullscreen') ?? false; + }); + } + + Future _saveMapPreference(String key, bool value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(key, value); + } + Future _loadVoicePreferences() async { final value = await VoiceBitratePreferences.getBitrate(); if (!mounted) return; @@ -1154,6 +1176,240 @@ class _SettingsScreenState extends State { ), ]), + _buildSectionHeader('Map'), + _buildSettingsCard([ + SwitchListTile( + secondary: const Icon(Icons.explore), + title: const Text('Rotate map with heading'), + subtitle: const Text( + 'Rotate the map based on your compass or movement heading', + ), + value: _rotateMapWithHeading, + onChanged: (value) async { + setState(() { + _rotateMapWithHeading = value; + }); + await _saveMapPreference('map_rotate_with_heading', value); + }, + ), + SwitchListTile( + secondary: const Icon(Icons.bug_report_outlined), + title: const Text('Show map debug info'), + subtitle: const Text( + 'Display extra map diagnostics and internal state overlays', + ), + value: _showMapDebugInfo, + onChanged: (value) async { + setState(() { + _showMapDebugInfo = value; + }); + await _saveMapPreference('map_show_debug_info', value); + }, + ), + SwitchListTile( + secondary: const Icon(Icons.fullscreen), + title: const Text('Open map in fullscreen'), + subtitle: const Text( + 'Start the map tab in fullscreen mode by default', + ), + value: _openMapInFullscreen, + onChanged: (value) async { + setState(() { + _openMapInFullscreen = value; + }); + await _saveMapPreference('map_fullscreen', value); + }, + ), + Consumer( + builder: (context, drawingProvider, child) => SwitchListTile( + secondary: const Icon(Icons.fmd_good_outlined), + title: const Text('Show SAR markers'), + subtitle: const Text( + 'Display SAR markers on the main map', + ), + value: drawingProvider.showSarMarkers, + onChanged: (value) { + drawingProvider.toggleSarMarkers(); + }, + ), + ), + Consumer( + builder: (context, mapProvider, child) => SwitchListTile( + secondary: const Icon(Icons.timeline), + title: const Text('Show all contact trails'), + subtitle: const Text( + 'Display location trails for all contacts that have history', + ), + value: mapProvider.showAllContactTrails, + onChanged: (value) async { + await mapProvider.toggleAllContactTrails(); + }, + ), + ), + Consumer( + builder: (context, mapProvider, child) => SwitchListTile( + secondary: const Icon(Icons.router_outlined), + title: const Text('Hide repeaters on map'), + subtitle: const Text( + 'Hide repeater contacts from the main map view', + ), + value: mapProvider.hideRepeatersOnMap, + onChanged: (value) async { + await mapProvider.setHideRepeatersOnMap(value); + }, + ), + ), + ]), + _buildSettingsCard([ + ListTile( + leading: const Icon(Icons.layers_outlined), + title: const Text('Rendering'), + subtitle: const Text( + 'Control map drawings and overlay layers used by the renderer', + ), + ), + Consumer( + builder: (context, drawingProvider, child) => SwitchListTile( + secondary: const Icon(Icons.draw_outlined), + title: Text(AppLocalizations.of(context)!.showReceivedDrawings), + subtitle: const Text( + 'Render drawings received from other devices on the map', + ), + value: drawingProvider.showReceivedDrawings, + onChanged: (value) async { + await drawingProvider.toggleReceivedDrawings(); + }, + ), + ), + Consumer( + builder: (context, mapProvider, child) => SwitchListTile( + secondary: const Icon(Icons.grid_on, color: Colors.blue), + title: Text(AppLocalizations.of(context)!.cadastralParcels), + subtitle: const Text('WMS overlay'), + value: mapProvider.showCadastralOverlay, + onChanged: (value) async { + await mapProvider.toggleCadastralOverlay(); + }, + ), + ), + Consumer( + builder: (context, mapProvider, child) => SwitchListTile( + secondary: const Icon(Icons.route, color: Colors.green), + title: Text(AppLocalizations.of(context)!.forestRoads), + subtitle: const Text('WMS overlay'), + value: mapProvider.showForestRoadsOverlay, + onChanged: (value) async { + await mapProvider.toggleForestRoadsOverlay(); + }, + ), + ), + Consumer( + builder: (context, mapProvider, child) => SwitchListTile( + secondary: const Icon(Icons.hiking, color: Colors.brown), + title: Text(AppLocalizations.of(context)!.hikingTrails), + subtitle: const Text('WMS overlay'), + value: mapProvider.showHikingTrailsOverlay, + onChanged: (value) async { + await mapProvider.toggleHikingTrailsOverlay(); + }, + ), + ), + Consumer( + builder: (context, mapProvider, child) => SwitchListTile( + secondary: const Icon(Icons.alt_route, color: Colors.grey), + title: Text(AppLocalizations.of(context)!.mainRoads), + subtitle: const Text('WMS overlay'), + value: mapProvider.showMainRoadsOverlay, + onChanged: (value) async { + await mapProvider.toggleMainRoadsOverlay(); + }, + ), + ), + Consumer( + builder: (context, mapProvider, child) => SwitchListTile( + secondary: const Icon(Icons.numbers, color: Colors.purple), + title: Text(AppLocalizations.of(context)!.houseNumbers), + subtitle: const Text('WMS overlay'), + value: mapProvider.showHouseNumbersOverlay, + onChanged: (value) async { + await mapProvider.toggleHouseNumbersOverlay(); + }, + ), + ), + Consumer( + builder: (context, mapProvider, child) => SwitchListTile( + secondary: const Icon( + Icons.warning_amber, + color: Colors.orange, + ), + title: Text(AppLocalizations.of(context)!.fireHazardZones), + subtitle: const Text('WMS overlay'), + value: mapProvider.showFireHazardZonesOverlay, + onChanged: (value) async { + await mapProvider.toggleFireHazardZonesOverlay(); + }, + ), + ), + Consumer( + builder: (context, mapProvider, child) => SwitchListTile( + secondary: const Icon( + Icons.local_fire_department, + color: Colors.red, + ), + title: Text(AppLocalizations.of(context)!.historicalFires), + subtitle: const Text('WMS overlay'), + value: mapProvider.showHistoricalFiresOverlay, + onChanged: (value) async { + await mapProvider.toggleHistoricalFiresOverlay(); + }, + ), + ), + Consumer( + builder: (context, mapProvider, child) => SwitchListTile( + secondary: const Icon(Icons.forest, color: Colors.teal), + title: Text(AppLocalizations.of(context)!.firebreaks), + subtitle: const Text('WMS overlay'), + value: mapProvider.showFirebreaksOverlay, + onChanged: (value) async { + await mapProvider.toggleFirebreaksOverlay(); + }, + ), + ), + Consumer( + builder: (context, mapProvider, child) => SwitchListTile( + secondary: const Icon(Icons.warning, color: Colors.deepOrange), + title: Text(AppLocalizations.of(context)!.krasFireZones), + subtitle: const Text('WMS overlay'), + value: mapProvider.showKrasFireZonesOverlay, + onChanged: (value) async { + await mapProvider.toggleKrasFireZonesOverlay(); + }, + ), + ), + Consumer( + builder: (context, mapProvider, child) => SwitchListTile( + secondary: const Icon(Icons.place, color: Colors.indigo), + title: Text(AppLocalizations.of(context)!.placeNames), + subtitle: const Text('WMS overlay'), + value: mapProvider.showPlaceNamesOverlay, + onChanged: (value) async { + await mapProvider.togglePlaceNamesOverlay(); + }, + ), + ), + Consumer( + builder: (context, mapProvider, child) => SwitchListTile( + secondary: const Icon(Icons.border_outer, color: Colors.cyan), + title: Text(AppLocalizations.of(context)!.municipalityBorders), + subtitle: const Text('WMS overlay'), + value: mapProvider.showMunicipalityBordersOverlay, + onChanged: (value) async { + await mapProvider.toggleMunicipalityBordersOverlay(); + }, + ), + ), + ]), + _buildSectionHeader('Voice'), Consumer2( builder: (context, appProvider, connectionProvider, child) => diff --git a/lib/services/contact_storage_service.dart b/lib/services/contact_storage_service.dart index 72f5991..d2e3e74 100644 --- a/lib/services/contact_storage_service.dart +++ b/lib/services/contact_storage_service.dart @@ -159,6 +159,7 @@ class ContactStorageService { 'outPathLen': contact.outPathLen, 'outPath': base64Encode(contact.outPath), 'advName': contact.advName, + 'nameOverride': contact.nameOverride, 'lastAdvert': contact.lastAdvert, 'advLat': contact.advLat, 'advLon': contact.advLon, @@ -181,6 +182,7 @@ class ContactStorageService { outPathLen: json['outPathLen'] as int, outPath: Uint8List.fromList(base64Decode(json['outPath'] as String)), advName: json['advName'] as String, + nameOverride: json['nameOverride'] as String?, lastAdvert: json['lastAdvert'] as int, advLat: json['advLat'] as int, advLon: json['advLon'] as int, @@ -247,6 +249,8 @@ class ContactStorageService { 'label': group.label, 'query': group.query, 'createdAtMillis': group.createdAt.millisecondsSinceEpoch, + 'matchPrefixes': group.matchPrefixes, + 'isAutoGroup': group.isAutoGroup, }; } @@ -260,6 +264,10 @@ class ContactStorageService { createdAt: DateTime.fromMillisecondsSinceEpoch( json['createdAtMillis'] as int, ), + matchPrefixes: (json['matchPrefixes'] as List?) + ?.map((value) => value as String) + .toList(), + isAutoGroup: json['isAutoGroup'] as bool? ?? false, ); } catch (e) { debugPrint('❌ [ContactStorage] Error parsing contact group: $e'); diff --git a/lib/services/live_traffic_summary.dart b/lib/services/live_traffic_summary.dart index e418a32..38c4816 100644 --- a/lib/services/live_traffic_summary.dart +++ b/lib/services/live_traffic_summary.dart @@ -218,7 +218,8 @@ class LiveTrafficSummary { final visibleEntries = filteredEntries.reversed.take(maxVisibleEntries).toList(); const txCount = 0; final totalCount = rxCount; - final packetsPerMinute = totalCount; + final packetsPerMinute = + ((totalCount * Duration.secondsPerMinute) / window.inSeconds).round(); return LiveTrafficSnapshot( windowStart: effectiveStart, diff --git a/lib/services/message_storage_service.dart b/lib/services/message_storage_service.dart index 265c481..3406d26 100644 --- a/lib/services/message_storage_service.dart +++ b/lib/services/message_storage_service.dart @@ -11,6 +11,7 @@ import 'package:latlong2/latlong.dart'; /// Service for persisting messages to local storage class MessageStorageService { static const String _messagesKey = 'stored_messages'; + static const String _removedSarMarkerIdsKey = 'removed_sar_marker_ids'; static const String _messageContactLocationsKey = 'stored_message_contact_locations'; static const String _messageReceptionDetailsKey = @@ -269,12 +270,36 @@ class MessageStorageService { await prefs.remove(_messageReceptionDetailsKey); await prefs.remove(_messageTransferDetailsKey); await prefs.remove(_messageRouteMetadataKey); + await prefs.remove(_removedSarMarkerIdsKey); debugPrint('✅ [MessageStorage] Cleared all stored messages'); } catch (e) { debugPrint('❌ [MessageStorage] Error clearing messages: $e'); } } + Future> loadRemovedSarMarkerIds() async { + try { + final prefs = await SharedPreferences.getInstance(); + final ids = prefs.getStringList(_removedSarMarkerIdsKey) ?? const []; + return ids.toSet(); + } catch (e) { + debugPrint('❌ [MessageStorage] Error loading removed SAR marker IDs: $e'); + return const {}; + } + } + + Future saveRemovedSarMarkerIds(Set ids) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList(_removedSarMarkerIdsKey, ids.toList()..sort()); + debugPrint( + '✅ [MessageStorage] Saved ${ids.length} removed SAR marker IDs', + ); + } catch (e) { + debugPrint('❌ [MessageStorage] Error saving removed SAR marker IDs: $e'); + } + } + /// Get storage statistics Future> getStorageStats() async { try { diff --git a/lib/utils/contact_grouping.dart b/lib/utils/contact_grouping.dart index d2e110e..875102c 100644 --- a/lib/utils/contact_grouping.dart +++ b/lib/utils/contact_grouping.dart @@ -4,54 +4,98 @@ class InferredContactGroup { final String key; final String label; final List contacts; + final List matchPrefixes; const InferredContactGroup({ required this.key, required this.label, required this.contacts, - }); + List? matchPrefixes, + }) : matchPrefixes = matchPrefixes ?? const []; DateTime get latestSeen => contacts.first.lastSeenTime; } -class ContactListItem { - final Contact? contact; - final InferredContactGroup? group; - - const ContactListItem._({this.contact, this.group}); - - const ContactListItem.contact(Contact contact) : this._(contact: contact); - - const ContactListItem.group(InferredContactGroup group) - : this._(group: group); - - bool get isGroup => group != null; - - DateTime get latestSeen => group?.latestSeen ?? contact!.lastSeenTime; -} - class ContactGrouping { static final RegExp _prefixedNamePattern = RegExp( r'^([A-Za-z0-9]{2,})([-_/:])', ); + static String? inferredGroupLabelForContact(Contact contact) { + return _extractPrefix(contact.displayName)?.label; + } + + static bool contactMatchesInferredGroupLabel(Contact contact, String query) { + final normalizedQuery = query.trim().toLowerCase(); + if (normalizedQuery.isEmpty) { + return false; + } + + final groupLabel = inferredGroupLabelForContact(contact)?.toLowerCase(); + return groupLabel?.contains(normalizedQuery) ?? false; + } + + static String? sharedInferredGroupLabel(List contacts) { + String? sharedLabel; + for (final contact in contacts) { + final label = inferredGroupLabelForContact(contact); + if (label == null) { + return null; + } + if (sharedLabel == null) { + sharedLabel = label; + continue; + } + if (sharedLabel != label) { + return null; + } + } + return sharedLabel; + } + + static String? sharedParentGroupLabel(List labels) { + if (labels.isEmpty) { + return null; + } + + var commonPrefix = labels.first; + for (final label in labels.skip(1)) { + final maxLength = commonPrefix.length < label.length + ? commonPrefix.length + : label.length; + var matchLength = 0; + while (matchLength < maxLength && + commonPrefix.codeUnitAt(matchLength) == + label.codeUnitAt(matchLength)) { + matchLength++; + } + commonPrefix = commonPrefix.substring(0, matchLength); + if (commonPrefix.isEmpty) { + return null; + } + } + + final separatorIndex = commonPrefix.lastIndexOf(RegExp(r'[-_/:]')); + if (separatorIndex < 1) { + return null; + } + + final parentLabel = commonPrefix.substring(0, separatorIndex + 1); + return parentLabel.length >= 3 ? parentLabel : null; + } + static List sortByLastSeen(List contacts) { return List.from(contacts) ..sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime)); } - static List buildItems( + static List inferGroups( List contacts, { int minGroupSize = 4, + int? maxNamedGroups, + String? overflowGroupLabel, }) { final sortedContacts = sortByLastSeen(contacts); - return buildItemsFromSorted(sortedContacts, minGroupSize: minGroupSize); - } - - static List buildItemsFromSorted( - List sortedContacts, { - int minGroupSize = 4, - }) { final groupedContacts = >{}; final groupLabels = {}; @@ -62,32 +106,46 @@ class ContactGrouping { groupLabels.putIfAbsent(prefix.key, () => prefix.label); } - final eligibleGroups = {}; + final rankedGroups = []; for (final entry in groupedContacts.entries) { if (entry.value.length < minGroupSize) continue; - eligibleGroups[entry.key] = InferredContactGroup( - key: entry.key, - label: groupLabels[entry.key] ?? entry.key, - contacts: entry.value, + rankedGroups.add( + InferredContactGroup( + key: entry.key, + label: groupLabels[entry.key] ?? entry.key, + contacts: entry.value, + matchPrefixes: [groupLabels[entry.key] ?? entry.key], + ), ); } + rankedGroups.sort((a, b) => b.latestSeen.compareTo(a.latestSeen)); - final emittedGroups = {}; - final items = []; + if (maxNamedGroups != null && + overflowGroupLabel != null && + rankedGroups.length > maxNamedGroups) { + final retainedGroups = rankedGroups.take(maxNamedGroups).toList(); + final overflowGroups = rankedGroups.skip(maxNamedGroups).toList(); + final parentLabel = sharedParentGroupLabel([ + for (final group in overflowGroups) ...group.matchPrefixes, + ]); + if (parentLabel != null) { + final overflowContacts = overflowGroups + .expand((group) => group.contacts) + .toList(); + return [ + ...retainedGroups, + InferredContactGroup( + key: parentLabel, + label: parentLabel, + contacts: sortByLastSeen(overflowContacts), + matchPrefixes: [parentLabel], + ), + ]; + } - for (final contact in sortedContacts) { - final prefix = _extractPrefix(contact.displayName); - final group = prefix == null ? null : eligibleGroups[prefix.key]; - if (group == null) { - items.add(ContactListItem.contact(contact)); - continue; - } - if (emittedGroups.add(group.key)) { - items.add(ContactListItem.group(group)); - } + return rankedGroups; } - - return items; + return rankedGroups; } static _GroupPrefix? _extractPrefix(String name) { diff --git a/lib/widgets/contacts/contact_tile.dart b/lib/widgets/contacts/contact_tile.dart index 29a757f..897d0df 100644 --- a/lib/widgets/contacts/contact_tile.dart +++ b/lib/widgets/contacts/contact_tile.dart @@ -318,104 +318,115 @@ class ContactTile extends StatelessWidget { borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), builder: (sheetContext) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(Icons.message_outlined), - title: Text(l10n.messages), - enabled: canMessage, - onTap: !canMessage - ? null - : () async { - Navigator.pop(sheetContext); - await _openMessagesForContact(context, contact); - }, - ), - if (contact.displayLocation != null) + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ ListTile( - leading: const Icon(Icons.map_outlined), - title: Text(l10n.viewOnMap), - onTap: () { - Navigator.pop(sheetContext); - _showContactOnMap(context, contact); - }, - ), - if (contact.type == ContactType.room && !contact.isPublicChannel) - ListTile( - leading: const Icon(Icons.login), - title: Text( - context - .read() - .getRoomLoginState(contact.publicKeyPrefix) - ?.isLoggedIn == - true - ? AppLocalizations.of(context)!.reLoginToRoom - : AppLocalizations.of(context)!.loginToRoom, - ), - onTap: () { - Navigator.pop(sheetContext); - _showRoomLoginDialog(context, contact); - }, - ), - if (canAddToSensors) - ListTile( - leading: Icon( - isInSensors ? Icons.sensors : Icons.sensors_outlined, - ), - title: Text( - isInSensors - ? l10n.contactInSensors - : l10n.contactAddToSensors, - ), - enabled: !isInSensors, - onTap: isInSensors + leading: const Icon(Icons.message_outlined), + title: Text(l10n.messages), + enabled: canMessage, + onTap: !canMessage ? null : () async { Navigator.pop(sheetContext); - await _addContactToSensors(context, contact); + await _openMessagesForContact(context, contact); }, ), - if (canSetPath) - ListTile( - leading: const Icon(Icons.alt_route), - title: Text(l10n.contactSetPath), - onTap: () { - Navigator.pop(sheetContext); - _showSetRouteDialog(context, contact); - }, - ), - if (!contact.isChannel) - ListTile( - leading: const Icon(Icons.route), - title: const Text('Trace'), - onTap: () { - Navigator.pop(sheetContext); - _showTraceSheet(context, contact); - }, - ), - if (!contact.isPublicChannel) - ListTile( - leading: const Icon(Icons.delete, color: Colors.red), - title: Text( - contact.isChannel ? l10n.deleteChannel : l10n.deleteContact, - style: const TextStyle(color: Colors.red), + if (contact.displayLocation != null) + ListTile( + leading: const Icon(Icons.map_outlined), + title: Text(l10n.viewOnMap), + onTap: () { + Navigator.pop(sheetContext); + _showContactOnMap(context, contact); + }, ), - onTap: () async { - Navigator.pop(sheetContext); - await Future.delayed(Duration.zero); - if (!context.mounted) return; - WidgetsBinding.instance.addPostFrameCallback((_) { + if (contact.type == ContactType.room && !contact.isPublicChannel) + ListTile( + leading: const Icon(Icons.login), + title: Text( + context + .read() + .getRoomLoginState(contact.publicKeyPrefix) + ?.isLoggedIn == + true + ? AppLocalizations.of(context)!.reLoginToRoom + : AppLocalizations.of(context)!.loginToRoom, + ), + onTap: () { + Navigator.pop(sheetContext); + _showRoomLoginDialog(context, contact); + }, + ), + if (canAddToSensors) + ListTile( + leading: Icon( + isInSensors ? Icons.sensors : Icons.sensors_outlined, + ), + title: Text( + isInSensors + ? l10n.contactInSensors + : l10n.contactAddToSensors, + ), + enabled: !isInSensors, + onTap: isInSensors + ? null + : () async { + Navigator.pop(sheetContext); + await _addContactToSensors(context, contact); + }, + ), + if (canSetPath) + ListTile( + leading: const Icon(Icons.alt_route), + title: Text(l10n.contactSetPath), + onTap: () { + Navigator.pop(sheetContext); + _showSetRouteDialog(context, contact); + }, + ), + if (!contact.isChannel) + ListTile( + leading: const Icon(Icons.route), + title: const Text('Trace'), + onTap: () { + Navigator.pop(sheetContext); + _showTraceSheet(context, contact); + }, + ), + if (!contact.isPublicChannel) + ListTile( + leading: const Icon(Icons.edit_outlined), + title: const Text('Edit name'), + onTap: () { + Navigator.pop(sheetContext); + _showNameOverrideDialog(context, contact); + }, + ), + if (!contact.isPublicChannel) + ListTile( + leading: const Icon(Icons.delete, color: Colors.red), + title: Text( + contact.isChannel ? l10n.deleteChannel : l10n.deleteContact, + style: const TextStyle(color: Colors.red), + ), + onTap: () async { + Navigator.pop(sheetContext); + await Future.delayed(Duration.zero); if (!context.mounted) return; - if (contact.isChannel) { - _showDeleteChannelDialog(context, contact); - } else { - _showDeleteConfirmation(context, contact); - } - }); - }, - ), - ], + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) return; + if (contact.isChannel) { + _showDeleteChannelDialog(context, contact); + } else { + _showDeleteConfirmation(context, contact); + } + }); + }, + ), + ], + ), ), ), ); @@ -555,6 +566,61 @@ class ContactTile extends StatelessWidget { } } + Future _showNameOverrideDialog( + BuildContext context, + Contact contact, + ) async { + final controller = TextEditingController(text: contact.nameOverride ?? ''); + final l10n = AppLocalizations.of(context)!; + + final result = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Edit name'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: controller, + autofocus: true, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + labelText: 'Custom name', + hintText: contact.advName, + helperText: 'Leave blank to use the advertised name.', + ), + onSubmitted: (value) { + Navigator.of(dialogContext).pop(value); + }, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(l10n.cancel), + ), + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(controller.text), + child: Text(l10n.save), + ), + ], + ), + ); + + controller.dispose(); + + if (result == null || !context.mounted) { + return; + } + + context.read().setContactNameOverride( + contact.publicKeyHex, + result, + ); + } + Future _showSetRouteDialog( BuildContext context, Contact contact, diff --git a/lib/widgets/messages/recipient_selector_sheet.dart b/lib/widgets/messages/recipient_selector_sheet.dart index bb410d2..15fdb0d 100644 --- a/lib/widgets/messages/recipient_selector_sheet.dart +++ b/lib/widgets/messages/recipient_selector_sheet.dart @@ -12,6 +12,7 @@ class RecipientSelectorSheet extends StatefulWidget { final Map unreadCountsByPublicKey; final String? currentDestinationType; final String? currentRecipientPublicKey; + final bool showAllOption; final Function(String type, Contact? recipient) onSelect; const RecipientSelectorSheet({ @@ -23,6 +24,7 @@ class RecipientSelectorSheet extends StatefulWidget { required this.unreadCountsByPublicKey, this.currentDestinationType, this.currentRecipientPublicKey, + this.showAllOption = true, required this.onSelect, }); @@ -61,6 +63,11 @@ class _RecipientSelectorSheetState extends State { final filteredContacts = _filterContacts(widget.contacts); final filteredRooms = _filterContacts(widget.rooms); final filteredChannels = _filterContacts(widget.channels); + final showChannelsSection = widget.channels.isNotEmpty; + final showContactsSection = widget.contacts.isNotEmpty; + final showRoomsSection = widget.rooms.isNotEmpty; + final showAnyRecipients = + showChannelsSection || showContactsSection || showRoomsSection; return Container( constraints: BoxConstraints( @@ -142,21 +149,23 @@ class _RecipientSelectorSheetState extends State { child: ListView( shrinkWrap: true, children: [ - _buildOptionTile( - context: context, - icon: Icons.all_inbox, - title: l10n.showAll, - subtitle: 'All messages', - unreadCount: widget.unreadCount, - isSelected: _isSelected('all', null), - onTap: () { - widget.onSelect('all', null); - Navigator.pop(context); - }, - ), - const Divider(), + if (widget.showAllOption) ...[ + _buildOptionTile( + context: context, + icon: Icons.all_inbox, + title: l10n.showAll, + subtitle: 'All messages', + unreadCount: widget.unreadCount, + isSelected: _isSelected('all', null), + onTap: () { + widget.onSelect('all', null); + Navigator.pop(context); + }, + ), + if (showAnyRecipients) const Divider(), + ], // Channels section - if (widget.channels.isNotEmpty) ...[ + if (showChannelsSection) ...[ Padding( padding: const EdgeInsets.symmetric( horizontal: 16, @@ -204,10 +213,12 @@ class _RecipientSelectorSheetState extends State { }), ], - const Divider(), + if (showChannelsSection && + (showContactsSection || showRoomsSection)) + const Divider(), // Contacts section - if (widget.contacts.isNotEmpty) ...[ + if (showContactsSection) ...[ Padding( padding: const EdgeInsets.symmetric( horizontal: 16, @@ -253,10 +264,10 @@ class _RecipientSelectorSheetState extends State { }), ], - const Divider(), + if (showContactsSection && showRoomsSection) const Divider(), // Rooms section - if (widget.rooms.isNotEmpty) ...[ + if (showRoomsSection) ...[ Padding( padding: const EdgeInsets.symmetric( horizontal: 16, @@ -302,9 +313,7 @@ class _RecipientSelectorSheetState extends State { ], // Empty state - if (widget.contacts.isEmpty && - widget.rooms.isEmpty && - widget.channels.isEmpty) ...[ + if (!showAnyRecipients) ...[ Padding( padding: const EdgeInsets.all(32), child: Column( diff --git a/pubspec.lock b/pubspec.lock index 4d84f84..57a6418 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -794,11 +794,9 @@ packages: meshcore_client: dependency: "direct main" description: - path: "." - ref: main - resolved-ref: bd3744ee21376b81be5f852cd0c1a82c0df40460 - url: "https://github.com/dz0ny/meshcore_client.git" - source: git + path: "../meshcore_client" + relative: true + source: path version: "0.1.0" meta: dependency: transitive diff --git a/pubspec.yaml b/pubspec.yaml index e1c14dd..d86e210 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -139,6 +139,9 @@ dev_dependencies: fake_async: ^1.3.3 dependency_overrides: + meshcore_client: + path: ../meshcore_client + # path_provider_foundation 2.6.0 pulls in package:objective_c as a native # asset. That framework has been ending up archived with a macOS platform # slice and fails App Store validation for iOS uploads. diff --git a/test/providers/contacts_provider_test.dart b/test/providers/contacts_provider_test.dart index 8262f43..688b86a 100644 --- a/test/providers/contacts_provider_test.dart +++ b/test/providers/contacts_provider_test.dart @@ -4,6 +4,7 @@ import 'package:geolocator/geolocator.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:latlong2/latlong.dart'; import 'package:meshcore_sar_app/models/contact.dart'; +import 'package:meshcore_sar_app/models/contact_group.dart'; import 'package:meshcore_sar_app/providers/contacts_provider.dart'; import 'package:meshcore_sar_app/services/cayenne_lpp_parser.dart'; import 'package:meshcore_sar_app/utils/fast_gps_packet.dart'; @@ -427,6 +428,46 @@ void main() { expect(reloaded.advertLocation!.longitude, closeTo(13.9999, 0.0001)); }, ); + + test('prefers a local name override and preserves it across refreshes', () { + provider.setContactNameOverride(publicKeyHex(publicKey), 'Rescue One'); + + final renamed = provider.findContactByKey(publicKey)!; + expect(renamed.nameOverride, 'Rescue One'); + expect(renamed.displayName, 'Rescue One'); + + provider.addOrUpdateContact( + Contact( + publicKey: publicKey, + type: ContactType.chat, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: 'Updated Advertised Name', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: (46.0569 * 1e6).toInt(), + advLon: (14.5058 * 1e6).toInt(), + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ), + ); + + final refreshed = provider.findContactByKey(publicKey)!; + expect(refreshed.nameOverride, 'Rescue One'); + expect(refreshed.displayName, 'Rescue One'); + expect(refreshed.advName, 'Updated Advertised Name'); + }); + + test('persists a local name override across reloads', () async { + provider.setContactNameOverride(publicKeyHex(publicKey), 'Rescue One'); + await Future.delayed(Duration.zero); + + final reloadedProvider = ContactsProvider(); + await reloadedProvider.initializeEarly(); + + final reloaded = reloadedProvider.findContactByKey(publicKey)!; + expect(reloaded.nameOverride, 'Rescue One'); + expect(reloaded.displayName, 'Rescue One'); + }); }); group('ContactsProvider route updates', () { @@ -733,5 +774,45 @@ void main() { expect(restored.savedGroupsForSection('rooms').first.query, 'ops'); expect(restored.savedGroupsForSection('rooms').first.label, 'ops'); }); + + test('replaces persisted auto groups for a section', () async { + await provider.replaceAutoGroupsForSection('repeaters', [ + SavedContactGroup( + id: '${ContactsProvider.autoGroupIdPrefix}repeaters_al', + sectionKey: 'repeaters', + label: 'AL-', + query: 'AL-', + createdAt: DateTime(2026, 3, 10, 12), + matchPrefixes: const ['AL-'], + isAutoGroup: true, + ), + SavedContactGroup( + id: '${ContactsProvider.autoGroupIdPrefix}repeaters_others', + sectionKey: 'repeaters', + label: 'Others', + query: 'Others', + createdAt: DateTime(2026, 3, 10, 12), + matchPrefixes: const ['CR-', 'DE-'], + isAutoGroup: true, + ), + ]); + + final restored = ContactsProvider(); + await restored.initializeEarly(); + + expect(restored.savedGroupsForSection('repeaters'), hasLength(2)); + expect( + restored.savedGroupsForSection('repeaters').first.matchPrefixes, + isNotEmpty, + ); + expect( + restored.savedGroupsForSection('repeaters').first.isAutoGroup, + isTrue, + ); + }); }); } + +String publicKeyHex(Uint8List publicKey) { + return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join(''); +} diff --git a/test/providers/messages_provider_voice_test.dart b/test/providers/messages_provider_voice_test.dart index b8715b2..78f285d 100644 --- a/test/providers/messages_provider_voice_test.dart +++ b/test/providers/messages_provider_voice_test.dart @@ -131,6 +131,108 @@ void main() { expect(snapshot.location.longitude, closeTo(14.5058, 0.000001)); }); + test('dedupes repeated incoming contact messages with same text', () { + final provider = MessagesProvider(); + final sender = Uint8List.fromList([0, 1, 2, 3, 4, 5]); + + provider.addMessage( + Message( + id: 'dup-1', + messageType: MessageType.contact, + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700001000, + text: 'same payload', + receivedAt: DateTime.now(), + senderPublicKeyPrefix: sender, + ), + ); + provider.addMessage( + Message( + id: 'dup-2', + messageType: MessageType.contact, + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700001999, + text: 'same payload', + receivedAt: DateTime.now(), + senderPublicKeyPrefix: sender, + ), + ); + + expect(provider.messages, hasLength(1)); + expect(provider.messages.single.id, equals('dup-1')); + }); + + test('keeps separate incoming messages when text differs', () { + final provider = MessagesProvider(); + final sender = Uint8List.fromList([0, 1, 2, 3, 4, 5]); + + provider.addMessage( + Message( + id: 'unique-1', + messageType: MessageType.contact, + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700002000, + text: 'same payload', + receivedAt: DateTime.now(), + senderPublicKeyPrefix: sender, + ), + ); + provider.addMessage( + Message( + id: 'unique-2', + messageType: MessageType.contact, + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700002999, + text: 'different payload', + receivedAt: DateTime.now(), + senderPublicKeyPrefix: sender, + ), + ); + + expect(provider.messages, hasLength(2)); + }); + + test('removed SAR markers stay hidden after provider restore', () async { + final provider = MessagesProvider(); + final message = Message( + id: 'sar-hidden', + messageType: MessageType.channel, + channelIdx: 0, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: 1700000003, + text: 'S:🧑:0:46.0569,14.5058:Hidden marker', + receivedAt: DateTime.now(), + senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]), + ); + + provider.addMessage(message); + expect( + provider.sarMarkers.map((marker) => marker.id), + contains(message.id), + ); + + await provider.removeSarMarker(message.id); + expect(provider.sarMarkers, isEmpty); + + final restoredProvider = MessagesProvider(); + await restoredProvider.initialize(); + + expect( + restoredProvider.messages.map((stored) => stored.id), + contains(message.id), + ); + expect( + restoredProvider.sarMarkers.map((marker) => marker.id), + isNot(contains(message.id)), + ); + expect(restoredProvider.removedSarMarkerIds, contains(message.id)); + }); + test('tracks and persists media transfer counts and downloaders', () async { final provider = MessagesProvider(); final voiceEnvelope = VoiceEnvelope( diff --git a/test/screens/contacts_tab_test.dart b/test/screens/contacts_tab_test.dart new file mode 100644 index 0000000..e16dd50 --- /dev/null +++ b/test/screens/contacts_tab_test.dart @@ -0,0 +1,92 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/l10n/app_localizations.dart'; +import 'package:meshcore_sar_app/models/contact.dart'; +import 'package:meshcore_sar_app/providers/connection_provider.dart'; +import 'package:meshcore_sar_app/providers/contacts_provider.dart'; +import 'package:meshcore_sar_app/providers/map_provider.dart'; +import 'package:meshcore_sar_app/providers/messages_provider.dart'; +import 'package:meshcore_sar_app/screens/contacts_tab.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + Contact buildChannel({required String name, required int channelIndex}) { + final publicKey = Uint8List(32); + publicKey[0] = 0xFF; + publicKey[1] = channelIndex; + + return Contact( + publicKey: publicKey, + type: ContactType.channel, + flags: 0, + outPathLen: -1, + outPath: Uint8List(0), + advName: name, + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 0, + advLon: 0, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + } + + Future pumpContactsTab( + WidgetTester tester, { + required List channels, + }) async { + final contactsProvider = ContactsProvider(); + for (final channel in channels) { + contactsProvider.addOrUpdateContact(channel); + } + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: contactsProvider), + ChangeNotifierProvider(create: (_) => ConnectionProvider()), + ChangeNotifierProvider(create: (_) => MessagesProvider()), + ChangeNotifierProvider(create: (_) => MapProvider()), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold(body: ContactsTab()), + ), + ), + ); + + await tester.pumpAndSettle(); + } + + testWidgets('private channel activity card shows delete action in sheet', ( + tester, + ) async { + await pumpContactsTab( + tester, + channels: [buildChannel(name: 'Ops', channelIndex: 3)], + ); + + expect(find.text('Ops'), findsOneWidget); + await tester.tap(find.text('Ops')); + await tester.pumpAndSettle(); + + expect(find.text('Delete Channel'), findsOneWidget); + + await tester.tap(find.text('Delete Channel')); + await tester.pumpAndSettle(); + + expect(find.text('Delete Channel'), findsWidgets); + expect( + find.text( + 'Are you sure you want to delete channel "Ops"? This action cannot be undone.', + ), + findsOneWidget, + ); + }); +} diff --git a/test/screens/live_traffic_screen_test.dart b/test/screens/live_traffic_screen_test.dart index c0b05ed..5ad947f 100644 --- a/test/screens/live_traffic_screen_test.dart +++ b/test/screens/live_traffic_screen_test.dart @@ -66,7 +66,7 @@ void main() { ), ); - expect(find.text('No live traffic yet'), findsOneWidget); + expect(find.text('No packets for this filter'), findsOneWidget); expect(find.text('Quiet'), findsOneWidget); }); @@ -115,7 +115,7 @@ void main() { expect(find.text('1 pkt/min'), findsOneWidget); expect(find.text('Device total 7'), findsOneWidget); - expect(find.textContaining('RESP'), findsOneWidget); + expect(find.text('Response'), findsWidgets); expect(find.text('MULTI-HOP'), findsOneWidget); expect(find.textContaining('RSSI -84 dBm'), findsOneWidget); }); @@ -151,7 +151,7 @@ void main() { await tester.tap(find.byTooltip('Clear live view')); await tester.pump(); - expect(find.text('No live traffic yet'), findsOneWidget); + expect(find.text('No packets for this filter'), findsOneWidget); now = now.add(const Duration(seconds: 2)); logs.add( @@ -173,7 +173,7 @@ void main() { refresh.value += 1; await tester.pump(); - expect(find.text('No live traffic yet'), findsNothing); + expect(find.text('No packets for this filter'), findsNothing); expect(find.textContaining('3 bytes'), findsOneWidget); }); } diff --git a/test/services/live_traffic_summary_test.dart b/test/services/live_traffic_summary_test.dart index de8dbd9..4479104 100644 --- a/test/services/live_traffic_summary_test.dart +++ b/test/services/live_traffic_summary_test.dart @@ -122,6 +122,27 @@ void main() { expect(snapshot.busyness, LiveTrafficBusyness.quiet); }); + test('normalizes packet rate for windows longer than one minute', () { + final now = DateTime(2026, 3, 12, 12, 0, 0); + final snapshot = LiveTrafficSummary.fromLogs( + List.generate( + 24, + (index) => _log( + timestamp: now.subtract(Duration(seconds: index * 10)), + direction: PacketDirection.rx, + rawData: [0x88, 0x00, 0x00], + responseCode: 0x88, + ), + ), + now: now, + window: const Duration(minutes: 5), + ); + + expect(snapshot.totalCount, 24); + expect(snapshot.windowDuration, const Duration(minutes: 5)); + expect(snapshot.packetsPerMinute, 5); + }); + test('supports clearing the live view without mutating source logs', () { final now = DateTime(2026, 3, 12, 12, 0, 0); final clearAt = now.subtract(const Duration(seconds: 8)); diff --git a/test/services/message_storage_service_test.dart b/test/services/message_storage_service_test.dart index dc28f90..d3e4e9b 100644 --- a/test/services/message_storage_service_test.dart +++ b/test/services/message_storage_service_test.dart @@ -98,4 +98,14 @@ void main() { DateTime.fromMillisecondsSinceEpoch(1700000100400), ); }); + + test('persists removed SAR marker IDs', () async { + final storage = MessageStorageService(); + + await storage.saveRemovedSarMarkerIds({'sar-2', 'sar-1'}); + + final restored = await storage.loadRemovedSarMarkerIds(); + + expect(restored, equals({'sar-1', 'sar-2'})); + }); } diff --git a/test/utils/contact_grouping_test.dart b/test/utils/contact_grouping_test.dart index a18f885..628a19f 100644 --- a/test/utils/contact_grouping_test.dart +++ b/test/utils/contact_grouping_test.dart @@ -26,12 +26,12 @@ void main() { ); } - group('ContactGrouping.buildItems', () { + group('ContactGrouping.inferGroups', () { test( 'groups prefixed contacts only when at least four share the prefix', () { final now = DateTime(2026, 3, 10, 12); - final items = ContactGrouping.buildItems([ + final groups = ContactGrouping.inferGroups([ buildContact(seed: 1, name: 'SI-1', lastSeen: now), buildContact( seed: 2, @@ -55,20 +55,20 @@ void main() { ), ]); - expect(items, hasLength(2)); - expect(items.first.isGroup, isTrue); - expect(items.first.group!.label, 'SI-'); - expect( - items.first.group!.contacts.map((contact) => contact.displayName), - ['SI-1', 'SI-2', 'SI-3', 'SI-4'], - ); - expect(items.last.contact!.displayName, 'OTHER'); + expect(groups, hasLength(1)); + expect(groups.first.label, 'SI-'); + expect(groups.first.contacts.map((contact) => contact.displayName), [ + 'SI-1', + 'SI-2', + 'SI-3', + 'SI-4', + ]); }, ); test('does not group when only three contacts share a prefix', () { final now = DateTime(2026, 3, 10, 12); - final items = ContactGrouping.buildItems([ + final groups = ContactGrouping.inferGroups([ buildContact(seed: 1, name: 'SI-1', lastSeen: now), buildContact( seed: 2, @@ -82,18 +82,12 @@ void main() { ), ]); - expect(items, hasLength(3)); - expect(items.every((item) => !item.isGroup), isTrue); + expect(groups, isEmpty); }); - test('orders groups and ungrouped contacts by latest last seen', () { + test('orders groups by latest last seen', () { final now = DateTime(2026, 3, 10, 12); - final items = ContactGrouping.buildItems([ - buildContact( - seed: 1, - name: 'Lone', - lastSeen: now.subtract(const Duration(minutes: 1)), - ), + final groups = ContactGrouping.inferGroups([ buildContact(seed: 2, name: 'SI-1', lastSeen: now), buildContact( seed: 3, @@ -110,11 +104,261 @@ void main() { name: 'SI-4', lastSeen: now.subtract(const Duration(minutes: 4)), ), + buildContact( + seed: 6, + name: 'HR-1', + lastSeen: now.subtract(const Duration(minutes: 1)), + ), + buildContact( + seed: 7, + name: 'HR-2', + lastSeen: now.subtract(const Duration(minutes: 5)), + ), + buildContact( + seed: 8, + name: 'HR-3', + lastSeen: now.subtract(const Duration(minutes: 6)), + ), + buildContact( + seed: 9, + name: 'HR-4', + lastSeen: now.subtract(const Duration(minutes: 7)), + ), ]); - expect(items, hasLength(2)); - expect(items.first.isGroup, isTrue); - expect(items.last.contact!.displayName, 'Lone'); + expect(groups, hasLength(2)); + expect(groups.first.label, 'SI-'); + expect(groups.last.label, 'HR-'); + }); + + test('collapses extra auto-groups into a shared parent prefix', () { + final now = DateTime(2026, 3, 10, 12); + final groups = ContactGrouping.inferGroups( + [ + buildContact(seed: 1, name: 'AL-1', lastSeen: now), + buildContact( + seed: 2, + name: 'AL-2', + lastSeen: now.subtract(const Duration(minutes: 1)), + ), + buildContact( + seed: 3, + name: 'AL-3', + lastSeen: now.subtract(const Duration(minutes: 2)), + ), + buildContact( + seed: 4, + name: 'AL-4', + lastSeen: now.subtract(const Duration(minutes: 3)), + ), + buildContact( + seed: 5, + name: 'BR-1', + lastSeen: now.subtract(const Duration(minutes: 4)), + ), + buildContact( + seed: 6, + name: 'BR-2', + lastSeen: now.subtract(const Duration(minutes: 5)), + ), + buildContact( + seed: 7, + name: 'BR-3', + lastSeen: now.subtract(const Duration(minutes: 6)), + ), + buildContact( + seed: 8, + name: 'BR-4', + lastSeen: now.subtract(const Duration(minutes: 7)), + ), + buildContact( + seed: 9, + name: 'HU-PE-1', + lastSeen: now.subtract(const Duration(minutes: 8)), + ), + buildContact( + seed: 10, + name: 'HU-PE-2', + lastSeen: now.subtract(const Duration(minutes: 9)), + ), + buildContact( + seed: 11, + name: 'HU-GA-1', + lastSeen: now.subtract(const Duration(minutes: 10)), + ), + buildContact( + seed: 12, + name: 'HU-GA-2', + lastSeen: now.subtract(const Duration(minutes: 11)), + ), + buildContact( + seed: 13, + name: 'HU-PE-3', + lastSeen: now.subtract(const Duration(minutes: 12)), + ), + buildContact( + seed: 14, + name: 'HU-PE-4', + lastSeen: now.subtract(const Duration(minutes: 13)), + ), + buildContact( + seed: 15, + name: 'HU-GA-3', + lastSeen: now.subtract(const Duration(minutes: 14)), + ), + buildContact( + seed: 16, + name: 'HU-GA-4', + lastSeen: now.subtract(const Duration(minutes: 15)), + ), + ], + maxNamedGroups: 2, + overflowGroupLabel: 'Others', + ); + + expect(groups, hasLength(3)); + expect(groups[0].label, 'AL-'); + expect(groups[1].label, 'BR-'); + expect(groups[2].label, 'HU-'); + expect(groups[2].contacts.map((contact) => contact.displayName), [ + 'HU-PE-1', + 'HU-PE-2', + 'HU-GA-1', + 'HU-GA-2', + 'HU-PE-3', + 'HU-PE-4', + 'HU-GA-3', + 'HU-GA-4', + ]); + expect(groups[2].matchPrefixes, ['HU-']); + }); + + test( + 'does not create an Others bucket when overflow has no shared parent', + () { + final now = DateTime(2026, 3, 10, 12); + final groups = ContactGrouping.inferGroups( + [ + buildContact(seed: 1, name: 'AL-1', lastSeen: now), + buildContact( + seed: 2, + name: 'AL-2', + lastSeen: now.subtract(const Duration(minutes: 1)), + ), + buildContact( + seed: 3, + name: 'AL-3', + lastSeen: now.subtract(const Duration(minutes: 2)), + ), + buildContact( + seed: 4, + name: 'AL-4', + lastSeen: now.subtract(const Duration(minutes: 3)), + ), + buildContact( + seed: 5, + name: 'BR-1', + lastSeen: now.subtract(const Duration(minutes: 4)), + ), + buildContact( + seed: 6, + name: 'BR-2', + lastSeen: now.subtract(const Duration(minutes: 5)), + ), + buildContact( + seed: 7, + name: 'BR-3', + lastSeen: now.subtract(const Duration(minutes: 6)), + ), + buildContact( + seed: 8, + name: 'BR-4', + lastSeen: now.subtract(const Duration(minutes: 7)), + ), + buildContact( + seed: 9, + name: 'CR-1', + lastSeen: now.subtract(const Duration(minutes: 8)), + ), + buildContact( + seed: 10, + name: 'CR-2', + lastSeen: now.subtract(const Duration(minutes: 9)), + ), + buildContact( + seed: 11, + name: 'CR-3', + lastSeen: now.subtract(const Duration(minutes: 10)), + ), + buildContact( + seed: 12, + name: 'CR-4', + lastSeen: now.subtract(const Duration(minutes: 11)), + ), + ], + maxNamedGroups: 2, + overflowGroupLabel: 'Others', + ); + + expect(groups.map((group) => group.label), ['AL-', 'BR-', 'CR-']); + }, + ); + }); + + group('ContactGrouping.contactMatchesInferredGroupLabel', () { + test('matches the inferred auto-group label for prefixed contacts', () { + final contact = buildContact( + seed: 99, + name: 'SI-1', + lastSeen: DateTime(2026, 3, 10, 12), + ); + + expect( + ContactGrouping.contactMatchesInferredGroupLabel(contact, 'SI-'), + isTrue, + ); + expect( + ContactGrouping.contactMatchesInferredGroupLabel(contact, 'si'), + isTrue, + ); + }); + + test('returns false when a contact has no inferred auto-group label', () { + final contact = buildContact( + seed: 100, + name: 'Lone Contact', + lastSeen: DateTime(2026, 3, 10, 12), + ); + + expect( + ContactGrouping.contactMatchesInferredGroupLabel(contact, 'SI'), + isFalse, + ); + }); + + test('finds a shared inferred label for related repeater names', () { + final contacts = [ + buildContact( + seed: 101, + name: 'HU-PE', + lastSeen: DateTime(2026, 3, 10, 12), + ), + buildContact( + seed: 102, + name: 'HU-GA', + lastSeen: DateTime(2026, 3, 10, 11, 59), + ), + ]; + + expect(ContactGrouping.sharedInferredGroupLabel(contacts), 'HU-'); + }); + + test('finds a shared parent label for related subgroup prefixes', () { + expect( + ContactGrouping.sharedParentGroupLabel(['HU-PE-', 'HU-GA-']), + 'HU-', + ); + expect(ContactGrouping.sharedParentGroupLabel(['AL-', 'BR-']), isNull); }); }); } diff --git a/test/widgets/contact_tile_test.dart b/test/widgets/contact_tile_test.dart index 4f5d3f3..45897de 100644 --- a/test/widgets/contact_tile_test.dart +++ b/test/widgets/contact_tile_test.dart @@ -82,4 +82,17 @@ void main() { expect(find.text('Trace'), findsNothing); }); + + testWidgets('shows overridden contact name as primary label', (tester) async { + await pumpTile( + tester, + buildContact( + name: 'John Smith', + type: ContactType.chat, + ).copyWith(nameOverride: 'Rescue One'), + ); + + expect(find.text('Rescue One'), findsOneWidget); + expect(find.text('John Smith'), findsNothing); + }); } diff --git a/test/widgets/recipient_selector_sheet_test.dart b/test/widgets/recipient_selector_sheet_test.dart index 288e02c..4b9abfb 100644 --- a/test/widgets/recipient_selector_sheet_test.dart +++ b/test/widgets/recipient_selector_sheet_test.dart @@ -71,4 +71,31 @@ void main() { expect(find.text('7'), findsOneWidget); expect(find.text('3'), findsOneWidget); }); + + testWidgets('can hide show all option for contact-only flows', ( + tester, + ) async { + final contact = buildContact(name: 'John Smith', type: ContactType.chat); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: RecipientSelectorSheet( + contacts: [contact], + rooms: const [], + channels: const [], + unreadCount: 0, + unreadCountsByPublicKey: const {}, + showAllOption: false, + onSelect: (_, __) {}, + ), + ), + ), + ); + + expect(find.text('Show all'), findsNothing); + expect(find.text('John Smith'), findsOneWidget); + }); }