mirror of
https://github.com/block/buzz.git
synced 2026-11-09 02:41:57 +00:00
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:
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
|
||||
@@ -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', (
|
||||
|
||||
Reference in New Issue
Block a user