mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Add packet filter stats and hex
This commit is contained in:
31
lib/models/contact_group.dart
Normal file
31
lib/models/contact_group.dart
Normal file
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
|
enum PathRecordSource { learned, observed }
|
||||||
|
|
||||||
class PathRecord {
|
class PathRecord {
|
||||||
final List<int> pathBytes;
|
final List<int> pathBytes;
|
||||||
final int hopCount;
|
final int hopCount;
|
||||||
final int hashSize;
|
final int hashSize;
|
||||||
|
final PathRecordSource source;
|
||||||
final int successCount;
|
final int successCount;
|
||||||
final int failureCount;
|
final int failureCount;
|
||||||
final int lastRoundTripTimeMs;
|
final int lastRoundTripTimeMs;
|
||||||
@@ -11,6 +14,7 @@ class PathRecord {
|
|||||||
required this.pathBytes,
|
required this.pathBytes,
|
||||||
required this.hopCount,
|
required this.hopCount,
|
||||||
required this.hashSize,
|
required this.hashSize,
|
||||||
|
required this.source,
|
||||||
required this.successCount,
|
required this.successCount,
|
||||||
required this.failureCount,
|
required this.failureCount,
|
||||||
required this.lastRoundTripTimeMs,
|
required this.lastRoundTripTimeMs,
|
||||||
@@ -27,6 +31,7 @@ class PathRecord {
|
|||||||
List<int>? pathBytes,
|
List<int>? pathBytes,
|
||||||
int? hopCount,
|
int? hopCount,
|
||||||
int? hashSize,
|
int? hashSize,
|
||||||
|
PathRecordSource? source,
|
||||||
int? successCount,
|
int? successCount,
|
||||||
int? failureCount,
|
int? failureCount,
|
||||||
int? lastRoundTripTimeMs,
|
int? lastRoundTripTimeMs,
|
||||||
@@ -36,6 +41,7 @@ class PathRecord {
|
|||||||
pathBytes: pathBytes ?? this.pathBytes,
|
pathBytes: pathBytes ?? this.pathBytes,
|
||||||
hopCount: hopCount ?? this.hopCount,
|
hopCount: hopCount ?? this.hopCount,
|
||||||
hashSize: hashSize ?? this.hashSize,
|
hashSize: hashSize ?? this.hashSize,
|
||||||
|
source: source ?? this.source,
|
||||||
successCount: successCount ?? this.successCount,
|
successCount: successCount ?? this.successCount,
|
||||||
failureCount: failureCount ?? this.failureCount,
|
failureCount: failureCount ?? this.failureCount,
|
||||||
lastRoundTripTimeMs: lastRoundTripTimeMs ?? this.lastRoundTripTimeMs,
|
lastRoundTripTimeMs: lastRoundTripTimeMs ?? this.lastRoundTripTimeMs,
|
||||||
@@ -48,6 +54,7 @@ class PathRecord {
|
|||||||
'path_bytes': pathBytes,
|
'path_bytes': pathBytes,
|
||||||
'hop_count': hopCount,
|
'hop_count': hopCount,
|
||||||
'hash_size': hashSize,
|
'hash_size': hashSize,
|
||||||
|
'source': source.name,
|
||||||
'success_count': successCount,
|
'success_count': successCount,
|
||||||
'failure_count': failureCount,
|
'failure_count': failureCount,
|
||||||
'last_round_trip_time_ms': lastRoundTripTimeMs,
|
'last_round_trip_time_ms': lastRoundTripTimeMs,
|
||||||
@@ -62,6 +69,10 @@ class PathRecord {
|
|||||||
.toList(),
|
.toList(),
|
||||||
hopCount: json['hop_count'] as int? ?? 0,
|
hopCount: json['hop_count'] as int? ?? 0,
|
||||||
hashSize: json['hash_size'] as int? ?? 1,
|
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,
|
successCount: json['success_count'] as int? ?? 0,
|
||||||
failureCount: json['failure_count'] as int? ?? 0,
|
failureCount: json['failure_count'] as int? ?? 0,
|
||||||
lastRoundTripTimeMs: json['last_round_trip_time_ms'] as int? ?? 0,
|
lastRoundTripTimeMs: json['last_round_trip_time_ms'] as int? ?? 0,
|
||||||
@@ -163,6 +174,10 @@ class ContactPathHistory {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<PathRecord> get observedPaths => directPaths
|
||||||
|
.where((record) => record.source == PathRecordSource.observed)
|
||||||
|
.toList();
|
||||||
|
|
||||||
factory ContactPathHistory.fromJson(
|
factory ContactPathHistory.fromJson(
|
||||||
String contactPublicKeyHex,
|
String contactPublicKeyHex,
|
||||||
Map<String, dynamic> json,
|
Map<String, dynamic> json,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:math';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
|
import '../models/contact_group.dart';
|
||||||
import '../models/message_contact_location.dart';
|
import '../models/message_contact_location.dart';
|
||||||
import '../services/cayenne_lpp_parser.dart';
|
import '../services/cayenne_lpp_parser.dart';
|
||||||
import '../services/contact_storage_service.dart';
|
import '../services/contact_storage_service.dart';
|
||||||
@@ -58,6 +59,7 @@ class _RetainedRoute {
|
|||||||
class ContactsProvider with ChangeNotifier {
|
class ContactsProvider with ChangeNotifier {
|
||||||
static const double _firstHopFallbackOffsetMeters = 100.0;
|
static const double _firstHopFallbackOffsetMeters = 100.0;
|
||||||
final Map<String, Contact> _contacts = {};
|
final Map<String, Contact> _contacts = {};
|
||||||
|
final List<SavedContactGroup> _savedContactGroups = <SavedContactGroup>[];
|
||||||
final Map<String, PendingAdvert> _pendingAdverts = {};
|
final Map<String, PendingAdvert> _pendingAdverts = {};
|
||||||
final ContactStorageService _storageService = ContactStorageService();
|
final ContactStorageService _storageService = ContactStorageService();
|
||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
@@ -79,6 +81,7 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
'📦 [ContactsProvider] Early loading persisted contacts (no filtering)...',
|
'📦 [ContactsProvider] Early loading persisted contacts (no filtering)...',
|
||||||
);
|
);
|
||||||
final storedContacts = await _storageService.loadContacts();
|
final storedContacts = await _storageService.loadContacts();
|
||||||
|
final storedGroups = await _storageService.loadContactGroups();
|
||||||
|
|
||||||
// Add stored contacts (excluding any with all-zeros public key)
|
// Add stored contacts (excluding any with all-zeros public key)
|
||||||
const publicChannelKey =
|
const publicChannelKey =
|
||||||
@@ -92,8 +95,11 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_isInitialized = true;
|
_isInitialized = true;
|
||||||
|
_savedContactGroups
|
||||||
|
..clear()
|
||||||
|
..addAll(storedGroups);
|
||||||
debugPrint(
|
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
|
// Ensure public channel exists after loading
|
||||||
@@ -123,6 +129,7 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
final storedContacts = await _storageService.loadContacts(
|
final storedContacts = await _storageService.loadContacts(
|
||||||
excludePublicKey: devicePublicKey,
|
excludePublicKey: devicePublicKey,
|
||||||
);
|
);
|
||||||
|
final storedGroups = await _storageService.loadContactGroups();
|
||||||
|
|
||||||
// Add stored contacts (excluding any with all-zeros public key)
|
// Add stored contacts (excluding any with all-zeros public key)
|
||||||
const publicChannelKey =
|
const publicChannelKey =
|
||||||
@@ -136,8 +143,11 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_isInitialized = true;
|
_isInitialized = true;
|
||||||
|
_savedContactGroups
|
||||||
|
..clear()
|
||||||
|
..addAll(storedGroups);
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'✅ [ContactsProvider] Loaded ${storedContacts.length} persisted contacts',
|
'✅ [ContactsProvider] Loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Ensure public channel exists after loading
|
// Ensure public channel exists after loading
|
||||||
@@ -208,10 +218,97 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<Contact> get contacts => _contacts.values.toList();
|
List<Contact> get contacts => _contacts.values.toList();
|
||||||
|
List<SavedContactGroup> get savedContactGroups =>
|
||||||
|
List<SavedContactGroup>.from(_savedContactGroups)
|
||||||
|
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||||
List<PendingAdvert> get pendingAdverts =>
|
List<PendingAdvert> get pendingAdverts =>
|
||||||
_pendingAdverts.values.toList()
|
_pendingAdverts.values.toList()
|
||||||
..sort((a, b) => b.receivedAt.compareTo(a.receivedAt));
|
..sort((a, b) => b.receivedAt.compareTo(a.receivedAt));
|
||||||
|
|
||||||
|
List<SavedContactGroup> 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<void> 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<void> removeSavedGroupById(String id) async {
|
||||||
|
final beforeCount = _savedContactGroups.length;
|
||||||
|
_savedContactGroups.removeWhere((group) => group.id == id);
|
||||||
|
if (_savedContactGroups.length == beforeCount) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _persistSavedGroups();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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<void> _persistSavedGroups() async {
|
||||||
|
try {
|
||||||
|
await _storageService.saveContactGroups(_savedContactGroups);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [ContactsProvider] Error persisting contact groups: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _normalizeGroupQuery(String query) => query.trim().toLowerCase();
|
||||||
|
|
||||||
List<Contact> get chatContacts =>
|
List<Contact> get chatContacts =>
|
||||||
contacts.where((c) => c.isChat).toList()..sort(_sortByLastSeen);
|
contacts.where((c) => c.isChat).toList()..sort(_sortByLastSeen);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
|
|||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
|
import '../models/contact_group.dart';
|
||||||
import '../providers/contacts_provider.dart';
|
import '../providers/contacts_provider.dart';
|
||||||
import '../providers/app_provider.dart';
|
import '../providers/app_provider.dart';
|
||||||
import '../providers/connection_provider.dart';
|
import '../providers/connection_provider.dart';
|
||||||
@@ -203,6 +204,80 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
}).toList();
|
}).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<Contact> 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<void> _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<Contact> _sortContacts(List<Contact> contacts, ContactSection section) {
|
List<Contact> _sortContacts(List<Contact> contacts, ContactSection section) {
|
||||||
final sorted = List<Contact>.from(contacts);
|
final sorted = List<Contact>.from(contacts);
|
||||||
if (section == ContactSection.channels) {
|
if (section == ContactSection.channels) {
|
||||||
@@ -319,18 +394,38 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
allChatContacts,
|
allChatContacts,
|
||||||
ContactSection.teamMembers,
|
ContactSection.teamMembers,
|
||||||
);
|
);
|
||||||
|
final savedTeamGroups = _buildSavedGroupsForSection(
|
||||||
|
contactsProvider,
|
||||||
|
allChatContacts,
|
||||||
|
ContactSection.teamMembers,
|
||||||
|
);
|
||||||
final repeaters = _filterContactsForSection(
|
final repeaters = _filterContactsForSection(
|
||||||
allRepeaters,
|
allRepeaters,
|
||||||
ContactSection.repeaters,
|
ContactSection.repeaters,
|
||||||
);
|
);
|
||||||
|
final savedRepeaterGroups = _buildSavedGroupsForSection(
|
||||||
|
contactsProvider,
|
||||||
|
allRepeaters,
|
||||||
|
ContactSection.repeaters,
|
||||||
|
);
|
||||||
final rooms = _filterContactsForSection(
|
final rooms = _filterContactsForSection(
|
||||||
allRooms,
|
allRooms,
|
||||||
ContactSection.rooms,
|
ContactSection.rooms,
|
||||||
);
|
);
|
||||||
|
final savedRoomGroups = _buildSavedGroupsForSection(
|
||||||
|
contactsProvider,
|
||||||
|
allRooms,
|
||||||
|
ContactSection.rooms,
|
||||||
|
);
|
||||||
final filteredChannels = _filterContactsForSection(
|
final filteredChannels = _filterContactsForSection(
|
||||||
allChannels,
|
allChannels,
|
||||||
ContactSection.channels,
|
ContactSection.channels,
|
||||||
);
|
);
|
||||||
|
final savedChannelGroups = _buildSavedGroupsForSection(
|
||||||
|
contactsProvider,
|
||||||
|
allChannels,
|
||||||
|
ContactSection.channels,
|
||||||
|
);
|
||||||
final pendingAdverts = contactsProvider.pendingAdverts;
|
final pendingAdverts = contactsProvider.pendingAdverts;
|
||||||
|
|
||||||
_schedulePendingAdvertResolution(pendingAdverts, connectionProvider);
|
_schedulePendingAdvertResolution(pendingAdverts, connectionProvider);
|
||||||
@@ -385,11 +480,22 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
ContactSection.teamMembers,
|
ContactSection.teamMembers,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_buildSectionFilterField(context, ContactSection.teamMembers),
|
_buildSectionFilterField(
|
||||||
|
context,
|
||||||
|
ContactSection.teamMembers,
|
||||||
|
contactsProvider,
|
||||||
|
),
|
||||||
if (chatContacts.isEmpty)
|
if (chatContacts.isEmpty)
|
||||||
_buildEmptyFilterState(context)
|
_buildEmptyFilterState(context)
|
||||||
else
|
else ...[
|
||||||
..._buildContactSectionItems(chatContacts),
|
..._buildSavedGroupCards(
|
||||||
|
savedTeamGroups,
|
||||||
|
ContactSection.teamMembers,
|
||||||
|
),
|
||||||
|
..._buildContactSectionItems(
|
||||||
|
_excludeGroupedContacts(chatContacts, savedTeamGroups),
|
||||||
|
),
|
||||||
|
],
|
||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -401,11 +507,22 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
icon: Icons.router,
|
icon: Icons.router,
|
||||||
trailing: _buildSortMenu(context, ContactSection.repeaters),
|
trailing: _buildSortMenu(context, ContactSection.repeaters),
|
||||||
),
|
),
|
||||||
_buildSectionFilterField(context, ContactSection.repeaters),
|
_buildSectionFilterField(
|
||||||
|
context,
|
||||||
|
ContactSection.repeaters,
|
||||||
|
contactsProvider,
|
||||||
|
),
|
||||||
if (repeaters.isEmpty)
|
if (repeaters.isEmpty)
|
||||||
_buildEmptyFilterState(context)
|
_buildEmptyFilterState(context)
|
||||||
else
|
else ...[
|
||||||
..._buildContactSectionItems(repeaters),
|
..._buildSavedGroupCards(
|
||||||
|
savedRepeaterGroups,
|
||||||
|
ContactSection.repeaters,
|
||||||
|
),
|
||||||
|
..._buildContactSectionItems(
|
||||||
|
_excludeGroupedContacts(repeaters, savedRepeaterGroups),
|
||||||
|
),
|
||||||
|
],
|
||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -417,11 +534,22 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
icon: Icons.tag,
|
icon: Icons.tag,
|
||||||
trailing: _buildSortMenu(context, ContactSection.rooms),
|
trailing: _buildSortMenu(context, ContactSection.rooms),
|
||||||
),
|
),
|
||||||
_buildSectionFilterField(context, ContactSection.rooms),
|
_buildSectionFilterField(
|
||||||
|
context,
|
||||||
|
ContactSection.rooms,
|
||||||
|
contactsProvider,
|
||||||
|
),
|
||||||
if (rooms.isEmpty)
|
if (rooms.isEmpty)
|
||||||
_buildEmptyFilterState(context)
|
_buildEmptyFilterState(context)
|
||||||
else
|
else ...[
|
||||||
..._buildContactSectionItems(rooms),
|
..._buildSavedGroupCards(
|
||||||
|
savedRoomGroups,
|
||||||
|
ContactSection.rooms,
|
||||||
|
),
|
||||||
|
..._buildContactSectionItems(
|
||||||
|
_excludeGroupedContacts(rooms, savedRoomGroups),
|
||||||
|
),
|
||||||
|
],
|
||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -452,11 +580,22 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
count: filteredChannels.length,
|
count: filteredChannels.length,
|
||||||
icon: Icons.broadcast_on_personal,
|
icon: Icons.broadcast_on_personal,
|
||||||
),
|
),
|
||||||
_buildSectionFilterField(context, ContactSection.channels),
|
_buildSectionFilterField(
|
||||||
|
context,
|
||||||
|
ContactSection.channels,
|
||||||
|
contactsProvider,
|
||||||
|
),
|
||||||
if (allChannels.isNotEmpty && filteredChannels.isEmpty)
|
if (allChannels.isNotEmpty && filteredChannels.isEmpty)
|
||||||
_buildEmptyFilterState(context),
|
_buildEmptyFilterState(context),
|
||||||
|
..._buildSavedGroupCards(
|
||||||
|
savedChannelGroups,
|
||||||
|
ContactSection.channels,
|
||||||
|
),
|
||||||
if (filteredChannels.isNotEmpty) ...[
|
if (filteredChannels.isNotEmpty) ...[
|
||||||
...filteredChannels.map(
|
..._excludeGroupedContacts(
|
||||||
|
filteredChannels,
|
||||||
|
savedChannelGroups,
|
||||||
|
).map(
|
||||||
(channel) => _ChannelActivityCard(
|
(channel) => _ChannelActivityCard(
|
||||||
channel: channel,
|
channel: channel,
|
||||||
messagesProvider: messagesProvider,
|
messagesProvider: messagesProvider,
|
||||||
@@ -520,14 +659,58 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<Contact> _excludeGroupedContacts(
|
||||||
|
List<Contact> 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<Widget> _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<ContactsProvider>()
|
||||||
|
.removeSavedGroupById(group.group.id),
|
||||||
|
kindLabel: 'Saved filter',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildSectionFilterField(
|
Widget _buildSectionFilterField(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
ContactSection section,
|
ContactSection section,
|
||||||
|
ContactsProvider contactsProvider,
|
||||||
) {
|
) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final colorScheme = theme.colorScheme;
|
final colorScheme = theme.colorScheme;
|
||||||
final controller = _filterControllers[section]!;
|
final controller = _filterControllers[section]!;
|
||||||
final hasFilter = (_sectionFilters[section] ?? '').isNotEmpty;
|
final hasFilter = (_sectionFilters[section] ?? '').isNotEmpty;
|
||||||
|
final isSavedFilter = hasFilter
|
||||||
|
? contactsProvider.hasSavedGroupForFilter(
|
||||||
|
section.name,
|
||||||
|
_sectionFilters[section] ?? '',
|
||||||
|
)
|
||||||
|
: false;
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
@@ -600,7 +783,38 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
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(
|
||||||
padding: const EdgeInsets.only(right: 6),
|
padding: const EdgeInsets.only(right: 6),
|
||||||
child: Material(
|
child: Material(
|
||||||
@@ -624,8 +838,8 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
else
|
] else
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -751,6 +965,13 @@ class _PendingAdvertTile extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _RenderedSavedGroup {
|
||||||
|
final SavedContactGroup group;
|
||||||
|
final List<Contact> contacts;
|
||||||
|
|
||||||
|
const _RenderedSavedGroup({required this.group, required this.contacts});
|
||||||
|
}
|
||||||
|
|
||||||
class _SectionHeader extends StatelessWidget {
|
class _SectionHeader extends StatelessWidget {
|
||||||
final String title;
|
final String title;
|
||||||
final int count;
|
final int count;
|
||||||
@@ -800,20 +1021,24 @@ class _SectionHeader extends StatelessWidget {
|
|||||||
class _InferredContactGroupCard extends StatelessWidget {
|
class _InferredContactGroupCard extends StatelessWidget {
|
||||||
final String label;
|
final String label;
|
||||||
final List<Contact> contacts;
|
final List<Contact> contacts;
|
||||||
|
final String? kindLabel;
|
||||||
final Position? currentPosition;
|
final Position? currentPosition;
|
||||||
final double Function(double, double, double, double) calculateDistance;
|
final double Function(double, double, double, double) calculateDistance;
|
||||||
final String Function(double) formatDistance;
|
final String Function(double) formatDistance;
|
||||||
final VoidCallback? onNavigateToMap;
|
final VoidCallback? onNavigateToMap;
|
||||||
final VoidCallback? onNavigateToMessages;
|
final VoidCallback? onNavigateToMessages;
|
||||||
|
final VoidCallback? onDelete;
|
||||||
|
|
||||||
const _InferredContactGroupCard({
|
const _InferredContactGroupCard({
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.contacts,
|
required this.contacts,
|
||||||
|
this.kindLabel,
|
||||||
required this.currentPosition,
|
required this.currentPosition,
|
||||||
required this.calculateDistance,
|
required this.calculateDistance,
|
||||||
required this.formatDistance,
|
required this.formatDistance,
|
||||||
required this.onNavigateToMap,
|
required this.onNavigateToMap,
|
||||||
required this.onNavigateToMessages,
|
required this.onNavigateToMessages,
|
||||||
|
this.onDelete,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -843,11 +1068,24 @@ class _InferredContactGroupCard extends StatelessWidget {
|
|||||||
title: Row(
|
title: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Column(
|
||||||
label,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
style: Theme.of(
|
children: [
|
||||||
context,
|
Text(
|
||||||
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w800),
|
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),
|
const SizedBox(width: 8),
|
||||||
@@ -862,6 +1100,18 @@ class _InferredContactGroupCard extends StatelessWidget {
|
|||||||
style: Theme.of(context).textTheme.labelSmall,
|
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: [
|
children: [
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import 'repeaters_map_screen.dart';
|
|||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
import 'device_config_screen.dart';
|
import 'device_config_screen.dart';
|
||||||
import 'packet_log_screen.dart';
|
import 'packet_log_screen.dart';
|
||||||
|
import 'live_traffic_screen.dart';
|
||||||
import 'spectrum_scan_screen.dart';
|
import 'spectrum_scan_screen.dart';
|
||||||
import '../utils/toast_logger.dart';
|
import '../utils/toast_logger.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
@@ -218,6 +219,10 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _openLiveTraffic(ConnectionProvider provider) {
|
||||||
|
openLiveTrafficScreen(context, provider);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
_lifecycleState = state;
|
_lifecycleState = state;
|
||||||
@@ -647,6 +652,41 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<ConnectionProvider>();
|
||||||
|
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(
|
items.add(
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
child: const Row(
|
child: const Row(
|
||||||
@@ -1056,6 +1096,7 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
SizedBox(width: isTight ? 8 : 12),
|
SizedBox(width: isTight ? 8 : 12),
|
||||||
if (_showRxTxIndicators)
|
if (_showRxTxIndicators)
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
|
onTap: () => _openLiveTraffic(provider),
|
||||||
onLongPress: () {
|
onLongPress: () {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
|
|||||||
1013
lib/screens/live_traffic_screen.dart
Normal file
1013
lib/screens/live_traffic_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,12 +2,14 @@ import 'dart:convert';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
|
import '../models/contact_group.dart';
|
||||||
import '../utils/key_comparison.dart';
|
import '../utils/key_comparison.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
/// Service for persisting contacts to local storage
|
/// Service for persisting contacts to local storage
|
||||||
class ContactStorageService {
|
class ContactStorageService {
|
||||||
static const String _contactsKey = 'stored_contacts';
|
static const String _contactsKey = 'stored_contacts';
|
||||||
|
static const String _contactGroupsKey = 'stored_contact_groups';
|
||||||
static const int _maxStoredContacts = 500; // Store up to 500 contacts
|
static const int _maxStoredContacts = 500; // Store up to 500 contacts
|
||||||
|
|
||||||
/// Save contacts to persistent storage
|
/// Save contacts to persistent storage
|
||||||
@@ -90,6 +92,40 @@ class ContactStorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> saveContactGroups(List<SavedContactGroup> 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<List<SavedContactGroup>> 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<dynamic>;
|
||||||
|
return jsonList
|
||||||
|
.map((json) => _contactGroupFromJson(json as Map<String, dynamic>))
|
||||||
|
.whereType<SavedContactGroup>()
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [ContactStorage] Error loading contact groups: $e');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get storage statistics
|
/// Get storage statistics
|
||||||
Future<Map<String, dynamic>> getStorageStats() async {
|
Future<Map<String, dynamic>> getStorageStats() async {
|
||||||
try {
|
try {
|
||||||
@@ -203,4 +239,31 @@ class ContactStorageService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _contactGroupToJson(SavedContactGroup group) {
|
||||||
|
return {
|
||||||
|
'id': group.id,
|
||||||
|
'sectionKey': group.sectionKey,
|
||||||
|
'label': group.label,
|
||||||
|
'query': group.query,
|
||||||
|
'createdAtMillis': group.createdAt.millisecondsSinceEpoch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
SavedContactGroup? _contactGroupFromJson(Map<String, dynamic> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
246
lib/services/live_traffic_summary.dart
Normal file
246
lib/services/live_traffic_summary.dart
Normal file
@@ -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<LiveTrafficEntry> 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<BlePacketLog> 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 = <LiveTrafficEntry>[];
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,6 +54,7 @@ class PathHistoryService {
|
|||||||
pathBytes: contact.routePathBytes.toList(),
|
pathBytes: contact.routePathBytes.toList(),
|
||||||
hopCount: contact.routeHopCount,
|
hopCount: contact.routeHopCount,
|
||||||
hashSize: contact.routeHashSize,
|
hashSize: contact.routeHashSize,
|
||||||
|
source: existing?.source ?? PathRecordSource.learned,
|
||||||
successCount: existing?.successCount ?? 0,
|
successCount: existing?.successCount ?? 0,
|
||||||
failureCount: existing?.failureCount ?? 0,
|
failureCount: existing?.failureCount ?? 0,
|
||||||
lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0,
|
lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0,
|
||||||
@@ -98,6 +99,7 @@ class PathHistoryService {
|
|||||||
pathBytes: normalizedPathBytes,
|
pathBytes: normalizedPathBytes,
|
||||||
hopCount: normalizedPathBytes.length ~/ hashSize,
|
hopCount: normalizedPathBytes.length ~/ hashSize,
|
||||||
hashSize: hashSize,
|
hashSize: hashSize,
|
||||||
|
source: PathRecordSource.observed,
|
||||||
successCount: existing?.successCount ?? 0,
|
successCount: existing?.successCount ?? 0,
|
||||||
failureCount: existing?.failureCount ?? 0,
|
failureCount: existing?.failureCount ?? 0,
|
||||||
lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0,
|
lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0,
|
||||||
@@ -197,6 +199,7 @@ class PathHistoryService {
|
|||||||
pathBytes: selection.pathBytes.toList(),
|
pathBytes: selection.pathBytes.toList(),
|
||||||
hopCount: selection.hopCount,
|
hopCount: selection.hopCount,
|
||||||
hashSize: selection.hashSize,
|
hashSize: selection.hashSize,
|
||||||
|
source: existing?.source ?? PathRecordSource.learned,
|
||||||
successCount: (existing?.successCount ?? 0) + (success ? 1 : 0),
|
successCount: (existing?.successCount ?? 0) + (success ? 1 : 0),
|
||||||
failureCount: (existing?.failureCount ?? 0) + (success ? 0 : 1),
|
failureCount: (existing?.failureCount ?? 0) + (success ? 0 : 1),
|
||||||
lastRoundTripTimeMs: success
|
lastRoundTripTimeMs: success
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ class ResolvedNodeHash {
|
|||||||
final bool isOwnNode;
|
final bool isOwnNode;
|
||||||
final bool isUniqueMatch;
|
final bool isUniqueMatch;
|
||||||
final int matchCount;
|
final int matchCount;
|
||||||
|
final double? latitude;
|
||||||
|
final double? longitude;
|
||||||
|
|
||||||
const ResolvedNodeHash({
|
const ResolvedNodeHash({
|
||||||
required this.hashHex,
|
required this.hashHex,
|
||||||
@@ -37,6 +39,8 @@ class ResolvedNodeHash {
|
|||||||
required this.isOwnNode,
|
required this.isOwnNode,
|
||||||
required this.isUniqueMatch,
|
required this.isUniqueMatch,
|
||||||
required this.matchCount,
|
required this.matchCount,
|
||||||
|
this.latitude,
|
||||||
|
this.longitude,
|
||||||
});
|
});
|
||||||
|
|
||||||
String get hexLabel => '0x${hashHex.toUpperCase()}';
|
String get hexLabel => '0x${hashHex.toUpperCase()}';
|
||||||
@@ -179,6 +183,8 @@ class LogRxRouteDecoder {
|
|||||||
required Iterable<Contact> contacts,
|
required Iterable<Contact> contacts,
|
||||||
Uint8List? ownPublicKey,
|
Uint8List? ownPublicKey,
|
||||||
String? ownName,
|
String? ownName,
|
||||||
|
double? ownLatitude,
|
||||||
|
double? ownLongitude,
|
||||||
}) {
|
}) {
|
||||||
final normalizedHashHex = hashHex.toLowerCase();
|
final normalizedHashHex = hashHex.toLowerCase();
|
||||||
final ownKeyHex = _bytesToHex(ownPublicKey);
|
final ownKeyHex = _bytesToHex(ownPublicKey);
|
||||||
@@ -192,6 +198,8 @@ class LogRxRouteDecoder {
|
|||||||
isOwnNode: true,
|
isOwnNode: true,
|
||||||
isUniqueMatch: true,
|
isUniqueMatch: true,
|
||||||
matchCount: 1,
|
matchCount: 1,
|
||||||
|
latitude: ownLatitude,
|
||||||
|
longitude: ownLongitude,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,12 +218,15 @@ class LogRxRouteDecoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (matches.length == 1) {
|
if (matches.length == 1) {
|
||||||
|
final location = matches.first.displayLocation;
|
||||||
return ResolvedNodeHash(
|
return ResolvedNodeHash(
|
||||||
hashHex: normalizedHashHex,
|
hashHex: normalizedHashHex,
|
||||||
label: matches.first.displayName,
|
label: matches.first.displayName,
|
||||||
isOwnNode: false,
|
isOwnNode: false,
|
||||||
isUniqueMatch: true,
|
isUniqueMatch: true,
|
||||||
matchCount: 1,
|
matchCount: 1,
|
||||||
|
latitude: location?.latitude,
|
||||||
|
longitude: location?.longitude,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -357,13 +357,62 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
final lastSeen = MaterialLocalizations.of(
|
final lastSeen = MaterialLocalizations.of(
|
||||||
context,
|
context,
|
||||||
).formatShortDate(record.lastUsedAt);
|
).formatShortDate(record.lastUsedAt);
|
||||||
|
final sourceLabel = switch (record.source) {
|
||||||
|
PathRecordSource.observed => 'Observed on mesh',
|
||||||
|
PathRecordSource.learned => 'Learned route',
|
||||||
|
};
|
||||||
final successRate = attempts == 0
|
final successRate = attempts == 0
|
||||||
? 'No send stats yet'
|
? 'No send stats yet'
|
||||||
: '${(record.successRate * 100).round()}% success over $attempts send${attempts == 1 ? '' : 's'}';
|
: '${(record.successRate * 100).round()}% success over $attempts send${attempts == 1 ? '' : 's'}';
|
||||||
final latency = record.lastRoundTripTimeMs > 0
|
final latency = record.lastRoundTripTimeMs > 0
|
||||||
? ' • ${record.lastRoundTripTimeMs} ms'
|
? ' • ${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() {
|
Widget _buildPreviewSection() {
|
||||||
@@ -586,41 +635,44 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ListView.separated(
|
PathRecord? observedRecord;
|
||||||
itemCount: records.length,
|
for (final record in records) {
|
||||||
shrinkWrap: true,
|
if (record.source == PathRecordSource.observed) {
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
observedRecord = record;
|
||||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
break;
|
||||||
itemBuilder: (context, index) {
|
}
|
||||||
final record = records[index];
|
}
|
||||||
final canonicalText = _canonicalRouteFromBytes(
|
final remainingRecords = observedRecord == null
|
||||||
record.pathBytes,
|
? records
|
||||||
hashSize: record.hashSize,
|
: records
|
||||||
);
|
.where((record) => !identical(record, observedRecord))
|
||||||
return Card(
|
.toList();
|
||||||
margin: EdgeInsets.zero,
|
|
||||||
child: ListTile(
|
return Column(
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
horizontal: 16,
|
children: [
|
||||||
vertical: 10,
|
if (observedRecord != null) ...[
|
||||||
),
|
_buildHistoryRecordTile(observedRecord, title: 'Observed mesh route'),
|
||||||
title: Text(
|
const SizedBox(height: 16),
|
||||||
canonicalText,
|
],
|
||||||
style: Theme.of(
|
if (remainingRecords.isEmpty)
|
||||||
context,
|
Text(
|
||||||
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
|
observedRecord == null
|
||||||
),
|
? 'No additional route history yet.'
|
||||||
subtitle: Padding(
|
: 'Observed routes you start using will continue to build history here.',
|
||||||
padding: const EdgeInsets.only(top: 6),
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
child: Text(_historySubtitle(record)),
|
)
|
||||||
),
|
else
|
||||||
trailing: FilledButton.tonal(
|
ListView.separated(
|
||||||
onPressed: () => _applyHistoryRecord(record),
|
itemCount: remainingRecords.length,
|
||||||
child: const Text('Use'),
|
shrinkWrap: true,
|
||||||
),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return _buildHistoryRecordTile(remainingRecords[index]);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
],
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -699,4 +699,39 @@ void main() {
|
|||||||
expect(after.advLon, equals(before.advLon));
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
179
test/screens/live_traffic_screen_test.dart
Normal file
179
test/screens/live_traffic_screen_test.dart
Normal file
@@ -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<int> 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<int> _multiHopRaw({
|
||||||
|
required List<int> 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 = <BlePacketLog>[];
|
||||||
|
final refresh = ValueNotifier<int>(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 = <BlePacketLog>[];
|
||||||
|
final refresh = ValueNotifier<int>(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 = <BlePacketLog>[];
|
||||||
|
final refresh = ValueNotifier<int>(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);
|
||||||
|
});
|
||||||
|
}
|
||||||
147
test/services/live_traffic_summary_test.dart
Normal file
147
test/services/live_traffic_summary_test.dart
Normal file
@@ -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<int> 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<int> _multiHopRaw({
|
||||||
|
required List<int> 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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import 'package:meshcore_sar_app/models/contact.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/models/path_selection.dart';
|
||||||
import 'package:meshcore_sar_app/services/path_history_service.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.pathBytes, [0x03, 0x04, 0x01, 0x02]);
|
||||||
expect(history.directPaths.single.hashSize, 2);
|
expect(history.directPaths.single.hashSize, 2);
|
||||||
expect(history.directPaths.single.hopCount, 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);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user