From 53daf2c9a763128253588b8f7830923db19a715a Mon Sep 17 00:00:00 2001 From: Janez T Date: Thu, 12 Mar 2026 20:54:04 +0100 Subject: [PATCH] Add packet filter stats and hex --- lib/models/contact_group.dart | 31 + lib/models/path_history.dart | 15 + lib/providers/contacts_provider.dart | 101 +- lib/screens/contacts_tab.dart | 288 ++++- lib/screens/home_screen.dart | 41 + lib/screens/live_traffic_screen.dart | 1013 +++++++++++++++++ lib/services/contact_storage_service.dart | 63 + lib/services/live_traffic_summary.dart | 246 ++++ lib/services/path_history_service.dart | 3 + lib/utils/log_rx_route_decoder.dart | 11 + .../contacts/contact_route_dialog.dart | 122 +- test/providers/contacts_provider_test.dart | 35 + test/screens/live_traffic_screen_test.dart | 179 +++ test/services/live_traffic_summary_test.dart | 147 +++ test/services/path_history_service_test.dart | 26 + 15 files changed, 2265 insertions(+), 56 deletions(-) create mode 100644 lib/models/contact_group.dart create mode 100644 lib/screens/live_traffic_screen.dart create mode 100644 lib/services/live_traffic_summary.dart create mode 100644 test/screens/live_traffic_screen_test.dart create mode 100644 test/services/live_traffic_summary_test.dart diff --git a/lib/models/contact_group.dart b/lib/models/contact_group.dart new file mode 100644 index 0000000..46621ad --- /dev/null +++ b/lib/models/contact_group.dart @@ -0,0 +1,31 @@ +class SavedContactGroup { + final String id; + final String sectionKey; + final String label; + final String query; + final DateTime createdAt; + + const SavedContactGroup({ + required this.id, + required this.sectionKey, + required this.label, + required this.query, + required this.createdAt, + }); + + SavedContactGroup copyWith({ + String? id, + String? sectionKey, + String? label, + String? query, + DateTime? createdAt, + }) { + return SavedContactGroup( + id: id ?? this.id, + sectionKey: sectionKey ?? this.sectionKey, + label: label ?? this.label, + query: query ?? this.query, + createdAt: createdAt ?? this.createdAt, + ); + } +} diff --git a/lib/models/path_history.dart b/lib/models/path_history.dart index 83ffbb7..64661b9 100644 --- a/lib/models/path_history.dart +++ b/lib/models/path_history.dart @@ -1,7 +1,10 @@ +enum PathRecordSource { learned, observed } + class PathRecord { final List pathBytes; final int hopCount; final int hashSize; + final PathRecordSource source; final int successCount; final int failureCount; final int lastRoundTripTimeMs; @@ -11,6 +14,7 @@ class PathRecord { required this.pathBytes, required this.hopCount, required this.hashSize, + required this.source, required this.successCount, required this.failureCount, required this.lastRoundTripTimeMs, @@ -27,6 +31,7 @@ class PathRecord { List? pathBytes, int? hopCount, int? hashSize, + PathRecordSource? source, int? successCount, int? failureCount, int? lastRoundTripTimeMs, @@ -36,6 +41,7 @@ class PathRecord { pathBytes: pathBytes ?? this.pathBytes, hopCount: hopCount ?? this.hopCount, hashSize: hashSize ?? this.hashSize, + source: source ?? this.source, successCount: successCount ?? this.successCount, failureCount: failureCount ?? this.failureCount, lastRoundTripTimeMs: lastRoundTripTimeMs ?? this.lastRoundTripTimeMs, @@ -48,6 +54,7 @@ class PathRecord { 'path_bytes': pathBytes, 'hop_count': hopCount, 'hash_size': hashSize, + 'source': source.name, 'success_count': successCount, 'failure_count': failureCount, 'last_round_trip_time_ms': lastRoundTripTimeMs, @@ -62,6 +69,10 @@ class PathRecord { .toList(), hopCount: json['hop_count'] as int? ?? 0, hashSize: json['hash_size'] as int? ?? 1, + source: PathRecordSource.values.firstWhere( + (value) => value.name == (json['source'] as String? ?? 'learned'), + orElse: () => PathRecordSource.learned, + ), successCount: json['success_count'] as int? ?? 0, failureCount: json['failure_count'] as int? ?? 0, lastRoundTripTimeMs: json['last_round_trip_time_ms'] as int? ?? 0, @@ -163,6 +174,10 @@ class ContactPathHistory { }; } + List get observedPaths => directPaths + .where((record) => record.source == PathRecordSource.observed) + .toList(); + factory ContactPathHistory.fromJson( String contactPublicKeyHex, Map json, diff --git a/lib/providers/contacts_provider.dart b/lib/providers/contacts_provider.dart index d2c62fb..3c6bdf7 100644 --- a/lib/providers/contacts_provider.dart +++ b/lib/providers/contacts_provider.dart @@ -2,6 +2,7 @@ import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:latlong2/latlong.dart'; import '../models/contact.dart'; +import '../models/contact_group.dart'; import '../models/message_contact_location.dart'; import '../services/cayenne_lpp_parser.dart'; import '../services/contact_storage_service.dart'; @@ -58,6 +59,7 @@ class _RetainedRoute { class ContactsProvider with ChangeNotifier { static const double _firstHopFallbackOffsetMeters = 100.0; final Map _contacts = {}; + final List _savedContactGroups = []; final Map _pendingAdverts = {}; final ContactStorageService _storageService = ContactStorageService(); bool _isInitialized = false; @@ -79,6 +81,7 @@ class ContactsProvider with ChangeNotifier { '📦 [ContactsProvider] Early loading persisted contacts (no filtering)...', ); final storedContacts = await _storageService.loadContacts(); + final storedGroups = await _storageService.loadContactGroups(); // Add stored contacts (excluding any with all-zeros public key) const publicChannelKey = @@ -92,8 +95,11 @@ class ContactsProvider with ChangeNotifier { } _isInitialized = true; + _savedContactGroups + ..clear() + ..addAll(storedGroups); debugPrint( - '✅ [ContactsProvider] Early loaded ${storedContacts.length} persisted contacts', + '✅ [ContactsProvider] Early loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups', ); // Ensure public channel exists after loading @@ -123,6 +129,7 @@ class ContactsProvider with ChangeNotifier { final storedContacts = await _storageService.loadContacts( excludePublicKey: devicePublicKey, ); + final storedGroups = await _storageService.loadContactGroups(); // Add stored contacts (excluding any with all-zeros public key) const publicChannelKey = @@ -136,8 +143,11 @@ class ContactsProvider with ChangeNotifier { } _isInitialized = true; + _savedContactGroups + ..clear() + ..addAll(storedGroups); debugPrint( - '✅ [ContactsProvider] Loaded ${storedContacts.length} persisted contacts', + '✅ [ContactsProvider] Loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups', ); // Ensure public channel exists after loading @@ -208,10 +218,97 @@ class ContactsProvider with ChangeNotifier { } List get contacts => _contacts.values.toList(); + List get savedContactGroups => + List.from(_savedContactGroups) + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); List get pendingAdverts => _pendingAdverts.values.toList() ..sort((a, b) => b.receivedAt.compareTo(a.receivedAt)); + List savedGroupsForSection(String sectionKey) { + return savedContactGroups + .where((group) => group.sectionKey == sectionKey) + .toList(); + } + + bool hasSavedGroupForFilter(String sectionKey, String query) { + final normalizedQuery = _normalizeGroupQuery(query); + if (normalizedQuery.isEmpty) { + return false; + } + + return _savedContactGroups.any( + (group) => + group.sectionKey == sectionKey && + _normalizeGroupQuery(group.query) == normalizedQuery, + ); + } + + Future addSavedGroupForFilter( + String sectionKey, + String query, { + String? label, + }) async { + final normalizedQuery = _normalizeGroupQuery(query); + if (normalizedQuery.isEmpty || + hasSavedGroupForFilter(sectionKey, normalizedQuery)) { + return; + } + + _savedContactGroups.add( + SavedContactGroup( + id: '${sectionKey}_${DateTime.now().microsecondsSinceEpoch}', + sectionKey: sectionKey, + label: (label ?? query).trim(), + query: query.trim(), + createdAt: DateTime.now(), + ), + ); + + await _persistSavedGroups(); + notifyListeners(); + } + + Future removeSavedGroupById(String id) async { + final beforeCount = _savedContactGroups.length; + _savedContactGroups.removeWhere((group) => group.id == id); + if (_savedContactGroups.length == beforeCount) { + return; + } + + await _persistSavedGroups(); + notifyListeners(); + } + + Future removeSavedGroupForFilter( + String sectionKey, + String query, + ) async { + final normalizedQuery = _normalizeGroupQuery(query); + final beforeCount = _savedContactGroups.length; + _savedContactGroups.removeWhere( + (group) => + group.sectionKey == sectionKey && + _normalizeGroupQuery(group.query) == normalizedQuery, + ); + if (_savedContactGroups.length == beforeCount) { + return; + } + + await _persistSavedGroups(); + notifyListeners(); + } + + Future _persistSavedGroups() async { + try { + await _storageService.saveContactGroups(_savedContactGroups); + } catch (e) { + debugPrint('❌ [ContactsProvider] Error persisting contact groups: $e'); + } + } + + String _normalizeGroupQuery(String query) => query.trim().toLowerCase(); + List get chatContacts => contacts.where((c) => c.isChat).toList()..sort(_sortByLastSeen); diff --git a/lib/screens/contacts_tab.dart b/lib/screens/contacts_tab.dart index cfa123b..5cbc590 100644 --- a/lib/screens/contacts_tab.dart +++ b/lib/screens/contacts_tab.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import 'package:geolocator/geolocator.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'; @@ -203,6 +204,80 @@ class _ContactsTabState extends State { }).toList(); } + bool _contactMatchesFilter(Contact contact, String query) { + final normalizedQuery = query.trim().toLowerCase(); + if (normalizedQuery.isEmpty) { + return true; + } + + final name = contact.displayName.toLowerCase(); + final advertisedName = contact.advName.toLowerCase(); + return name.contains(normalizedQuery) || + advertisedName.contains(normalizedQuery); + } + + List<_RenderedSavedGroup> _buildSavedGroupsForSection( + ContactsProvider contactsProvider, + List contacts, + ContactSection section, + ) { + return contactsProvider + .savedGroupsForSection(section.name) + .map((group) { + final matches = contacts + .where((contact) => _contactMatchesFilter(contact, group.query)) + .toList(); + return _RenderedSavedGroup(group: group, contacts: matches); + }) + .where((group) => group.contacts.isNotEmpty) + .toList() + ..sort( + (a, b) => b.contacts.first.lastSeenTime.compareTo( + a.contacts.first.lastSeenTime, + ), + ); + } + + Future _toggleSavedGroupForSection( + BuildContext context, + ContactsProvider contactsProvider, + ContactSection section, + ) async { + final filter = (_sectionFilters[section] ?? '').trim(); + if (filter.isEmpty) { + return; + } + + final alreadySaved = contactsProvider.hasSavedGroupForFilter( + section.name, + filter, + ); + + if (alreadySaved) { + await contactsProvider.removeSavedGroupForFilter(section.name, filter); + } else { + await contactsProvider.addSavedGroupForFilter( + section.name, + filter, + label: filter, + ); + } + + if (!context.mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + alreadySaved + ? 'Removed saved group "$filter"' + : 'Saved group "$filter"', + ), + ), + ); + } + List _sortContacts(List contacts, ContactSection section) { final sorted = List.from(contacts); if (section == ContactSection.channels) { @@ -319,18 +394,38 @@ class _ContactsTabState extends State { allChatContacts, ContactSection.teamMembers, ); + final savedTeamGroups = _buildSavedGroupsForSection( + contactsProvider, + allChatContacts, + ContactSection.teamMembers, + ); final repeaters = _filterContactsForSection( allRepeaters, ContactSection.repeaters, ); + final savedRepeaterGroups = _buildSavedGroupsForSection( + contactsProvider, + allRepeaters, + ContactSection.repeaters, + ); final rooms = _filterContactsForSection( allRooms, ContactSection.rooms, ); + final savedRoomGroups = _buildSavedGroupsForSection( + contactsProvider, + allRooms, + ContactSection.rooms, + ); final filteredChannels = _filterContactsForSection( allChannels, ContactSection.channels, ); + final savedChannelGroups = _buildSavedGroupsForSection( + contactsProvider, + allChannels, + ContactSection.channels, + ); final pendingAdverts = contactsProvider.pendingAdverts; _schedulePendingAdvertResolution(pendingAdverts, connectionProvider); @@ -385,11 +480,22 @@ class _ContactsTabState extends State { ContactSection.teamMembers, ), ), - _buildSectionFilterField(context, ContactSection.teamMembers), + _buildSectionFilterField( + context, + ContactSection.teamMembers, + contactsProvider, + ), if (chatContacts.isEmpty) _buildEmptyFilterState(context) - else - ..._buildContactSectionItems(chatContacts), + else ...[ + ..._buildSavedGroupCards( + savedTeamGroups, + ContactSection.teamMembers, + ), + ..._buildContactSectionItems( + _excludeGroupedContacts(chatContacts, savedTeamGroups), + ), + ], const Divider(height: 32), ], @@ -401,11 +507,22 @@ class _ContactsTabState extends State { icon: Icons.router, trailing: _buildSortMenu(context, ContactSection.repeaters), ), - _buildSectionFilterField(context, ContactSection.repeaters), + _buildSectionFilterField( + context, + ContactSection.repeaters, + contactsProvider, + ), if (repeaters.isEmpty) _buildEmptyFilterState(context) - else - ..._buildContactSectionItems(repeaters), + else ...[ + ..._buildSavedGroupCards( + savedRepeaterGroups, + ContactSection.repeaters, + ), + ..._buildContactSectionItems( + _excludeGroupedContacts(repeaters, savedRepeaterGroups), + ), + ], const Divider(height: 32), ], @@ -417,11 +534,22 @@ class _ContactsTabState extends State { icon: Icons.tag, trailing: _buildSortMenu(context, ContactSection.rooms), ), - _buildSectionFilterField(context, ContactSection.rooms), + _buildSectionFilterField( + context, + ContactSection.rooms, + contactsProvider, + ), if (rooms.isEmpty) _buildEmptyFilterState(context) - else - ..._buildContactSectionItems(rooms), + else ...[ + ..._buildSavedGroupCards( + savedRoomGroups, + ContactSection.rooms, + ), + ..._buildContactSectionItems( + _excludeGroupedContacts(rooms, savedRoomGroups), + ), + ], const Divider(height: 32), ], @@ -452,11 +580,22 @@ class _ContactsTabState extends State { count: filteredChannels.length, icon: Icons.broadcast_on_personal, ), - _buildSectionFilterField(context, ContactSection.channels), + _buildSectionFilterField( + context, + ContactSection.channels, + contactsProvider, + ), if (allChannels.isNotEmpty && filteredChannels.isEmpty) _buildEmptyFilterState(context), + ..._buildSavedGroupCards( + savedChannelGroups, + ContactSection.channels, + ), if (filteredChannels.isNotEmpty) ...[ - ...filteredChannels.map( + ..._excludeGroupedContacts( + filteredChannels, + savedChannelGroups, + ).map( (channel) => _ChannelActivityCard( channel: channel, messagesProvider: messagesProvider, @@ -520,14 +659,58 @@ class _ContactsTabState extends State { }).toList(); } + List _excludeGroupedContacts( + List contacts, + List<_RenderedSavedGroup> savedGroups, + ) { + final groupedKeys = savedGroups + .expand( + (group) => group.contacts.map((contact) => contact.publicKeyHex), + ) + .toSet(); + return contacts + .where((contact) => !groupedKeys.contains(contact.publicKeyHex)) + .toList(); + } + + List _buildSavedGroupCards( + List<_RenderedSavedGroup> groups, + ContactSection section, + ) { + return groups + .map( + (group) => _InferredContactGroupCard( + label: group.group.label, + contacts: group.contacts, + currentPosition: _currentPosition, + calculateDistance: _calculateDistanceInMeters, + formatDistance: _formatDistance, + onNavigateToMap: widget.onNavigateToMap, + onNavigateToMessages: widget.onNavigateToMessages, + onDelete: () => context + .read() + .removeSavedGroupById(group.group.id), + kindLabel: 'Saved filter', + ), + ) + .toList(); + } + Widget _buildSectionFilterField( BuildContext context, ContactSection section, + ContactsProvider contactsProvider, ) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; final controller = _filterControllers[section]!; final hasFilter = (_sectionFilters[section] ?? '').isNotEmpty; + final isSavedFilter = hasFilter + ? contactsProvider.hasSavedGroupForFilter( + section.name, + _sectionFilters[section] ?? '', + ) + : false; return Padding( padding: const EdgeInsets.only(bottom: 8), @@ -600,7 +783,38 @@ class _ContactsTabState extends State { ), ), ), - if (hasFilter) + if (hasFilter) ...[ + Padding( + padding: const EdgeInsets.only(right: 4), + child: Material( + color: + (isSavedFilter + ? colorScheme.error + : colorScheme.primary) + .withValues(alpha: 0.10), + shape: const CircleBorder(), + child: InkWell( + customBorder: const CircleBorder(), + onTap: () => _toggleSavedGroupForSection( + context, + contactsProvider, + section, + ), + child: Padding( + padding: const EdgeInsets.all(6), + child: Icon( + isSavedFilter + ? Icons.delete_outline_rounded + : Icons.bookmark_add_outlined, + size: 16, + color: isSavedFilter + ? colorScheme.error + : colorScheme.primary, + ), + ), + ), + ), + ), Padding( padding: const EdgeInsets.only(right: 6), child: Material( @@ -624,8 +838,8 @@ class _ContactsTabState extends State { ), ), ), - ) - else + ), + ] else const SizedBox(width: 12), ], ), @@ -751,6 +965,13 @@ class _PendingAdvertTile extends StatelessWidget { } } +class _RenderedSavedGroup { + final SavedContactGroup group; + final List contacts; + + const _RenderedSavedGroup({required this.group, required this.contacts}); +} + class _SectionHeader extends StatelessWidget { final String title; final int count; @@ -800,20 +1021,24 @@ class _SectionHeader extends StatelessWidget { class _InferredContactGroupCard extends StatelessWidget { final String label; final List contacts; + final String? kindLabel; final Position? currentPosition; final double Function(double, double, double, double) calculateDistance; final String Function(double) formatDistance; final VoidCallback? onNavigateToMap; final VoidCallback? onNavigateToMessages; + final VoidCallback? onDelete; const _InferredContactGroupCard({ required this.label, required this.contacts, + this.kindLabel, required this.currentPosition, required this.calculateDistance, required this.formatDistance, required this.onNavigateToMap, required this.onNavigateToMessages, + this.onDelete, }); @override @@ -843,11 +1068,24 @@ class _InferredContactGroupCard extends StatelessWidget { title: Row( children: [ Expanded( - child: Text( - label, - style: Theme.of( - context, - ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w800), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + if (kindLabel case final value?) + Text( + value, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ], ), ), const SizedBox(width: 8), @@ -862,6 +1100,18 @@ class _InferredContactGroupCard extends StatelessWidget { style: Theme.of(context).textTheme.labelSmall, ), ), + if (onDelete != null) ...[ + const SizedBox(width: 4), + IconButton( + tooltip: 'Delete group', + onPressed: onDelete, + icon: Icon( + Icons.delete_outline_rounded, + size: 18, + color: colorScheme.error, + ), + ), + ], ], ), children: [ diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index b92a982..48e9f7f 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -18,6 +18,7 @@ import 'repeaters_map_screen.dart'; import 'settings_screen.dart'; import 'device_config_screen.dart'; import 'packet_log_screen.dart'; +import 'live_traffic_screen.dart'; import 'spectrum_scan_screen.dart'; import '../utils/toast_logger.dart'; import '../l10n/app_localizations.dart'; @@ -218,6 +219,10 @@ class _HomeScreenState extends State ); } + void _openLiveTraffic(ConnectionProvider provider) { + openLiveTrafficScreen(context, provider); + } + @override void didChangeAppLifecycleState(AppLifecycleState state) { _lifecycleState = state; @@ -647,6 +652,41 @@ class _HomeScreenState extends State ); } + items.add( + PopupMenuItem( + child: const Row( + children: [ + Icon(Icons.radar_outlined), + SizedBox(width: 8), + Text('Live Traffic'), + ], + ), + onTap: () { + final navigator = Navigator.of(context); + final provider = context.read(); + Future.delayed(Duration.zero, () { + if (!mounted) return; + navigator.push( + MaterialPageRoute( + builder: (_) => LiveTrafficScreen.fromProvider( + provider, + openPacketLogs: () { + navigator.push( + MaterialPageRoute( + builder: (_) => PacketLogScreen( + bleService: provider.bleService, + ), + ), + ); + }, + ), + ), + ); + }); + }, + ), + ); + items.add( PopupMenuItem( child: const Row( @@ -1056,6 +1096,7 @@ class _HomeScreenState extends State SizedBox(width: isTight ? 8 : 12), if (_showRxTxIndicators) GestureDetector( + onTap: () => _openLiveTraffic(provider), onLongPress: () { Navigator.push( context, diff --git a/lib/screens/live_traffic_screen.dart b/lib/screens/live_traffic_screen.dart new file mode 100644 index 0000000..c7503ec --- /dev/null +++ b/lib/screens/live_traffic_screen.dart @@ -0,0 +1,1013 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:provider/provider.dart'; + +import '../models/ble_packet_log.dart'; +import '../models/contact.dart'; +import '../providers/contacts_provider.dart'; +import '../providers/connection_provider.dart'; +import '../services/live_traffic_summary.dart'; +import '../services/location_tracking_service.dart'; +import '../services/route_hash_preferences.dart'; +import '../utils/log_rx_route_decoder.dart'; +import 'packet_log_screen.dart'; + +T? _maybeProvider(BuildContext context) { + try { + return Provider.of(context, listen: false); + } catch (_) { + return null; + } +} + +class LiveTrafficScreen extends StatefulWidget { + final List Function() logReader; + final int Function()? rxCountReader; + final Listenable? refreshListenable; + final DateTime Function() now; + final VoidCallback? openPacketLogs; + + const LiveTrafficScreen({ + super.key, + required this.logReader, + this.rxCountReader, + this.refreshListenable, + DateTime Function()? now, + this.openPacketLogs, + }) : now = now ?? DateTime.now; + + factory LiveTrafficScreen.fromProvider( + ConnectionProvider provider, { + Key? key, + VoidCallback? openPacketLogs, + }) { + return LiveTrafficScreen( + key: key, + logReader: () => provider.bleService.packetLogs, + rxCountReader: () => provider.rxPacketCount, + refreshListenable: provider, + openPacketLogs: openPacketLogs, + ); + } + + @override + State createState() => _LiveTrafficScreenState(); +} + +class _LiveTrafficScreenState extends State { + static const List _windowOptions = [ + Duration(minutes: 1), + Duration(minutes: 5), + Duration(minutes: 10), + Duration(minutes: 15), + Duration(minutes: 30), + Duration(minutes: 60), + ]; + + Timer? _ticker; + DateTime? _clearedAt; + int _preferredHashSize = RouteHashPreferences.defaultHashSize; + Duration _selectedWindow = const Duration(minutes: 1); + String? _selectedPacketType; + + @override + void initState() { + super.initState(); + _loadPreferredHashSize(); + widget.refreshListenable?.addListener(_handleRefresh); + _ticker = Timer.periodic(const Duration(seconds: 1), (_) { + if (mounted) { + setState(() {}); + } + }); + } + + @override + void didUpdateWidget(covariant LiveTrafficScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.refreshListenable != widget.refreshListenable) { + oldWidget.refreshListenable?.removeListener(_handleRefresh); + widget.refreshListenable?.addListener(_handleRefresh); + } + } + + @override + void dispose() { + widget.refreshListenable?.removeListener(_handleRefresh); + _ticker?.cancel(); + super.dispose(); + } + + void _handleRefresh() { + if (!mounted) return; + setState(() {}); + } + + Future _loadPreferredHashSize() async { + final hashSize = await RouteHashPreferences.getHashSize(); + if (!mounted) return; + setState(() { + _preferredHashSize = hashSize; + }); + } + + @override + Widget build(BuildContext context) { + final unfilteredSnapshot = LiveTrafficSummary.fromLogs( + widget.logReader(), + now: widget.now(), + clearedAt: _clearedAt, + preferredHashSize: _preferredHashSize, + window: _selectedWindow, + ); + final snapshot = LiveTrafficSummary.fromLogs( + widget.logReader(), + now: widget.now(), + clearedAt: _clearedAt, + preferredHashSize: _preferredHashSize, + window: _selectedWindow, + packetTypeFilter: _selectedPacketType, + ); + final theme = Theme.of(context); + final contactsProvider = _maybeProvider(context); + final routeHashCounts = _RouteHashCounts.fromContacts( + contactsProvider?.contacts ?? const [], + ); + final packetTypes = unfilteredSnapshot.visibleEntries + .map((entry) => entry.payloadLabel) + .toSet() + .toList() + ..sort(); + final filteredEntries = snapshot.visibleEntries; + + return Scaffold( + appBar: AppBar( + title: const Text('Live Traffic'), + actions: [ + if (widget.openPacketLogs != null) + IconButton( + onPressed: widget.openPacketLogs, + tooltip: 'Open packet logs', + icon: const Icon(Icons.list_alt_rounded), + ), + IconButton( + onPressed: () { + setState(() { + _clearedAt = widget.now(); + }); + }, + tooltip: 'Clear live view', + icon: const Icon(Icons.cleaning_services_outlined), + ), + ], + ), + body: CustomScrollView( + slivers: [ + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + sliver: SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SummaryPanel( + snapshot: snapshot, + totalRxCount: widget.rxCountReader?.call(), + routeHashCounts: routeHashCounts, + onWindowTap: () => _showWindowPicker(context), + ), + const SizedBox(height: 10), + _PacketTypeFilterBar( + packetTypes: packetTypes, + selectedType: _selectedPacketType, + onSelected: (value) { + setState(() { + _selectedPacketType = value; + }); + }, + ), + ], + ), + ), + ), + if (filteredEntries.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.radar_rounded, + size: 64, + color: theme.colorScheme.primary.withValues(alpha: 0.45), + ), + const SizedBox(height: 16), + const Text( + 'No packets for this filter', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + unfilteredSnapshot.visibleEntries.isEmpty + ? 'This view only shows in-memory traffic while it is active.' + : 'Try a different packet type or switch back to All.', + textAlign: TextAlign.center, + style: TextStyle(color: theme.colorScheme.onSurfaceVariant), + ), + ], + ), + ), + ), + ) + else + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + sliver: SliverList.separated( + itemBuilder: (context, index) { + final entry = filteredEntries[index]; + return _LiveTrafficCard(entry: entry, now: widget.now()); + }, + separatorBuilder: (_, _) => const SizedBox(height: 10), + itemCount: filteredEntries.length, + ), + ), + ], + ), + ); + } + + Future _showWindowPicker(BuildContext context) async { + final selected = await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (context) { + return SafeArea( + child: ListView( + shrinkWrap: true, + children: [ + for (final option in _windowOptions) + ListTile( + title: Text(_windowLabel(option)), + trailing: option == _selectedWindow + ? const Icon(Icons.check) + : null, + onTap: () => Navigator.of(context).pop(option), + ), + ], + ), + ); + }, + ); + + if (selected == null || !mounted) return; + setState(() { + _selectedWindow = selected; + }); + } +} + +class _SummaryPanel extends StatelessWidget { + final LiveTrafficSnapshot snapshot; + final int? totalRxCount; + final _RouteHashCounts routeHashCounts; + final VoidCallback onWindowTap; + + const _SummaryPanel({ + required this.snapshot, + required this.totalRxCount, + required this.routeHashCounts, + required this.onWindowTap, + }); + + @override + Widget build(BuildContext context) { + final busynessColor = switch (snapshot.busyness) { + LiveTrafficBusyness.quiet => Colors.blueGrey, + LiveTrafficBusyness.active => Colors.orange, + LiveTrafficBusyness.busy => Colors.redAccent, + }; + final busynessLabel = switch (snapshot.busyness) { + LiveTrafficBusyness.quiet => 'Quiet', + LiveTrafficBusyness.active => 'Active', + LiveTrafficBusyness.busy => 'Busy', + }; + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + busynessColor.withValues(alpha: 0.18), + Theme.of(context).colorScheme.surfaceContainerHigh, + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(20), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + _SummaryBadge( + label: 'Mesh', + value: busynessLabel, + color: busynessColor, + ), + _SummaryBadge( + label: 'Rate', + value: '${snapshot.packetsPerMinute} pkt/min', + color: Theme.of(context).colorScheme.primary, + ), + _SummaryBadge( + label: 'Window', + value: _windowLabel(snapshot.windowDuration), + color: Theme.of(context).colorScheme.secondary, + onTap: onWindowTap, + ), + ], + ), + const SizedBox(height: 14), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + _MetricTile( + label: 'RX packets', + value: '${snapshot.rxCount}', + subtitle: totalRxCount == null + ? 'Last 60 sec' + : 'Device total $totalRxCount', + ), + _MetricTile( + label: 'RSSI', + value: snapshot.latestRssiDbm == null + ? 'No RX data' + : '${snapshot.latestRssiDbm} dBm', + subtitle: snapshot.avgRssiDbm == null + ? 'No average yet' + : 'Avg ${snapshot.avgRssiDbm!.toStringAsFixed(1)} dBm', + ), + _MetricTile( + label: 'SNR', + value: snapshot.latestSnrDb == null + ? 'No RX data' + : '${snapshot.latestSnrDb!.toStringAsFixed(1)} dB', + subtitle: snapshot.avgSnrDb == null + ? 'No average yet' + : 'Avg ${snapshot.avgSnrDb!.toStringAsFixed(1)} dB', + ), + _MetricTile( + label: 'Multi-hop', + value: '${snapshot.multiHopCount}', + subtitle: routeHashCounts.summaryLabel, + footer: snapshot.avgHopCount == null + ? 'No routes yet' + : 'Avg ${snapshot.avgHopCount!.toStringAsFixed(1)} hops', + ), + ], + ), + ], + ), + ); + } +} + +class _SummaryBadge extends StatelessWidget { + final String label; + final String value; + final Color color; + final VoidCallback? onTap; + + const _SummaryBadge({ + required this.label, + required this.value, + required this.color, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(999), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 6), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: color.withValues(alpha: 0.35)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '$label ', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + Text( + value, + style: const TextStyle(fontWeight: FontWeight.w800, fontSize: 12), + ), + ], + ), + ), + ); + } +} + +class _PacketTypeFilterBar extends StatelessWidget { + final List packetTypes; + final String? selectedType; + final ValueChanged onSelected; + + const _PacketTypeFilterBar({ + required this.packetTypes, + required this.selectedType, + required this.onSelected, + }); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _FilterChip( + label: 'All', + selected: selectedType == null, + onTap: () => onSelected(null), + ), + for (final type in packetTypes) ...[ + const SizedBox(width: 8), + _FilterChip( + label: type, + selected: selectedType == type, + onTap: () => onSelected(type), + ), + ], + ], + ), + ); + } +} + +class _FilterChip extends StatelessWidget { + final String label; + final bool selected; + final VoidCallback onTap; + + const _FilterChip({ + required this.label, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final color = selected ? scheme.primary : scheme.outline; + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(999), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: color.withValues(alpha: selected ? 0.14 : 0.08), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: color.withValues(alpha: selected ? 0.45 : 0.22)), + ), + child: Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: color, + ), + ), + ), + ); + } +} + +String _windowLabel(Duration duration) { + if (duration.inMinutes >= 60) { + return '${duration.inMinutes} min'; + } + return '${duration.inMinutes} min'; +} + +class _MetricTile extends StatelessWidget { + final String label; + final String value; + final String subtitle; + final String? footer; + + const _MetricTile({ + required this.label, + required this.value, + required this.subtitle, + this.footer, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: 160, + height: 128, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.82), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 6), + Text( + value, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800), + ), + const SizedBox(height: 4), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + subtitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 11, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + if (footer != null) ...[ + const SizedBox(height: 2), + Text( + footer!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 11, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + ], + ), + ); + } +} + +class _RouteHashCounts { + final int oneByte; + final int twoByte; + final int threeByte; + + const _RouteHashCounts({ + required this.oneByte, + required this.twoByte, + required this.threeByte, + }); + + factory _RouteHashCounts.fromContacts(List contacts) { + var oneByte = 0; + var twoByte = 0; + var threeByte = 0; + + for (final contact in contacts) { + if (!contact.routeHasPath || contact.routeHopCount <= 0) continue; + switch (contact.routeHashSize) { + case 1: + oneByte += 1; + break; + case 2: + twoByte += 1; + break; + case 3: + threeByte += 1; + break; + } + } + + return _RouteHashCounts( + oneByte: oneByte, + twoByte: twoByte, + threeByte: threeByte, + ); + } + + String get summaryLabel => '1b:$oneByte 2b:$twoByte 3b:$threeByte'; +} + +class _LiveTrafficCard extends StatelessWidget { + final LiveTrafficEntry entry; + final DateTime now; + + const _LiveTrafficCard({required this.entry, required this.now}); + + @override + Widget build(BuildContext context) { + final log = entry.log; + final isRx = log.direction == PacketDirection.rx; + final accent = isRx ? Colors.green : Colors.blue; + final rxInfo = log.logRxDataInfo; + final routePreview = _resolvedRoutePreview(context, entry); + final originDistance = _originDistanceLabel(context, entry); + + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: accent.withValues(alpha: 0.25)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + isRx ? 'RX' : 'TX', + style: TextStyle( + color: accent, + fontWeight: FontWeight.w800, + fontSize: 12, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + entry.payloadLabel, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + if (entry.payloadMeaning != null) + Text( + entry.payloadMeaning!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 11, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Text( + _timeAgo(log.timestamp, now), + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _PacketMetaChip( + label: '${log.rawData.length} bytes', + onTap: () => _showPacketBytesSheet(context, log.rawData), + ), + if (rxInfo?.rssiDbm != null) + _PacketMetaChip(label: 'RSSI ${rxInfo!.rssiDbm} dBm'), + if (rxInfo?.snrDb != null) + _PacketMetaChip( + label: 'SNR ${rxInfo!.snrDb!.toStringAsFixed(1)} dB', + ), + if (entry.hopCount != null) + _PacketMetaChip(label: '${entry.hopCount} hops'), + if (entry.isMultiHop) + const _PacketMetaChip(label: 'MULTI-HOP', emphasized: true), + if (originDistance != null) + _PacketMetaChip(label: 'Origin $originDistance'), + ], + ), + const SizedBox(height: 10), + Text( + routePreview, + style: TextStyle( + fontSize: 12, + fontFamily: 'monospace', + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } + + static String _timeAgo(DateTime timestamp, DateTime now) { + final diff = now.difference(timestamp); + if (diff.inSeconds < 60) return '${diff.inSeconds}s ago'; + return '${diff.inMinutes}m ago'; + } + + static String _resolvedRoutePreview( + BuildContext context, + LiveTrafficEntry entry, + ) { + final route = entry.route; + if (route == null || route.hopHashes.isEmpty) { + return entry.routePreview; + } + + final contactsProvider = _maybeProvider(context); + final connectionProvider = _maybeProvider(context); + if (contactsProvider == null && connectionProvider == null) { + return entry.routePreview; + } + final ownLatLng = _ownLatLng(connectionProvider); + + final resolvedLabels = route.hopHashes.map((hashHex) { + final resolved = LogRxRouteDecoder.resolveHash( + hashHex, + contacts: contactsProvider?.contacts ?? const [], + ownPublicKey: connectionProvider?.deviceInfo.publicKey, + ownName: + connectionProvider?.deviceInfo.selfName ?? + connectionProvider?.deviceInfo.displayName, + ownLatitude: ownLatLng?.latitude, + ownLongitude: ownLatLng?.longitude, + ); + return _compactNodeLabel(resolved); + }).toList(); + return resolvedLabels.join(' -> '); + } + + static String? _originDistanceLabel( + BuildContext context, + LiveTrafficEntry entry, + ) { + final route = entry.route; + if (route == null || route.hopHashes.isEmpty) { + return null; + } + + final contactsProvider = _maybeProvider(context); + final connectionProvider = _maybeProvider(context); + final ownLatLng = _ownLatLng(connectionProvider); + final resolved = LogRxRouteDecoder.resolveHash( + route.hopHashes.first, + contacts: contactsProvider?.contacts ?? const [], + ownPublicKey: connectionProvider?.deviceInfo.publicKey, + ownName: + connectionProvider?.deviceInfo.selfName ?? + connectionProvider?.deviceInfo.displayName, + ownLatitude: ownLatLng?.latitude, + ownLongitude: ownLatLng?.longitude, + ); + if (resolved.latitude == null || resolved.longitude == null) { + return null; + } + + final currentPosition = LocationTrackingService().currentPosition; + final originDistanceMeters = currentPosition != null + ? Geolocator.distanceBetween( + currentPosition.latitude, + currentPosition.longitude, + resolved.latitude!, + resolved.longitude!, + ) + : ownLatLng != null + ? Geolocator.distanceBetween( + ownLatLng.latitude, + ownLatLng.longitude, + resolved.latitude!, + resolved.longitude!, + ) + : null; + if (originDistanceMeters == null) { + return null; + } + return _formatDistance(originDistanceMeters); + } + + static _GeoPoint? _ownLatLng(ConnectionProvider? connectionProvider) { + final advLat = connectionProvider?.deviceInfo.advLat; + final advLon = connectionProvider?.deviceInfo.advLon; + if (advLat == null || advLon == null || (advLat == 0 && advLon == 0)) { + return null; + } + return _GeoPoint(advLat / 1e6, advLon / 1e6); + } + + static String _formatDistance(double meters) { + if (meters < 1000) { + return '${meters.round()}m'; + } + return '${(meters / 1000).toStringAsFixed(1)}km'; + } + + static Future _showPacketBytesSheet( + BuildContext context, + List bytes, + ) { + final data = bytes.toList(growable: false); + final hexLines = []; + const bytesPerLine = 16; + for (var offset = 0; offset < data.length; offset += bytesPerLine) { + final chunk = data.skip(offset).take(bytesPerLine).toList(); + final hex = chunk + .map((byte) => byte.toRadixString(16).padLeft(2, '0').toUpperCase()) + .join(' '); + hexLines.add('${offset.toRadixString(16).padLeft(4, '0').toUpperCase()}: $hex'); + } + + final ascii = data + .map((byte) => (byte >= 32 && byte <= 126) ? String.fromCharCode(byte) : '.') + .join(); + + return showModalBottomSheet( + context: context, + showDragHandle: true, + isScrollControlled: true, + builder: (context) { + final scheme = Theme.of(context).colorScheme; + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Packet Bytes', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 6), + Text( + '${data.length} bytes', + style: TextStyle(color: scheme.onSurfaceVariant), + ), + const SizedBox(height: 16), + Text( + 'Hex', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: scheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + ), + child: SelectableText( + hexLines.join('\n'), + style: TextStyle( + fontSize: 12, + height: 1.45, + fontFamily: 'monospace', + color: scheme.onSurface, + ), + ), + ), + const SizedBox(height: 16), + Text( + 'ASCII', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: scheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + ), + child: SelectableText( + ascii.isEmpty ? '(empty)' : ascii, + style: TextStyle( + fontSize: 12, + height: 1.45, + fontFamily: 'monospace', + color: scheme.onSurface, + ), + ), + ), + ], + ), + ), + ), + ); + }, + ); + } + + static String _compactNodeLabel(ResolvedNodeHash node) { + if (node.isOwnNode) { + return node.label; + } + if (node.matchCount > 0) { + return node.label; + } + return node.hexLabel; + } +} + +class _GeoPoint { + final double latitude; + final double longitude; + + const _GeoPoint(this.latitude, this.longitude); +} + +class _PacketMetaChip extends StatelessWidget { + final String label; + final bool emphasized; + final VoidCallback? onTap; + + const _PacketMetaChip({ + required this.label, + this.emphasized = false, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final color = emphasized ? scheme.primary : scheme.outline; + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(999), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: color.withValues(alpha: emphasized ? 0.12 : 0.08), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: color.withValues(alpha: 0.22)), + ), + child: Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ), + ); + } +} + +void openLiveTrafficScreen(BuildContext context, ConnectionProvider provider) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => LiveTrafficScreen.fromProvider( + provider, + openPacketLogs: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => PacketLogScreen(bleService: provider.bleService), + ), + ); + }, + ), + ), + ); +} diff --git a/lib/services/contact_storage_service.dart b/lib/services/contact_storage_service.dart index 5934598..72f5991 100644 --- a/lib/services/contact_storage_service.dart +++ b/lib/services/contact_storage_service.dart @@ -2,12 +2,14 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../models/contact.dart'; +import '../models/contact_group.dart'; import '../utils/key_comparison.dart'; import 'package:latlong2/latlong.dart'; /// Service for persisting contacts to local storage class ContactStorageService { static const String _contactsKey = 'stored_contacts'; + static const String _contactGroupsKey = 'stored_contact_groups'; static const int _maxStoredContacts = 500; // Store up to 500 contacts /// Save contacts to persistent storage @@ -90,6 +92,40 @@ class ContactStorageService { } } + Future saveContactGroups(List groups) async { + try { + final prefs = await SharedPreferences.getInstance(); + final jsonString = jsonEncode( + groups.map((group) => _contactGroupToJson(group)).toList(), + ); + await prefs.setString(_contactGroupsKey, jsonString); + debugPrint( + '✅ [ContactStorage] Saved ${groups.length} contact groups to storage', + ); + } catch (e) { + debugPrint('❌ [ContactStorage] Error saving contact groups: $e'); + } + } + + Future> loadContactGroups() async { + try { + final prefs = await SharedPreferences.getInstance(); + final jsonString = prefs.getString(_contactGroupsKey); + if (jsonString == null || jsonString.isEmpty) { + return []; + } + + final jsonList = jsonDecode(jsonString) as List; + return jsonList + .map((json) => _contactGroupFromJson(json as Map)) + .whereType() + .toList(); + } catch (e) { + debugPrint('❌ [ContactStorage] Error loading contact groups: $e'); + return []; + } + } + /// Get storage statistics Future> getStorageStats() async { try { @@ -203,4 +239,31 @@ class ContactStorageService { return null; } } + + Map _contactGroupToJson(SavedContactGroup group) { + return { + 'id': group.id, + 'sectionKey': group.sectionKey, + 'label': group.label, + 'query': group.query, + 'createdAtMillis': group.createdAt.millisecondsSinceEpoch, + }; + } + + SavedContactGroup? _contactGroupFromJson(Map json) { + try { + return SavedContactGroup( + id: json['id'] as String, + sectionKey: json['sectionKey'] as String, + label: json['label'] as String, + query: json['query'] as String, + createdAt: DateTime.fromMillisecondsSinceEpoch( + json['createdAtMillis'] as int, + ), + ); + } catch (e) { + debugPrint('❌ [ContactStorage] Error parsing contact group: $e'); + return null; + } + } } diff --git a/lib/services/live_traffic_summary.dart b/lib/services/live_traffic_summary.dart new file mode 100644 index 0000000..e418a32 --- /dev/null +++ b/lib/services/live_traffic_summary.dart @@ -0,0 +1,246 @@ +import '../models/ble_packet_log.dart'; +import '../utils/log_rx_route_decoder.dart'; + +enum LiveTrafficBusyness { quiet, active, busy } + +class LiveTrafficEntry { + final BlePacketLog log; + final DecodedLogRxRoute? route; + + const LiveTrafficEntry({required this.log, required this.route}); + + bool get isMultiHop => (route?.hopCount ?? 0) > 1; + + int? get hopCount => route?.hopCount; + + String get payloadLabel { + final decodedRoute = route; + if (decodedRoute == null) { + return log.responseCode != null ? log.opcodeName : 'Unknown'; + } + return payloadTypeLabel(decodedRoute.payloadType); + } + + String? get payloadMeaning { + final decodedRoute = route; + if (decodedRoute == null) return null; + return payloadTypeMeaning(decodedRoute.payloadType); + } + + String get routePreview { + final decodedRoute = route; + if (decodedRoute == null || decodedRoute.hopHashes.isEmpty) { + return 'Direct packet'; + } + return decodedRoute.hopHashes + .map((hashHex) => '0x${hashHex.toUpperCase()}') + .join(' -> '); + } + + static String payloadTypeLabel(int payloadType) { + switch (payloadType) { + case 0x00: + return 'Request'; + case 0x01: + return 'Response'; + case 0x02: + return 'Text message'; + case 0x03: + return 'Ack'; + case 0x04: + return 'Advertisement'; + case 0x05: + return 'Group text'; + case 0x06: + return 'Group datagram'; + case 0x07: + return 'Anonymous request'; + case 0x08: + return 'Returned path'; + case 0x09: + return 'Trace path'; + case 0x0A: + return 'Multipart packet'; + case 0x0B: + return 'Control packet'; + default: + return '0x${payloadType.toRadixString(16).padLeft(2, '0')}'; + } + } + + static String payloadTypeMeaning(int payloadType) { + switch (payloadType) { + case 0x00: + return 'Request (destination/source hashes + MAC)'; + case 0x01: + return 'Response to Request or Anonymous request'; + case 0x02: + return 'Plain text message'; + case 0x03: + return 'Simple acknowledgement'; + case 0x04: + return 'Node advertisement'; + case 0x05: + return 'Unverified group text message'; + case 0x06: + return 'Unverified group datagram'; + case 0x07: + return 'Generic anonymous request'; + case 0x08: + return 'Returned path payload'; + case 0x09: + return 'Trace path collecting hop SNR'; + case 0x0A: + return 'One packet from a multipart set'; + case 0x0B: + return 'Control or discovery packet'; + default: + return 'protocol payload'; + } + } +} + +class LiveTrafficSnapshot { + final DateTime windowStart; + final Duration windowDuration; + final int packetsPerMinute; + final int rxCount; + final int txCount; + final int totalCount; + final double? avgSnrDb; + final double? latestSnrDb; + final double? avgRssiDbm; + final int? latestRssiDbm; + final int multiHopCount; + final double? avgHopCount; + final List visibleEntries; + final LiveTrafficBusyness busyness; + + const LiveTrafficSnapshot({ + required this.windowStart, + required this.windowDuration, + required this.packetsPerMinute, + required this.rxCount, + required this.txCount, + required this.totalCount, + required this.avgSnrDb, + required this.latestSnrDb, + required this.avgRssiDbm, + required this.latestRssiDbm, + required this.multiHopCount, + required this.avgHopCount, + required this.visibleEntries, + required this.busyness, + }); +} + +class LiveTrafficSummary { + static const Duration rollingWindow = Duration(seconds: 60); + static const int maxVisibleEntries = 120; + static const int logRxDataResponseCode = 0x88; + + const LiveTrafficSummary._(); + + static LiveTrafficSnapshot fromLogs( + Iterable logs, { + required DateTime now, + DateTime? clearedAt, + int? preferredHashSize, + Duration window = rollingWindow, + String? packetTypeFilter, + }) { + final windowStart = now.subtract(window); + final effectiveStart = clearedAt != null && clearedAt.isAfter(windowStart) + ? clearedAt + : windowStart; + + final recentLogs = logs + .where( + (log) => + log.direction == PacketDirection.rx && + log.responseCode == logRxDataResponseCode && + !log.timestamp.isBefore(effectiveStart), + ) + .toList() + ..sort((a, b) => a.timestamp.compareTo(b.timestamp)); + + final entries = []; + for (final log in recentLogs) { + final route = LogRxRouteDecoder.decode( + log.rawData, + preferredHashSize: preferredHashSize, + ); + entries.add(LiveTrafficEntry(log: log, route: route)); + } + + final filteredEntries = packetTypeFilter == null + ? entries + : entries + .where((entry) => entry.payloadLabel == packetTypeFilter) + .toList(); + + var rxCount = 0; + var snrCount = 0; + var snrSum = 0.0; + var rssiCount = 0; + var rssiSum = 0.0; + double? latestSnrDb; + int? latestRssiDbm; + var multiHopCount = 0; + var hopCountTotal = 0; + var hopCountSamples = 0; + + for (final entry in filteredEntries) { + rxCount += 1; + + final rxInfo = entry.log.logRxDataInfo; + if (rxInfo?.snrDb != null) { + snrCount += 1; + snrSum += rxInfo!.snrDb!; + latestSnrDb = rxInfo.snrDb!; + } + if (rxInfo?.rssiDbm != null) { + rssiCount += 1; + rssiSum += rxInfo!.rssiDbm!.toDouble(); + latestRssiDbm = rxInfo.rssiDbm!; + } + + final route = entry.route; + if (route != null && route.hopCount > 0) { + hopCountSamples += 1; + hopCountTotal += route.hopCount; + if (route.hopCount > 1) { + multiHopCount += 1; + } + } + } + + final visibleEntries = filteredEntries.reversed.take(maxVisibleEntries).toList(); + const txCount = 0; + final totalCount = rxCount; + final packetsPerMinute = totalCount; + + return LiveTrafficSnapshot( + windowStart: effectiveStart, + windowDuration: window, + packetsPerMinute: packetsPerMinute, + rxCount: rxCount, + txCount: txCount, + totalCount: totalCount, + avgSnrDb: snrCount == 0 ? null : snrSum / snrCount, + latestSnrDb: latestSnrDb, + avgRssiDbm: rssiCount == 0 ? null : rssiSum / rssiCount, + latestRssiDbm: latestRssiDbm, + multiHopCount: multiHopCount, + avgHopCount: hopCountSamples == 0 ? null : hopCountTotal / hopCountSamples, + visibleEntries: visibleEntries, + busyness: _busynessForPacketsPerMinute(packetsPerMinute), + ); + } + + static LiveTrafficBusyness _busynessForPacketsPerMinute(int ppm) { + if (ppm <= 5) return LiveTrafficBusyness.quiet; + if (ppm <= 20) return LiveTrafficBusyness.active; + return LiveTrafficBusyness.busy; + } +} diff --git a/lib/services/path_history_service.dart b/lib/services/path_history_service.dart index 81cfd50..855e11e 100644 --- a/lib/services/path_history_service.dart +++ b/lib/services/path_history_service.dart @@ -54,6 +54,7 @@ class PathHistoryService { pathBytes: contact.routePathBytes.toList(), hopCount: contact.routeHopCount, hashSize: contact.routeHashSize, + source: existing?.source ?? PathRecordSource.learned, successCount: existing?.successCount ?? 0, failureCount: existing?.failureCount ?? 0, lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0, @@ -98,6 +99,7 @@ class PathHistoryService { pathBytes: normalizedPathBytes, hopCount: normalizedPathBytes.length ~/ hashSize, hashSize: hashSize, + source: PathRecordSource.observed, successCount: existing?.successCount ?? 0, failureCount: existing?.failureCount ?? 0, lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0, @@ -197,6 +199,7 @@ class PathHistoryService { pathBytes: selection.pathBytes.toList(), hopCount: selection.hopCount, hashSize: selection.hashSize, + source: existing?.source ?? PathRecordSource.learned, successCount: (existing?.successCount ?? 0) + (success ? 1 : 0), failureCount: (existing?.failureCount ?? 0) + (success ? 0 : 1), lastRoundTripTimeMs: success diff --git a/lib/utils/log_rx_route_decoder.dart b/lib/utils/log_rx_route_decoder.dart index 95564d3..a5deec4 100644 --- a/lib/utils/log_rx_route_decoder.dart +++ b/lib/utils/log_rx_route_decoder.dart @@ -30,6 +30,8 @@ class ResolvedNodeHash { final bool isOwnNode; final bool isUniqueMatch; final int matchCount; + final double? latitude; + final double? longitude; const ResolvedNodeHash({ required this.hashHex, @@ -37,6 +39,8 @@ class ResolvedNodeHash { required this.isOwnNode, required this.isUniqueMatch, required this.matchCount, + this.latitude, + this.longitude, }); String get hexLabel => '0x${hashHex.toUpperCase()}'; @@ -179,6 +183,8 @@ class LogRxRouteDecoder { required Iterable contacts, Uint8List? ownPublicKey, String? ownName, + double? ownLatitude, + double? ownLongitude, }) { final normalizedHashHex = hashHex.toLowerCase(); final ownKeyHex = _bytesToHex(ownPublicKey); @@ -192,6 +198,8 @@ class LogRxRouteDecoder { isOwnNode: true, isUniqueMatch: true, matchCount: 1, + latitude: ownLatitude, + longitude: ownLongitude, ); } @@ -210,12 +218,15 @@ class LogRxRouteDecoder { } if (matches.length == 1) { + final location = matches.first.displayLocation; return ResolvedNodeHash( hashHex: normalizedHashHex, label: matches.first.displayName, isOwnNode: false, isUniqueMatch: true, matchCount: 1, + latitude: location?.latitude, + longitude: location?.longitude, ); } diff --git a/lib/widgets/contacts/contact_route_dialog.dart b/lib/widgets/contacts/contact_route_dialog.dart index c27282c..2a309f9 100644 --- a/lib/widgets/contacts/contact_route_dialog.dart +++ b/lib/widgets/contacts/contact_route_dialog.dart @@ -357,13 +357,62 @@ class _ContactRouteDialogState extends State { final lastSeen = MaterialLocalizations.of( context, ).formatShortDate(record.lastUsedAt); + final sourceLabel = switch (record.source) { + PathRecordSource.observed => 'Observed on mesh', + PathRecordSource.learned => 'Learned route', + }; final successRate = attempts == 0 ? 'No send stats yet' : '${(record.successRate * 100).round()}% success over $attempts send${attempts == 1 ? '' : 's'}'; final latency = record.lastRoundTripTimeMs > 0 ? ' • ${record.lastRoundTripTimeMs} ms' : ''; - return '$successRate • Last used $lastSeen$latency'; + return '$sourceLabel • $successRate • Last used $lastSeen$latency'; + } + + Widget _buildHistoryRecordTile(PathRecord record, {String? title}) { + final canonicalText = _canonicalRouteFromBytes( + record.pathBytes, + hashSize: record.hashSize, + ); + return Card( + margin: EdgeInsets.zero, + child: ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + leading: title == null ? null : const Icon(Icons.alt_route), + title: title == null + ? Text( + canonicalText, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'), + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 6), + Text( + canonicalText, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'), + ), + ], + ), + subtitle: Padding( + padding: const EdgeInsets.only(top: 6), + child: Text(_historySubtitle(record)), + ), + trailing: FilledButton.tonal( + onPressed: () => _applyHistoryRecord(record), + child: const Text('Use'), + ), + ), + ); } Widget _buildPreviewSection() { @@ -586,41 +635,44 @@ class _ContactRouteDialogState extends State { ); } - return ListView.separated( - itemCount: records.length, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - separatorBuilder: (_, _) => const SizedBox(height: 8), - itemBuilder: (context, index) { - final record = records[index]; - final canonicalText = _canonicalRouteFromBytes( - record.pathBytes, - hashSize: record.hashSize, - ); - return Card( - margin: EdgeInsets.zero, - child: ListTile( - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 10, - ), - title: Text( - canonicalText, - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'), - ), - subtitle: Padding( - padding: const EdgeInsets.only(top: 6), - child: Text(_historySubtitle(record)), - ), - trailing: FilledButton.tonal( - onPressed: () => _applyHistoryRecord(record), - child: const Text('Use'), - ), + PathRecord? observedRecord; + for (final record in records) { + if (record.source == PathRecordSource.observed) { + observedRecord = record; + break; + } + } + final remainingRecords = observedRecord == null + ? records + : records + .where((record) => !identical(record, observedRecord)) + .toList(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (observedRecord != null) ...[ + _buildHistoryRecordTile(observedRecord, title: 'Observed mesh route'), + const SizedBox(height: 16), + ], + if (remainingRecords.isEmpty) + Text( + observedRecord == null + ? 'No additional route history yet.' + : 'Observed routes you start using will continue to build history here.', + style: Theme.of(context).textTheme.bodyMedium, + ) + else + ListView.separated( + itemCount: remainingRecords.length, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (context, index) { + return _buildHistoryRecordTile(remainingRecords[index]); + }, ), - ); - }, + ], ); } diff --git a/test/providers/contacts_provider_test.dart b/test/providers/contacts_provider_test.dart index b5b94a9..8262f43 100644 --- a/test/providers/contacts_provider_test.dart +++ b/test/providers/contacts_provider_test.dart @@ -699,4 +699,39 @@ void main() { expect(after.advLon, equals(before.advLon)); }); }); + + group('ContactsProvider saved contact groups', () { + late ContactsProvider provider; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + provider = ContactsProvider(); + }); + + test('adds and removes saved groups by filter', () async { + expect(provider.savedContactGroups, isEmpty); + + await provider.addSavedGroupForFilter('teamMembers', 'alpha'); + + expect(provider.savedContactGroups, hasLength(1)); + expect(provider.hasSavedGroupForFilter('teamMembers', 'alpha'), isTrue); + expect(provider.hasSavedGroupForFilter('teamMembers', 'ALPHA'), isTrue); + + await provider.removeSavedGroupForFilter('teamMembers', 'ALPHA'); + + expect(provider.savedContactGroups, isEmpty); + expect(provider.hasSavedGroupForFilter('teamMembers', 'alpha'), isFalse); + }); + + test('loads persisted saved groups during initialization', () async { + await provider.addSavedGroupForFilter('rooms', 'ops'); + + final restored = ContactsProvider(); + await restored.initializeEarly(); + + expect(restored.savedGroupsForSection('rooms'), hasLength(1)); + expect(restored.savedGroupsForSection('rooms').first.query, 'ops'); + expect(restored.savedGroupsForSection('rooms').first.label, 'ops'); + }); + }); } diff --git a/test/screens/live_traffic_screen_test.dart b/test/screens/live_traffic_screen_test.dart new file mode 100644 index 0000000..c0b05ed --- /dev/null +++ b/test/screens/live_traffic_screen_test.dart @@ -0,0 +1,179 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/models/ble_packet_log.dart'; +import 'package:meshcore_sar_app/screens/live_traffic_screen.dart'; + +BlePacketLog _log({ + required DateTime timestamp, + required PacketDirection direction, + required List rawData, + int? responseCode, + double? snrDb, + int? rssiDbm, +}) { + return BlePacketLog( + timestamp: timestamp, + rawData: Uint8List.fromList(rawData), + direction: direction, + responseCode: responseCode ?? (rawData.isEmpty ? null : rawData.first), + logRxDataInfo: snrDb == null && rssiDbm == null + ? null + : LogRxDataInfo( + entropy: 0, + isLikelyEncrypted: false, + snrDb: snrDb, + rssiDbm: rssiDbm, + ), + ); +} + +List _multiHopRaw({ + required List hops, + int payloadType = 0x01, + int hashSize = 2, +}) { + final hopCount = hops.length ~/ hashSize; + final pathDescriptor = ((hashSize - 1) << 6) | hopCount; + return [ + 0x88, + 0x00, + 0x00, + payloadType << 2, + 0x00, + 0x00, + 0x00, + 0x00, + pathDescriptor, + ...hops, + ]; +} + +void main() { + testWidgets('shows empty state before traffic arrives', (tester) async { + final logs = []; + final refresh = ValueNotifier(0); + DateTime now = DateTime(2026, 3, 12, 12, 0, 0); + + await tester.pumpWidget( + MaterialApp( + home: LiveTrafficScreen( + logReader: () => logs, + refreshListenable: refresh, + now: () => now, + ), + ), + ); + + expect(find.text('No live traffic yet'), findsOneWidget); + expect(find.text('Quiet'), findsOneWidget); + }); + + testWidgets('updates summary and stream for incoming live traffic', ( + tester, + ) async { + final logs = []; + final refresh = ValueNotifier(0); + DateTime now = DateTime(2026, 3, 12, 12, 0, 0); + + await tester.pumpWidget( + MaterialApp( + home: LiveTrafficScreen( + logReader: () => logs, + rxCountReader: () => 7, + refreshListenable: refresh, + now: () => now, + ), + ), + ); + + logs.addAll([ + _log( + timestamp: now.subtract(const Duration(seconds: 10)), + direction: PacketDirection.rx, + rawData: _multiHopRaw(hops: [0xC0, 0x10, 0x63, 0x01, 0x68, 0xD9]), + responseCode: 0x88, + snrDb: 13.5, + rssiDbm: -84, + ), + _log( + timestamp: now.subtract(const Duration(seconds: 3)), + direction: PacketDirection.tx, + rawData: [0x05, 0x01, 0x02], + responseCode: 0x88, + ), + _log( + timestamp: now.subtract(const Duration(seconds: 2)), + direction: PacketDirection.rx, + rawData: [0x05, 0x01, 0x02], + responseCode: 0x05, + ), + ]); + refresh.value += 1; + await tester.pump(); + + expect(find.text('1 pkt/min'), findsOneWidget); + expect(find.text('Device total 7'), findsOneWidget); + expect(find.textContaining('RESP'), findsOneWidget); + expect(find.text('MULTI-HOP'), findsOneWidget); + expect(find.textContaining('RSSI -84 dBm'), findsOneWidget); + }); + + testWidgets('clear live view only resets transient screen state', ( + tester, + ) async { + final logs = []; + final refresh = ValueNotifier(0); + DateTime now = DateTime(2026, 3, 12, 12, 0, 0); + + logs.add( + _log( + timestamp: now.subtract(const Duration(seconds: 4)), + direction: PacketDirection.rx, + rawData: _multiHopRaw(hops: [0xC0, 0x10, 0x63, 0x01]), + responseCode: 0x88, + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: LiveTrafficScreen( + logReader: () => logs, + refreshListenable: refresh, + now: () => now, + ), + ), + ); + + expect(find.text('MULTI-HOP'), findsOneWidget); + + await tester.tap(find.byTooltip('Clear live view')); + await tester.pump(); + + expect(find.text('No live traffic yet'), findsOneWidget); + + now = now.add(const Duration(seconds: 2)); + logs.add( + _log( + timestamp: now, + direction: PacketDirection.tx, + rawData: [0x03, 0x04], + responseCode: 0x88, + ), + ); + logs.add( + _log( + timestamp: now, + direction: PacketDirection.rx, + rawData: [0x88, 0x00, 0x00], + responseCode: 0x88, + ), + ); + refresh.value += 1; + await tester.pump(); + + expect(find.text('No live traffic yet'), 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 new file mode 100644 index 0000000..de8dbd9 --- /dev/null +++ b/test/services/live_traffic_summary_test.dart @@ -0,0 +1,147 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/models/ble_packet_log.dart'; +import 'package:meshcore_sar_app/services/live_traffic_summary.dart'; + +BlePacketLog _log({ + required DateTime timestamp, + required PacketDirection direction, + required List rawData, + int? responseCode, + double? snrDb, + int? rssiDbm, +}) { + return BlePacketLog( + timestamp: timestamp, + rawData: Uint8List.fromList(rawData), + direction: direction, + responseCode: responseCode ?? (rawData.isEmpty ? null : rawData.first), + logRxDataInfo: snrDb == null && rssiDbm == null + ? null + : LogRxDataInfo( + entropy: 0, + isLikelyEncrypted: false, + snrDb: snrDb, + rssiDbm: rssiDbm, + ), + ); +} + +List _multiHopRaw({ + required List hops, + int payloadType = 0x01, + int hashSize = 2, +}) { + final hopCount = hops.length ~/ hashSize; + final pathDescriptor = ((hashSize - 1) << 6) | hopCount; + return [ + 0x88, + 0x00, + 0x00, + payloadType << 2, + 0x00, + 0x00, + 0x00, + 0x00, + pathDescriptor, + ...hops, + ]; +} + +void main() { + group('LiveTrafficSummary', () { + test('uses only the rolling 60-second window', () { + final now = DateTime(2026, 3, 12, 12, 0, 0); + final snapshot = LiveTrafficSummary.fromLogs([ + _log( + timestamp: now.subtract(const Duration(seconds: 61)), + direction: PacketDirection.rx, + rawData: [0x88, 0x00, 0x00], + responseCode: 0x88, + ), + _log( + timestamp: now.subtract(const Duration(seconds: 20)), + direction: PacketDirection.rx, + rawData: [0x88, 0x00, 0x00], + responseCode: 0x88, + ), + _log( + timestamp: now.subtract(const Duration(seconds: 10)), + direction: PacketDirection.tx, + rawData: [0x01, 0x02], + responseCode: 0x88, + ), + _log( + timestamp: now.subtract(const Duration(seconds: 5)), + direction: PacketDirection.rx, + rawData: [0x01, 0x02], + responseCode: 0x01, + ), + ], now: now); + + expect(snapshot.totalCount, 1); + expect(snapshot.rxCount, 1); + expect(snapshot.txCount, 0); + expect(snapshot.packetsPerMinute, 1); + }); + + test('aggregates RSSI, SNR, and multi-hop route metrics', () { + final now = DateTime(2026, 3, 12, 12, 0, 0); + final snapshot = LiveTrafficSummary.fromLogs([ + _log( + timestamp: now.subtract(const Duration(seconds: 30)), + direction: PacketDirection.rx, + rawData: _multiHopRaw(hops: [0xC0, 0x10, 0x63, 0x01, 0x68, 0xD9]), + responseCode: 0x88, + snrDb: 12.0, + rssiDbm: -84, + ), + _log( + timestamp: now.subtract(const Duration(seconds: 15)), + direction: PacketDirection.rx, + rawData: _multiHopRaw(hops: [0xDE, 0xAD, 0xBE, 0xEF]), + responseCode: 0x88, + snrDb: 6.0, + rssiDbm: -90, + ), + _log( + timestamp: now.subtract(const Duration(seconds: 5)), + direction: PacketDirection.tx, + rawData: [0x03, 0x04], + responseCode: 0x88, + ), + ], now: now); + + expect(snapshot.latestRssiDbm, -90); + expect(snapshot.latestSnrDb, 6.0); + expect(snapshot.avgRssiDbm, closeTo(-87.0, 0.01)); + expect(snapshot.avgSnrDb, closeTo(9.0, 0.01)); + expect(snapshot.multiHopCount, 2); + expect(snapshot.avgHopCount, closeTo(2.5, 0.01)); + expect(snapshot.busyness, LiveTrafficBusyness.quiet); + }); + + 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)); + final snapshot = LiveTrafficSummary.fromLogs([ + _log( + timestamp: now.subtract(const Duration(seconds: 10)), + direction: PacketDirection.rx, + rawData: [0x88, 0x00, 0x00], + responseCode: 0x88, + ), + _log( + timestamp: now.subtract(const Duration(seconds: 4)), + direction: PacketDirection.rx, + rawData: [0x88, 0x00, 0x00], + responseCode: 0x88, + ), + ], now: now, clearedAt: clearAt); + + expect(snapshot.totalCount, 1); + expect(snapshot.visibleEntries, hasLength(1)); + }); + }); +} diff --git a/test/services/path_history_service_test.dart b/test/services/path_history_service_test.dart index 9ae869d..95082ac 100644 --- a/test/services/path_history_service_test.dart +++ b/test/services/path_history_service_test.dart @@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:meshcore_sar_app/models/contact.dart'; +import 'package:meshcore_sar_app/models/path_history.dart'; import 'package:meshcore_sar_app/models/path_selection.dart'; import 'package:meshcore_sar_app/services/path_history_service.dart'; @@ -178,6 +179,31 @@ void main() { expect(history.directPaths.single.pathBytes, [0x03, 0x04, 0x01, 0x02]); expect(history.directPaths.single.hashSize, 2); expect(history.directPaths.single.hopCount, 2); + expect(history.directPaths.single.source, PathRecordSource.observed); + }, + ); + + test( + 'learned paths stay marked as observed after being seen on-air', + () async { + final service = PathHistoryService(); + final contact = _buildContact( + seed: 3, + pathBytes: [0xAA, 0xBB], + hopCount: 2, + hashSize: 1, + ); + + await service.initialize(); + await service.recordReceivedBytePath(contact.publicKeyHex, [ + 0xBB, + 0xAA, + ], 1); + await service.recordLearnedPath(contact); + + final history = service.historyFor(contact.publicKeyHex); + expect(history.directPaths, hasLength(1)); + expect(history.directPaths.single.source, PathRecordSource.observed); }, ); }