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
Strycher 3 days ago
parent f1930549ed
commit 746e102330

@ -28,6 +28,7 @@ import '../widgets/list_filter_widget.dart';
import '../widgets/empty_state.dart';
import '../widgets/blocked_badge.dart';
import '../widgets/app_shell.dart';
import '../widgets/contact_filter_rail.dart';
import '../widgets/path_selection_dialog.dart';
import '../widgets/repeater_login_dialog.dart';
import '../widgets/room_login_dialog.dart';
@ -324,6 +325,7 @@ class _ContactsScreenState extends State<ContactsScreen>
onDestinationSelected: (index) => _handleQuickSwitch(index, context),
contactsUnreadCount: connector.getTotalContactsUnreadCount(),
channelsUnreadCount: connector.getTotalChannelsUnreadCount(),
drawerContent: const ContactFilterRail(),
appBar: AppBar(
title: AppBarTitle(context.l10n.contacts_title),
bottom: const SyncProgressAppBarBottom(),

@ -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…
Cancel
Save

Powered by TurnKey Linux.