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 <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-10-19 15:03:03 -07:00
parent 042438655e
commit d9b2f7bb8c
5 changed files with 382 additions and 566 deletions

View File

@@ -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';

View File

@@ -310,25 +310,6 @@ class _SliverChannelsList extends HookConsumerWidget {
);
}
void showFilter(String group, List<Channel> roster, VoidCallback expand) {
unawaited(
showBuzzModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (_) => _SectionFilterSheet(
group: group,
channels: roster,
currentPubkey: currentPubkey,
selectedChannelId: selectedChannel.value,
onOpen: (channel) {
expand();
return openFilteredChannel(channel);
},
),
),
);
}
List<Channel> 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,

View File

@@ -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<String?> _showSectionViewSheet(
BuildContext context, {
required String title,
required String group,
required ChannelSortMode sortMode,
required ValueChanged<ChannelSortMode> onSort,
bool custom = false,
bool isFirst = false,
bool isLast = false,
}) => showBuzzModalBottomSheet<String>(
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<Channel> channels;
final String? currentPubkey;
final String? selectedChannelId;
final Future<void> Function(Channel) onOpen;
const _SectionFilterSheet({
final ChannelSortMode initialSort;
final ValueChanged<ChannelSortMode> 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 <String>[];
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 = <Channel>[];
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<ChannelFilterMode?>(null);
final days = useState(
(manager?.store.windowFor(group) ?? defaultActiveWindowSeconds) ~/ 86400,
);
final dirty = useState(false);
final saving = useState(false);
Future<void> 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<void> 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<T>(
String label,
Map<T, String> values,
T selected,
ValueChanged<T>? 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<void>(
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<ChannelSortMode>(
'Sort',
{
ChannelSortMode.alpha: 'A–Z',
ChannelSortMode.recent: 'Recent',
},
sort.value,
saving.value ? null : (value) => sort.value = value,
),
choices<ChannelFilterMode>(
'Show',
{
ChannelFilterMode.all: 'All',
ChannelFilterMode.unreads: 'Unreads',
ChannelFilterMode.active: 'Active',
},
mode.value,
writable ? (value) => mode.value = value : null,
),
if (mode.value == ChannelFilterMode.active)
choices<int>(
'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',
),
),
],
),
);
}

View File

@@ -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<Channel> channels;
final VoidCallback? onFilter;
final bool Function(Channel)? isVisible;
final Set<String> unreadChannelIds;
final Set<String> 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<String>(
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<String> _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<PopupMenuEntry<String>> _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<Channel> channels;
final VoidCallback? onFilter;
final bool Function(Channel)? isVisible;
final bool showTopDivider;
final Set<String> 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<ChannelSortMode>? 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<String>(
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<String> _filterMenuItem() => const PopupMenuItem(
value: 'filter',
padding: _sectionMenuItemPadding,
child: _SectionMenuItemContent(icon: BuzzIcons.filter2, label: 'Filter'),
);

View File

@@ -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<ChoiceChip>(
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<ListTile>(
find.ancestor(of: find.text('All'), matching: find.byType(ListTile)),
);
expect(all.enabled, isFalse);
expect(tester.widget<InkWell>(_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<ChoiceChip>(
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<Text>(find.text('7 days'));
expect(selected.style?.fontWeight, FontWeight.bold);
final selected = tester.widget<ChoiceChip>(
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<ChoiceChip>(
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<Text>(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<PopupMenuItem<String>>(
find.descendant(
of: popover,
matching: find.byWidgetPredicate(
(widget) => widget is PopupMenuItem<String>,
),
),
)
.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<Text>(find.text('Delete section'));
final deleteIcon = tester.widget<Icon>(
find.descendant(of: popover, matching: find.byIcon(BuzzIcons.trash2)),
final moveUp = tester.widget<ListTile>(
find.ancestor(of: find.text('Move up'), matching: find.byType(ListTile)),
);
expect(moveUp.enabled, isFalse);
final deleteText = tester.widget<Text>(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', (