From d9b2f7bb8c0a012365f78cf27cb3af259b6aa860 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 19 Oct 2026 15:03:03 -0700 Subject: [PATCH] feat(mobile): consolidate section view controls in one sheet Keep sort, filter and Active duration as local drafts until Apply. Expand filtered channels in the section list and retain read-only navigation and failed drafts. Co-authored-by: Carl Signed-off-by: Taylor Ho --- .../lib/features/channels/channels_page.dart | 2 - .../features/channels/channels_page/body.dart | 44 -- .../channels/channels_page/filters.dart | 530 ++++++++---------- .../channels/channels_page/sections.dart | 220 ++------ .../features/channels/channels_page_test.dart | 152 ++--- 5 files changed, 382 insertions(+), 566 deletions(-) diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index c70518b958..3c23b91071 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -1,5 +1,4 @@ import 'channel_unread.dart'; -import 'channel_filter/channel_filter_manager.dart'; import 'channel_filter/channel_filter_activity.dart'; import 'channel_filter/channel_filter_provider.dart'; import 'channel_filter/channel_filter_store.dart'; @@ -30,7 +29,6 @@ import '../../shared/widgets/app_list_card.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; -import '../../shared/widgets/anchored_popover_menu.dart'; import '../../shared/widgets/bee_refresh_indicator.dart'; import '../../shared/widgets/bottom_up_page_route.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; diff --git a/mobile/lib/features/channels/channels_page/body.dart b/mobile/lib/features/channels/channels_page/body.dart index 2039cd21a6..51941109c7 100644 --- a/mobile/lib/features/channels/channels_page/body.dart +++ b/mobile/lib/features/channels/channels_page/body.dart @@ -310,25 +310,6 @@ class _SliverChannelsList extends HookConsumerWidget { ); } - void showFilter(String group, List roster, VoidCallback expand) { - unawaited( - showBuzzModalBottomSheet( - context: context, - showDragHandle: true, - builder: (_) => _SectionFilterSheet( - group: group, - channels: roster, - currentPubkey: currentPubkey, - selectedChannelId: selectedChannel.value, - onOpen: (channel) { - expand(); - return openFilteredChannel(channel); - }, - ), - ), - ); - } - List customRoster(String id) => sortChannelsForList( streamChannels .where( @@ -375,11 +356,6 @@ class _SliverChannelsList extends HookConsumerWidget { onToggle: () => starredExpanded.value = !starredExpanded.value, channels: starredStreamChannels, isVisible: (c) => visible('starred', c), - onFilter: () => showFilter( - 'starred', - starredStreamChannels, - () => starredExpanded.value = true, - ), unreadChannelIds: unreadChannelIds, mutedChannelIds: mutedChannelIds, currentPubkey: currentPubkey, @@ -394,16 +370,6 @@ class _SliverChannelsList extends HookConsumerWidget { section: section, channels: customRoster(section.id), isVisible: (c) => visible(sectionSortGroupKey(section.id), c), - onFilter: () => showFilter( - sectionSortGroupKey(section.id), - customRoster(section.id), - () { - sectionExpandedStates.value = { - ...sectionExpandedStates.value, - section.id: true, - }; - }, - ), unreadChannelIds: unreadChannelIds, mutedChannelIds: mutedChannelIds, currentPubkey: currentPubkey, @@ -516,11 +482,6 @@ class _SliverChannelsList extends HookConsumerWidget { onToggle: () => channelsExpanded.value = !channelsExpanded.value, channels: ungroupedStreamChannels, isVisible: (c) => visible('channels', c), - onFilter: () => showFilter( - 'channels', - ungroupedStreamChannels, - () => channelsExpanded.value = true, - ), unreadChannelIds: unreadChannelIds, mutedChannelIds: mutedChannelIds, currentPubkey: currentPubkey, @@ -536,11 +497,6 @@ class _SliverChannelsList extends HookConsumerWidget { onToggle: () => dmsExpanded.value = !dmsExpanded.value, channels: sortedDmChannels, isVisible: (c) => visible('dms', c), - onFilter: () => showFilter( - 'dms', - sortedDmChannels, - () => dmsExpanded.value = true, - ), unreadChannelIds: unreadChannelIds, mutedChannelIds: mutedChannelIds, currentPubkey: currentPubkey, diff --git a/mobile/lib/features/channels/channels_page/filters.dart b/mobile/lib/features/channels/channels_page/filters.dart index 9ec1b28ab0..dd9b4e9278 100644 --- a/mobile/lib/features/channels/channels_page/filters.dart +++ b/mobile/lib/features/channels/channels_page/filters.dart @@ -1,325 +1,291 @@ part of '../channels_page.dart'; -/// A sheet gives hidden channel rows the same touch targets and presentation -/// as the main list, including unread, mute and working indicators. -class _SectionFilterSheet extends HookConsumerWidget { +Future _showSectionViewSheet( + BuildContext context, { + required String title, + required String group, + required ChannelSortMode sortMode, + required ValueChanged onSort, + bool custom = false, + bool isFirst = false, + bool isLast = false, +}) => showBuzzModalBottomSheet( + context: context, + showCloseButton: false, + showDragHandle: false, + isScrollControlled: true, + isDismissible: false, + enableDrag: false, + builder: (_) => _SectionViewSheet( + title: title, + group: group, + initialSort: sortMode, + onSort: onSort, + custom: custom, + isFirst: isFirst, + isLast: isLast, + ), +); + +class _SectionViewSheet extends HookConsumerWidget { + final String title; final String group; - final List channels; - final String? currentPubkey; - final String? selectedChannelId; - final Future Function(Channel) onOpen; - const _SectionFilterSheet({ + final ChannelSortMode initialSort; + final ValueChanged onSort; + final bool custom; + final bool isFirst; + final bool isLast; + + const _SectionViewSheet({ + required this.title, required this.group, - required this.channels, - required this.currentPubkey, - this.selectedChannelId, - required this.onOpen, + required this.initialSort, + required this.onSort, + required this.custom, + required this.isFirst, + required this.isLast, }); + @override Widget build(BuildContext context, WidgetRef ref) { final manager = ref.watch(channelFilterProvider); useListenable(manager); - final readState = ref.watch(readStateProvider); - final latestChannels = ref.watch(channelsProvider).value ?? channels; - final sections = ref.watch(channelSectionsProvider).store; - final stars = ref.watch(channelStarsProvider).store.channels; - final personalIds = - ref.watch(meGroupsProvider).asData?.value.channels ?? const []; - final roster = latestChannels.where((c) { - if (personalIds.contains(c.id)) return false; - if (!c.isMember || c.isArchived || (!c.isDm && !c.isStream)) return false; - final currentGroup = c.isDm - ? 'dms' - : stars[c.id]?.starred == true - ? 'starred' - : sections.sections.any((s) => s.id == sections.assignments[c.id]) - ? sectionSortGroupKey(sections.assignments[c.id]!) - : 'channels'; - return currentGroup == group; - }).toList(); - final names = ref.watch(identityNameSourcesProvider); - final sortMode = ref.watch(channelSortProvider).sortModeFor(group); - final ordered = group == 'dms' && sortMode == ChannelSortMode.alpha - ? sortDmChannelsByDisplayLabel( - roster, - currentPubkey: currentPubkey, - names: names, - ) - : sortChannelsForList(roster, sortMode); - final liveUnread = computeUnreadChannelIds( - channels: roster, - readState: readState, - channelsNotifier: ref.read(channelsProvider.notifier), + final originalManager = useRef(manager); + final sort = useState(initialSort); + final mode = useState( + manager?.store.modeFor(group) ?? ChannelFilterMode.all, ); - final liveMutes = ref.watch(channelMutesProvider).store.channels; - final clock = useState(0); - final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - final hidden = []; - var nextExpiry = 0; - for (final channel in ordered) { - final mode = manager?.store.modeFor(group) ?? ChannelFilterMode.all; - final evidence = mode == ChannelFilterMode.active - ? ref.watch(channelFilterActivityProvider(channel.id)) - : null; - final at = evidence?.asData?.value; - final window = - manager?.store.windowFor(group) ?? defaultActiveWindowSeconds; - if (at != null && - at + window > now && - (nextExpiry == 0 || at + window < nextExpiry)) { - nextExpiry = at + window; - } - if (!channelMatchesFilter( - mode: mode, - unread: liveUnread.contains(channel.id), - activitySeconds: at, - activityKnown: evidence?.asData != null, - nowSeconds: now, - windowSeconds: window, - selected: channel.id == selectedChannelId, - )) { - hidden.add(channel); - } - } - useEffect(() { - if (nextExpiry == 0) return null; - final timer = Timer( - Duration(seconds: nextExpiry - now), - () => clock.value++, - ); - return timer.cancel; - }, [nextExpiry, clock.value]); - final failed = useState(null); + final days = useState( + (manager?.store.windowFor(group) ?? defaultActiveWindowSeconds) ~/ 86400, + ); + final dirty = useState(false); final saving = useState(false); - Future save(ChannelFilterMode mode) async { - if (manager == null) return; + final failed = useState(false); + // A confirmed initial load may arrive while the sheet is open. Preserve edits. + useEffect(() { + if (!dirty.value && !saving.value) { + mode.value = manager?.store.modeFor(group) ?? ChannelFilterMode.all; + days.value = + (manager?.store.windowFor(group) ?? defaultActiveWindowSeconds) ~/ + 86400; + } + return null; + }, [manager, manager?.ready, manager?.store]); + if (!identical(originalManager.value, manager)) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(Grid.xs), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Preferences changed. Reopen section options to continue.', + ), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ), + ), + ); + } + final writable = + manager?.ready == true && manager?.busy == false && !saving.value; + void close([String? action]) { + if (!saving.value) Navigator.of(context).pop(action); + } + + Future apply() async { saving.value = true; + failed.value = false; try { - await manager.save(group, mode); - if (context.mounted) { - failed.value = null; + final filterChanged = + mode.value != + (manager?.store.modeFor(group) ?? ChannelFilterMode.all) || + (mode.value == ChannelFilterMode.active && + days.value * 86400 != manager?.store.windowFor(group)); + if (filterChanged) { + if (manager == null || !manager.ready || manager.busy) { + throw StateError('Preferences are read-only'); + } + await manager.save( + group, + mode.value, + windowSeconds: mode.value == ChannelFilterMode.active + ? days.value * 86400 + : null, + ); } + if (!context.mounted) return; + if (sort.value != initialSort) onSort(sort.value); + Navigator.of(context).pop(); } catch (_) { - if (context.mounted) failed.value = mode; + if (context.mounted) failed.value = true; } finally { if (context.mounted) saving.value = false; } } - final writable = - manager?.ready == true && manager?.busy == false && !saving.value; - final mode = manager?.store.modeFor(group) ?? ChannelFilterMode.all; - return SafeArea( - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, + Widget choices( + String label, + Map values, + T selected, + ValueChanged? change, + ) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: context.textTheme.titleSmall), + const SizedBox(height: Grid.xxs), + Wrap( + spacing: Grid.xxs, + runSpacing: Grid.xxs, children: [ - const Padding( - padding: EdgeInsets.all(Grid.xs), - child: Text('Filter'), - ), - if (manager?.ready != true && manager?.error == null) - const Padding( - padding: EdgeInsets.all(Grid.xs), - child: Text( - 'Filters are read-only until preferences load from the community.', - ), - ), - if (manager?.error != null && failed.value == null) - ListTile( - title: Text(manager!.error!), - trailing: TextButton( - onPressed: manager.busy ? null : manager.refresh, - child: const Text('Retry'), - ), - ), - for (final choice in ChannelFilterMode.values) - Semantics( - checked: mode == choice, - inMutuallyExclusiveGroup: true, - child: ListTile( - enabled: writable, - leading: Icon( - mode == choice - ? Icons.radio_button_checked - : Icons.radio_button_off, - ), - title: Text(switch (choice) { - ChannelFilterMode.all => 'All', - ChannelFilterMode.unreads => 'Unreads', - ChannelFilterMode.active => 'Active in the last', - }), - subtitle: choice == ChannelFilterMode.active - ? Text( - '${(manager?.store.windowFor(group) ?? defaultActiveWindowSeconds) ~/ 86400} days', - ) - : null, - onTap: !writable - ? null - : () async { - if (choice == ChannelFilterMode.active) { - await showBuzzDialog( - context: context, - barrierDismissible: false, - builder: (_) => _ActiveFilterDialog( - group: group, - manager: manager!, - ), - ); - } else { - await save(choice); - } - }, - ), - ), - if (failed.value != null) - Padding( - padding: const EdgeInsets.all(Grid.xs), - child: Column( - children: [ - const Text('Could not confirm the filter save.'), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: saving.value - ? null - : () { - failed.value = null; - manager?.dismissError(); - }, - child: const Text('Dismiss'), - ), - TextButton( - onPressed: !writable - ? null - : () => save(failed.value!), - child: const Text('Retry'), - ), - ], - ), - ], - ), - ), - if (hidden.isNotEmpty) const Divider(), - for (final channel in hidden) - _ChannelTile( - channel: channel, - isUnread: liveUnread.contains(channel.id), - isMuted: liveMutes[channel.id]?.muted == true, - currentPubkey: currentPubkey, - sectionId: group.startsWith('section:') - ? group.substring(8) - : null, - onMarkRead: null, - onTap: () { - Navigator.of(context).pop(); - unawaited(onOpen(channel)); - }, + for (final entry in values.entries) + ChoiceChip( + label: Text(entry.value), + selected: entry.key == selected, + onSelected: change == null + ? null + : (_) { + dirty.value = true; + change(entry.key); + }, ), ], ), - ), + const SizedBox(height: Grid.xs), + ], ); - } -} - -class _ActiveFilterDialog extends HookWidget { - final String group; - final ChannelFilterManager manager; - const _ActiveFilterDialog({required this.group, required this.manager}); - @override - Widget build(BuildContext context) { - useListenable(manager); - final days = useState(manager.store.windowFor(group) ~/ 86400); - final busy = useState(false); - final failed = useState(false); return PopScope( - canPop: !busy.value, - child: AlertDialog( - title: const Text('Active in the last'), - content: SizedBox( - width: 320, - child: SingleChildScrollView( + canPop: !saving.value, + child: SafeArea( + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.85, + ), + child: Padding( + padding: const EdgeInsets.all(Grid.xs), child: Column( mainAxisSize: MainAxisSize.min, children: [ + Row( + children: [ + Expanded( + child: Text(title, style: context.textTheme.titleLarge), + ), + IconButton( + tooltip: 'Close', + onPressed: saving.value ? null : () => close(), + icon: const Icon(Icons.close), + ), + ], + ), + const SizedBox(height: Grid.xxs), + Flexible( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + choices( + 'Sort', + { + ChannelSortMode.alpha: 'A–Z', + ChannelSortMode.recent: 'Recent', + }, + sort.value, + saving.value ? null : (value) => sort.value = value, + ), + choices( + 'Show', + { + ChannelFilterMode.all: 'All', + ChannelFilterMode.unreads: 'Unreads', + ChannelFilterMode.active: 'Active', + }, + mode.value, + writable ? (value) => mode.value = value : null, + ), + if (mode.value == ChannelFilterMode.active) + choices( + 'Active in the past', + { + for (final day in {1, 7, 30, 90, days.value}) + day: '$day ${day == 1 ? 'day' : 'days'}', + }, + days.value, + writable ? (value) => days.value = value : null, + ), + if (manager?.ready != true) + const Text( + 'Filters are read-only until preferences load from the community.', + ), + if (manager?.error != null && !failed.value) + TextButton( + onPressed: manager?.busy == true + ? null + : manager?.refresh, + child: const Text('Retry loading preferences'), + ), + if (failed.value) + const Text( + 'Could not confirm the filter save. Your choices are kept; try again.', + ), + if (custom) ...[ + const Divider(), + for (final action in { + 'rename': 'Rename section', + 'move_up': 'Move up', + 'move_down': 'Move down', + 'delete': 'Delete section', + }.entries) + ListTile( + contentPadding: EdgeInsets.zero, + title: Text( + action.value, + style: action.key == 'delete' + ? TextStyle(color: context.colors.error) + : null, + ), + enabled: + !saving.value && + !(action.key == 'move_up' && isFirst) && + !(action.key == 'move_down' && isLast), + onTap: () => close(action.key), + ), + ], + ], + ), + ), + ), + const SizedBox(height: Grid.xxs), Wrap( + alignment: WrapAlignment.end, spacing: Grid.xxs, runSpacing: Grid.xxs, children: [ - for (final preset in {1, 7, 30, 90, days.value}) - SizedBox( - width: 140, - child: OutlinedButton( - onPressed: busy.value - ? null - : () => days.value = preset, - child: Semantics( - checked: days.value == preset, - inMutuallyExclusiveGroup: true, - child: Text( - '$preset ${preset == 1 ? 'day' : 'days'}', - style: TextStyle( - fontWeight: days.value == preset - ? FontWeight.bold - : FontWeight.normal, - ), - ), - ), - ), + TextButton( + onPressed: saving.value ? null : () => close(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: saving.value || manager?.busy == true + ? null + : apply, + child: Text( + saving.value + ? 'Saving…' + : failed.value + ? 'Retry' + : 'Apply', ), + ), ], ), - if (failed.value) - const Padding( - padding: EdgeInsets.only(top: Grid.xs), - child: Text( - 'Could not confirm the save. Your selection is retained. Retry or Cancel.', - ), - ), ], ), ), ), - actions: [ - TextButton( - onPressed: busy.value - ? null - : () { - manager.dismissError(); - Navigator.of(context).pop(); - }, - child: const Text('Cancel'), - ), - TextButton( - onPressed: busy.value || !manager.ready - ? null - : () async { - busy.value = true; - failed.value = false; - try { - await manager.save( - group, - ChannelFilterMode.active, - windowSeconds: days.value * 86400, - ); - if (context.mounted) Navigator.of(context).pop(); - } catch (_) { - if (context.mounted) failed.value = true; - } finally { - if (context.mounted) busy.value = false; - } - }, - child: Text( - busy.value - ? 'Saving…' - : failed.value - ? 'Retry' - : 'Apply', - ), - ), - ], ), ); } diff --git a/mobile/lib/features/channels/channels_page/sections.dart b/mobile/lib/features/channels/channels_page/sections.dart index da2453cd92..105ab0083f 100644 --- a/mobile/lib/features/channels/channels_page/sections.dart +++ b/mobile/lib/features/channels/channels_page/sections.dart @@ -1,11 +1,8 @@ part of '../channels_page.dart'; -const _sectionMenuItemPadding = EdgeInsets.fromLTRB(Grid.xs, 0, Grid.twelve, 0); - -class _CustomChannelSection extends StatelessWidget { +class _CustomChannelSection extends HookWidget { final ChannelSection section; final List channels; - final VoidCallback? onFilter; final bool Function(Channel)? isVisible; final Set unreadChannelIds; final Set mutedChannelIds; @@ -27,7 +24,6 @@ class _CustomChannelSection extends StatelessWidget { const _CustomChannelSection({ required this.section, required this.channels, - this.onFilter, this.isVisible, required this.unreadChannelIds, required this.mutedChannelIds, @@ -49,13 +45,21 @@ class _CustomChannelSection extends StatelessWidget { @override Widget build(BuildContext context) { + final reveal = useState(false); + final hiddenIds = channels + .where((c) => !(isVisible?.call(c) ?? true)) + .map((c) => c.id) + .join(','); + useEffect(() { + reveal.value = false; + return null; + }, [hiddenIds]); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (showTopDivider) const _SectionDivider(), _CustomSectionHeader( section: section, - onFilter: onFilter, expanded: expanded, isFirst: isFirst, isLast: isLast, @@ -73,7 +77,7 @@ class _CustomChannelSection extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final channel in channels.where( - (c) => isVisible?.call(c) ?? true, + (c) => reveal.value || (isVisible?.call(c) ?? true), )) _ChannelTile( channel: channel, @@ -84,6 +88,15 @@ class _CustomChannelSection extends StatelessWidget { onMarkRead: () => onMarkChannelRead(channel), sectionId: section.id, ), + if (hiddenIds.isNotEmpty) + TextButton( + onPressed: () => reveal.value = !reveal.value, + child: Text( + reveal.value + ? 'Hide filtered channels' + : 'Show hidden channels · ${channels.where((c) => !(isVisible?.call(c) ?? true)).length}', + ), + ), const SizedBox(height: _kExpandedSectionTrailingPadding), ], ), @@ -99,7 +112,6 @@ class _CustomSectionHeader extends StatelessWidget { final bool isFirst; final bool isLast; final VoidCallback onToggle; - final VoidCallback? onFilter; final VoidCallback onRename; final VoidCallback onDelete; final VoidCallback onMoveUp; @@ -113,7 +125,6 @@ class _CustomSectionHeader extends StatelessWidget { required this.isFirst, required this.isLast, required this.onToggle, - this.onFilter, required this.onRename, required this.onDelete, required this.onMoveUp, @@ -157,64 +168,23 @@ class _CustomSectionHeader extends StatelessWidget { color: sectionColor, ), onPressed: () async { - final value = await showAnchoredPopover( - context: buttonContext, - width: 216, - alignment: AnchoredPopoverAlignment.end, - surfaceKey: ValueKey('section-popover-${section.id}'), - items: [ - const PopupMenuItem( - value: 'rename', - padding: _sectionMenuItemPadding, - child: _SectionMenuItemContent( - icon: BuzzIcons.pencil, - label: 'Rename section', - ), - ), - PopupMenuItem( - value: 'move_up', - enabled: !isFirst, - padding: _sectionMenuItemPadding, - child: const _SectionMenuItemContent( - icon: BuzzIcons.arrowUp, - label: 'Move up', - ), - ), - PopupMenuItem( - value: 'move_down', - enabled: !isLast, - padding: _sectionMenuItemPadding, - child: const _SectionMenuItemContent( - icon: BuzzIcons.arrowDown, - label: 'Move down', - ), - ), - ..._sortMenuItems(sortMode), - if (onFilter != null) _filterMenuItem(), - PopupMenuItem( - value: 'delete', - padding: _sectionMenuItemPadding, - child: _SectionMenuItemContent( - icon: BuzzIcons.trash2, - label: 'Delete section', - color: context.colors.error, - ), - ), - ], + final value = await _showSectionViewSheet( + buttonContext, + title: section.name, + group: sectionSortGroupKey(section.id), + sortMode: sortMode, + onSort: onSortModeChange, + custom: true, + isFirst: isFirst, + isLast: isLast, ); switch (value) { - case 'filter': - onFilter?.call(); case 'rename': onRename(); case 'move_up': onMoveUp(); case 'move_down': onMoveDown(); - case _kSortRecentMenuValue: - onSortModeChange(ChannelSortMode.recent); - case _kSortAlphaMenuValue: - onSortModeChange(ChannelSortMode.alpha); case 'delete': onDelete(); } @@ -229,36 +199,6 @@ class _CustomSectionHeader extends StatelessWidget { } } -class _SectionMenuItemContent extends StatelessWidget { - final IconData icon; - final String label; - final Color? color; - - const _SectionMenuItemContent({ - required this.icon, - required this.label, - this.color, - }); - - @override - Widget build(BuildContext context) { - return Row( - children: [ - Icon(icon, size: 16, color: color), - const SizedBox(width: Grid.xxs), - Flexible( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: color == null ? null : TextStyle(color: color), - ), - ), - ], - ); - } -} - class _SectionNameDialog extends HookWidget { final String title; final String confirmLabel; @@ -298,49 +238,11 @@ class _SectionNameDialog extends HookWidget { } } -const _kSortRecentMenuValue = 'sort_recent'; -const _kSortAlphaMenuValue = 'sort_alpha'; - -PopupMenuItem _sortMenuItem({ - required String value, - required String label, - required bool selected, -}) => PopupMenuItem( - value: value, - child: Row( - children: [ - Expanded(child: Text(label)), - if (selected) - const Icon(BuzzIcons.check, key: ValueKey('sort-selected-check')) - else - const SizedBox(width: 24), - ], - ), -); - -List> _sortMenuItems( - ChannelSortMode current, { - bool showDivider = true, -}) => [ - if (showDivider) const PopupMenuDivider(), - _sortMenuItem( - value: _kSortRecentMenuValue, - label: 'Sort: Recent', - selected: current == ChannelSortMode.recent, - ), - _sortMenuItem( - value: _kSortAlphaMenuValue, - label: 'Sort: A–Z', - selected: current == ChannelSortMode.alpha, - ), -]; - -class _ChannelSection extends StatelessWidget { +class _ChannelSection extends HookWidget { final String title; final bool expanded; final VoidCallback onToggle; final List channels; - final VoidCallback? onFilter; final bool Function(Channel)? isVisible; final bool showTopDivider; final Set unreadChannelIds; @@ -356,7 +258,6 @@ class _ChannelSection extends StatelessWidget { required this.expanded, required this.onToggle, required this.channels, - this.onFilter, this.isVisible, required this.showTopDivider, required this.unreadChannelIds, @@ -370,6 +271,15 @@ class _ChannelSection extends StatelessWidget { @override Widget build(BuildContext context) { + final reveal = useState(false); + final hiddenIds = channels + .where((c) => !(isVisible?.call(c) ?? true)) + .map((c) => c.id) + .join(','); + useEffect(() { + reveal.value = false; + return null; + }, [hiddenIds]); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -377,7 +287,6 @@ class _ChannelSection extends StatelessWidget { _SectionHeader( label: title, expanded: expanded, - onFilter: onFilter, onToggle: onToggle, sortMode: sortMode, onSortModeChange: onSortModeChange, @@ -404,7 +313,7 @@ class _ChannelSection extends StatelessWidget { ) else for (final channel in channels.where( - (c) => isVisible?.call(c) ?? true, + (c) => reveal.value || (isVisible?.call(c) ?? true), )) _ChannelTile( channel: channel, @@ -415,6 +324,15 @@ class _ChannelSection extends StatelessWidget { onMarkRead: null, sectionId: null, ), + if (hiddenIds.isNotEmpty) + TextButton( + onPressed: () => reveal.value = !reveal.value, + child: Text( + reveal.value + ? 'Hide filtered channels' + : 'Show hidden channels · ${channels.where((c) => !(isVisible?.call(c) ?? true)).length}', + ), + ), const SizedBox(height: _kExpandedSectionTrailingPadding), ], ), @@ -476,7 +394,6 @@ class _SectionHeader extends StatelessWidget { final String label; final bool expanded; final VoidCallback onToggle; - final VoidCallback? onFilter; final ChannelSortMode? sortMode; final ValueChanged? onSortModeChange; @@ -484,7 +401,6 @@ class _SectionHeader extends StatelessWidget { required this.label, required this.expanded, required this.onToggle, - this.onFilter, this.sortMode, this.onSortModeChange, }); @@ -526,33 +442,13 @@ class _SectionHeader extends StatelessWidget { color: sectionColor, ), onPressed: () async { - final value = await showAnchoredPopover( - context: buttonContext, - width: 216, - alignment: AnchoredPopoverAlignment.end, - color: context.colors.surface, - elevation: 4, - shadowColor: context.colors.shadow.withValues( - alpha: 0.18, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(Radii.md), - side: BorderSide(color: context.colors.outline), - ), - surfaceKey: ValueKey('sort-popover-$label'), - items: [ - ..._sortMenuItems(mode, showDivider: false), - if (onFilter != null) _filterMenuItem(), - ], + await _showSectionViewSheet( + buttonContext, + title: label, + group: label.toLowerCase(), + sortMode: mode, + onSort: (value) => onSortModeChange?.call(value), ); - if (value == 'filter') { - onFilter?.call(); - } - if (value == _kSortRecentMenuValue) { - onSortModeChange?.call(ChannelSortMode.recent); - } else if (value == _kSortAlphaMenuValue) { - onSortModeChange?.call(ChannelSortMode.alpha); - } }, ), ), @@ -614,9 +510,3 @@ class _SectionChevron extends StatelessWidget { ); } } - -PopupMenuItem _filterMenuItem() => const PopupMenuItem( - value: 'filter', - padding: _sectionMenuItemPadding, - child: _SectionMenuItemContent(icon: BuzzIcons.filter2, label: 'Filter'), -); diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 42ae0f05d8..d9cac02bc0 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -224,14 +224,61 @@ void main() { expect(find.byTooltip('Channels options'), findsOneWidget); await tester.tap(find.byTooltip('Channels options')); await tester.pumpAndSettle(); - await tester.tap(find.text('Filter')); + expect(find.text('general'), findsNothing); + final all = tester.widget( + find.ancestor(of: find.text('All'), matching: find.byType(ChoiceChip)), + ); + expect(all.onSelected, isNull); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Show hidden channels · 1')); await tester.pumpAndSettle(); expect(find.text('general'), findsOneWidget); - final all = tester.widget( - find.ancestor(of: find.text('All'), matching: find.byType(ListTile)), - ); - expect(all.enabled, isFalse); expect(tester.widget(_dmTileFor('general')).onTap, isNotNull); + expect(manager.store.modeFor('channels'), ChannelFilterMode.unreads); + await tester.pumpWidget(const SizedBox()); + manager.dispose(); + }, + ); + + testWidgets( + 'section view stages choices until Apply and reopens saved duration', + (tester) async { + final manager = _filterTestManager()..ready = true; + await tester.pumpWidget( + buildTestable( + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier(testChannels)), + channelFilterProvider.overrideWithValue(manager), + ], + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Channels options')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Active')); + await tester.pumpAndSettle(); + await tester.tap(find.text('7 days')); + await tester.pumpAndSettle(); + expect(manager.store.modeFor('channels'), ChannelFilterMode.all); + await tester.tap(find.text('Apply')); + await tester.pumpAndSettle(); + expect(manager.store.modeFor('channels'), ChannelFilterMode.active); + expect(manager.store.windowFor('channels'), 7 * 86400); + await tester.tap(find.byTooltip('Channels options')); + await tester.pumpAndSettle(); + final days = tester.widget( + find.ancestor( + of: find.text('7 days'), + matching: find.byType(ChoiceChip), + ), + ); + expect(days.selected, isTrue); + await tester.tap(find.text('All')); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(manager.store.modeFor('channels'), ChannelFilterMode.active); + expect(find.byType(AlertDialog), findsNothing); await tester.pumpWidget(const SizedBox()); manager.dispose(); }, @@ -257,9 +304,7 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.byTooltip('Channels options')); await tester.pumpAndSettle(); - await tester.tap(find.text('Filter')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Active in the last')); + await tester.tap(find.text('Active')); await tester.pumpAndSettle(); await tester.tap(find.text('7 days')); await tester.pumpAndSettle(); @@ -268,12 +313,17 @@ void main() { await tester.pumpAndSettle(); expect( find.text( - 'Could not confirm the save. Your selection is retained. Retry or Cancel.', + 'Could not confirm the filter save. Your choices are kept; try again.', ), findsOneWidget, ); - final selected = tester.widget(find.text('7 days')); - expect(selected.style?.fontWeight, FontWeight.bold); + final selected = tester.widget( + find.ancestor( + of: find.text('7 days'), + matching: find.byType(ChoiceChip), + ), + ); + expect(selected.selected, isTrue); await tester.tap(find.text('Cancel')); await tester.pumpAndSettle(); expect(find.byType(AlertDialog), findsNothing); @@ -415,20 +465,16 @@ void main() { await tester.tap(find.byTooltip('Channels options')); await tester.pumpAndSettle(); - expect(find.text('Sort: Recent'), findsOneWidget); - expect(find.text('Sort: A–Z'), findsOneWidget); - final popover = find.byKey(const ValueKey('sort-popover-Channels')); - expect(popover, findsOneWidget); - expect( - find.descendant(of: popover, matching: find.byType(PopupMenuDivider)), - findsNothing, - ); - final selectedCheck = find.byKey(const ValueKey('sort-selected-check')); - expect(selectedCheck, findsOneWidget); - expect( - tester.getCenter(selectedCheck).dx, - greaterThan(tester.getCenter(find.text('Sort: A–Z')).dx), + expect(find.text('Recent'), findsOneWidget); + expect(find.text('A–Z'), findsOneWidget); + expect(find.text('Show'), findsOneWidget); + expect(find.byType(AlertDialog), findsNothing); + final alpha = tester.widget( + find.ancestor(of: find.text('A–Z'), matching: find.byType(ChoiceChip)), ); + expect(alpha.selected, isTrue); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); for (final label in ['general', 'Alice']) { final text = tester.widget(find.text(label)); @@ -1077,63 +1123,23 @@ void main() { await tester.tap(find.byKey(const Key('section-menu-section-1'))); await tester.pumpAndSettle(); - final popover = find.byKey(const Key('section-popover-section-1')); - expect(popover, findsOneWidget); for (final label in [ 'Rename section', 'Move up', 'Move down', 'Delete section', ]) { - expect( - find.descendant(of: popover, matching: find.text(label)), - findsOne, - ); + expect(find.text(label), findsOneWidget); } - for (final icon in [ - BuzzIcons.pencil, - BuzzIcons.arrowUp, - BuzzIcons.arrowDown, - BuzzIcons.trash2, - ]) { - expect( - find.descendant(of: popover, matching: find.byIcon(icon)), - findsOne, - ); - } - - final actionMenuItems = tester - .widgetList>( - find.descendant( - of: popover, - matching: find.byWidgetPredicate( - (widget) => widget is PopupMenuItem, - ), - ), - ) - .where( - (item) => const { - 'rename', - 'move_up', - 'move_down', - 'delete', - }.contains(item.value), - ); - expect(actionMenuItems, hasLength(4)); - for (final item in actionMenuItems) { - expect( - item.padding, - const EdgeInsets.fromLTRB(Grid.xs, 0, Grid.twelve, 0), - ); - } - - final error = Theme.of(tester.element(popover)).colorScheme.error; - final deleteText = tester.widget(find.text('Delete section')); - final deleteIcon = tester.widget( - find.descendant(of: popover, matching: find.byIcon(BuzzIcons.trash2)), + final moveUp = tester.widget( + find.ancestor(of: find.text('Move up'), matching: find.byType(ListTile)), + ); + expect(moveUp.enabled, isFalse); + final deleteText = tester.widget(find.text('Delete section')); + expect( + deleteText.style?.color, + Theme.of(tester.element(find.text('Delete section'))).colorScheme.error, ); - expect(deleteText.style?.color, error); - expect(deleteIcon.color, error); }); testWidgets('aligns the top, section, row, and skeleton label columns', (