feat(#307): prebuilt filter rail in the Contacts nav panel
The nav panel was empty on the Contacts view. It now carries the prebuilt filters: All, Favorites, Users, Repeaters, Room Servers, Sensors, with Unread Only as a toggle beneath them. - Unread Only stays a toggle rather than a row, so it composes with the type filter (Users + Unread Only = unread DMs), per the decision on #307. - typeSupportsUnread() is the single predicate behind that: it currently reduces to "not repeaters", since the connector refuses to track unread for repeaters (meshcore_connector.dart:743, :6095) and the pair could only ever be empty. The toggle is disabled there rather than silently useless. - Each row shows how many contacts the filter would actually return, with Unread Only applied when it is on, so the number never disagrees with the list after tapping. Rendered as muted text rather than UnreadBadge, since it is a total and not an unread count. - Selecting a filter closes an unpinned drawer, matching the channel panel. Selection persists through UiViewStateService, which already stored both the type filter and the unread flag, so no new storage was needed. flutter analyze clean, dart format clean, 445 tests pass. Not yet run on hardware.pull/324/head
parent
f1930549ed
commit
746e102330
@ -0,0 +1,177 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../connector/meshcore_connector.dart';
|
||||||
|
import '../connector/meshcore_protocol.dart';
|
||||||
|
import '../l10n/l10n.dart';
|
||||||
|
import '../models/contact.dart';
|
||||||
|
import '../services/ui_view_state_service.dart';
|
||||||
|
import '../utils/contact_filter_types.dart';
|
||||||
|
|
||||||
|
/// Repeaters never accumulate unread: [MeshCoreConnector] refuses to track it
|
||||||
|
/// for them, so pairing Unread Only with Repeaters could only ever produce an
|
||||||
|
/// empty list. Every other type can, so the toggle stays available there.
|
||||||
|
bool typeSupportsUnread(ContactTypeFilter filter) =>
|
||||||
|
filter != ContactTypeFilter.repeaters;
|
||||||
|
|
||||||
|
/// Prebuilt contact filters for the nav panel, replacing an empty rail on the
|
||||||
|
/// Contacts view.
|
||||||
|
///
|
||||||
|
/// Unread Only is a toggle rather than a row of its own, because it composes
|
||||||
|
/// with the type filter: Users plus Unread Only means unread DMs.
|
||||||
|
class ContactFilterRail extends StatelessWidget {
|
||||||
|
const ContactFilterRail({super.key});
|
||||||
|
|
||||||
|
static const _entries = <ContactTypeFilter>[
|
||||||
|
ContactTypeFilter.all,
|
||||||
|
ContactTypeFilter.favorites,
|
||||||
|
ContactTypeFilter.users,
|
||||||
|
ContactTypeFilter.repeaters,
|
||||||
|
ContactTypeFilter.rooms,
|
||||||
|
ContactTypeFilter.sensors,
|
||||||
|
];
|
||||||
|
|
||||||
|
String _label(BuildContext context, ContactTypeFilter filter) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
switch (filter) {
|
||||||
|
case ContactTypeFilter.all:
|
||||||
|
return l10n.listFilter_all;
|
||||||
|
case ContactTypeFilter.favorites:
|
||||||
|
return l10n.listFilter_favorites;
|
||||||
|
case ContactTypeFilter.users:
|
||||||
|
return l10n.listFilter_users;
|
||||||
|
case ContactTypeFilter.repeaters:
|
||||||
|
return l10n.listFilter_repeaters;
|
||||||
|
case ContactTypeFilter.rooms:
|
||||||
|
return l10n.listFilter_roomServers;
|
||||||
|
case ContactTypeFilter.sensors:
|
||||||
|
return l10n.listFilter_sensors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _icon(ContactTypeFilter filter) {
|
||||||
|
switch (filter) {
|
||||||
|
case ContactTypeFilter.all:
|
||||||
|
return Icons.people_outline;
|
||||||
|
case ContactTypeFilter.favorites:
|
||||||
|
return Icons.star_outline;
|
||||||
|
case ContactTypeFilter.users:
|
||||||
|
return Icons.person_outline;
|
||||||
|
case ContactTypeFilter.repeaters:
|
||||||
|
return Icons.cell_tower;
|
||||||
|
case ContactTypeFilter.rooms:
|
||||||
|
return Icons.meeting_room_outlined;
|
||||||
|
case ContactTypeFilter.sensors:
|
||||||
|
return Icons.sensors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _matches(Contact contact, ContactTypeFilter filter) {
|
||||||
|
switch (filter) {
|
||||||
|
case ContactTypeFilter.all:
|
||||||
|
return true;
|
||||||
|
case ContactTypeFilter.favorites:
|
||||||
|
return contact.isFavorite;
|
||||||
|
case ContactTypeFilter.users:
|
||||||
|
return contact.type == advTypeChat;
|
||||||
|
case ContactTypeFilter.repeaters:
|
||||||
|
return contact.type == advTypeRepeater;
|
||||||
|
case ContactTypeFilter.rooms:
|
||||||
|
return contact.type == advTypeRoom;
|
||||||
|
case ContactTypeFilter.sensors:
|
||||||
|
return contact.type == advTypeSensor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final connector = context.watch<MeshCoreConnector>();
|
||||||
|
final viewState = context.watch<UiViewStateService>();
|
||||||
|
final selected = viewState.contactsTypeFilter;
|
||||||
|
final unreadOnly = viewState.contactsShowUnreadOnly;
|
||||||
|
final contacts = connector.contacts;
|
||||||
|
|
||||||
|
return ListView(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
children: [
|
||||||
|
for (final filter in _entries)
|
||||||
|
_RailTile(
|
||||||
|
icon: _icon(filter),
|
||||||
|
label: _label(context, filter),
|
||||||
|
selected: filter == selected,
|
||||||
|
// Count what the user would actually land on, so the number never
|
||||||
|
// disagrees with the list after tapping.
|
||||||
|
count: contacts.where((c) {
|
||||||
|
if (!_matches(c, filter)) return false;
|
||||||
|
if (unreadOnly && typeSupportsUnread(filter)) {
|
||||||
|
return connector.getUnreadCountForContact(c) > 0;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}).length,
|
||||||
|
onTap: () {
|
||||||
|
final scaffold = Scaffold.maybeOf(context);
|
||||||
|
if (scaffold?.isDrawerOpen ?? false) {
|
||||||
|
scaffold!.closeDrawer();
|
||||||
|
}
|
||||||
|
viewState.setContactsTypeFilter(filter);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
SwitchListTile(
|
||||||
|
dense: true,
|
||||||
|
secondary: const Icon(Icons.mark_email_unread_outlined),
|
||||||
|
title: Text(context.l10n.listFilter_unreadOnly),
|
||||||
|
value: unreadOnly,
|
||||||
|
// Disabled on Repeaters, where it could only ever yield nothing.
|
||||||
|
onChanged: typeSupportsUnread(selected)
|
||||||
|
? viewState.setContactsShowUnreadOnly
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RailTile extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final bool selected;
|
||||||
|
final int count;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _RailTile({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.selected,
|
||||||
|
required this.count,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
selected: selected,
|
||||||
|
selectedTileColor: theme.colorScheme.secondaryContainer,
|
||||||
|
leading: Icon(icon, size: 20),
|
||||||
|
title: Text(
|
||||||
|
label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Deliberately not UnreadBadge: this is a total for the filter, not an
|
||||||
|
// unread count, and the badge styling would imply unread.
|
||||||
|
trailing: Text(
|
||||||
|
'$count',
|
||||||
|
style: theme.textTheme.labelMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onTap: onTap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in new issue