Add packet filter stats and hex

This commit is contained in:
Janez T
2026-03-12 20:54:04 +01:00
parent c36d63a0ad
commit 53daf2c9a7
15 changed files with 2265 additions and 56 deletions

View File

@@ -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<ContactsTab> {
}).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) {
final sorted = List<Contact>.from(contacts);
if (section == ContactSection.channels) {
@@ -319,18 +394,38 @@ class _ContactsTabState extends State<ContactsTab> {
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<ContactsTab> {
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<ContactsTab> {
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<ContactsTab> {
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<ContactsTab> {
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<ContactsTab> {
}).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(
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<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: const EdgeInsets.only(right: 6),
child: Material(
@@ -624,8 +838,8 @@ class _ContactsTabState extends State<ContactsTab> {
),
),
),
)
else
),
] else
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 {
final String title;
final int count;
@@ -800,20 +1021,24 @@ class _SectionHeader extends StatelessWidget {
class _InferredContactGroupCard extends StatelessWidget {
final String label;
final List<Contact> 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: [

View File

@@ -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<HomeScreen>
);
}
void _openLiveTraffic(ConnectionProvider provider) {
openLiveTrafficScreen(context, provider);
}
@override
void didChangeAppLifecycleState(AppLifecycleState 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(
PopupMenuItem(
child: const Row(
@@ -1056,6 +1096,7 @@ class _HomeScreenState extends State<HomeScreen>
SizedBox(width: isTight ? 8 : 12),
if (_showRxTxIndicators)
GestureDetector(
onTap: () => _openLiveTraffic(provider),
onLongPress: () {
Navigator.push(
context,

File diff suppressed because it is too large Load Diff