Add a Settings option to reorder the selection action bar's actions

NooSelectionBar's fixed inline slots (3 mobile / 4 desktop, the rest
behind "More") previously just took the first N actions in whatever
order each screen happened to build them in. Give bulk actions a
stable SelectionActionKind identity (independent of their dynamic
icon/label, e.g. favorite vs. unfavorite) so Settings can offer a
drag-to-reorder list - mirroring the existing Tabs section, minus the
pin/hide concept, which doesn't apply here - and each screen applies
that saved priority order via orderSelectionActions before handing
its action list to the bar.
This commit is contained in:
2026-09-28 12:21:36 -04:00
parent 800e8ebf44
commit 254db255a7
10 changed files with 347 additions and 27 deletions
@@ -178,7 +178,9 @@ multi-select)
desktop - there can be up to 9: favorite, share, download, delete, copy,
move, rename, sync, details); the rest sit behind a trailing "More"
button that opens the same grouped-list sheet a file row's own overflow
menu (`ellipsis`/`ellipsis-vertical`) already uses.
menu (`ellipsis`/`ellipsis-vertical`) already uses. Which actions land in
the inline slots vs. "More" is the user's own priority order (Settings
→ Action bar, §4's 9-part list), not a fixed per-kind assignment.
- Mobile: inline actions are plain 20px accent-text icons, no fill; "More"
is the same 40px `NooOverflowButton` treatment (`ellipsis`/
`ellipsis-vertical`, fg-3) file rows use for their own overflow menu.
@@ -317,7 +319,12 @@ Sidebar items are 38px tall with radius 12, an 18px icon and a 14/500 label. The
5. Files cache
6. Appearance
7. Tabs
8. Swipe on a file
8. Action bar - a reorder-only list (no pin/hide, unlike Tabs): the
priority order for the Selection action bar's bulk actions (favorite,
share, download, delete, copy, move, rename, sync, details) - the
first few (3 mobile / 4 desktop) land in the bar's fixed inline
slots, the rest sit behind "More". See §2 "Selection action bar".
9. Swipe on a file
Mobile uses one column of grouped lists. Desktop uses a 2-column grid of cards with a 1px line and radius 20.
- **Share sheet / dialog:** sections in this order:
+75
View File
@@ -1,16 +1,91 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
/// A bulk action's identity, stable across the dynamic icon/label a
/// [SelectionAction] actually renders (e.g. `favorite` covers both
/// "Favorite" and "Remove from favorites"). This is what the user's
/// configured [order] - Settings' "Action bar" reorder list - and
/// [NooSelectionBar]'s inline/overflow split key off, since the live
/// [SelectionAction] list is rebuilt fresh on every selection change and
/// can't be compared by icon/label alone.
///
/// Not every screen offers every kind (e.g. Favorites has no `rename` or
/// `sync`) - [orderSelectionActions] just skips whatever kind a screen
/// didn't build.
enum SelectionActionKind {
favorite,
share,
download,
delete,
copy,
move,
rename,
sync,
details;
/// Generic label for the Settings reorder row - the live bar uses each
/// screen's own contextual label (e.g. "Remove from favorites") instead.
String get label => switch (this) {
SelectionActionKind.favorite => 'Favorite',
SelectionActionKind.share => 'Share',
SelectionActionKind.download => 'Download',
SelectionActionKind.delete => 'Delete',
SelectionActionKind.copy => 'Copy',
SelectionActionKind.move => 'Move',
SelectionActionKind.rename => 'Rename',
SelectionActionKind.sync => 'Sync to device',
SelectionActionKind.details => 'Details',
};
IconData get icon => switch (this) {
SelectionActionKind.favorite => LucideIcons.star,
SelectionActionKind.share => LucideIcons.share2,
SelectionActionKind.download => LucideIcons.download,
SelectionActionKind.delete => LucideIcons.trash2,
SelectionActionKind.copy => LucideIcons.copy,
SelectionActionKind.move => LucideIcons.folderInput,
SelectionActionKind.rename => LucideIcons.filePen,
SelectionActionKind.sync => LucideIcons.hardDriveDownload,
SelectionActionKind.details => LucideIcons.info,
};
}
/// One bulk action available for the current multi-selection (e.g. favorite,
/// download, delete), shown as an icon button in the sticky selection
/// toolbar each tab (Files, Photos, ...) renders inline in its own content.
class SelectionAction {
final SelectionActionKind kind;
final IconData icon;
final String label;
final VoidCallback onTap;
const SelectionAction({
required this.kind,
required this.icon,
required this.label,
required this.onTap,
});
}
/// Reorders [actions] to match the user's configured [order] (Settings'
/// "Action bar" section, [SelectionActionKind] values) - this is what makes
/// that setting take effect, since it's [order] that decides which actions
/// land in `NooSelectionBar`'s inline slots vs. behind "More". A kind in
/// [order] the caller didn't build (e.g. `rename` on a multi-item
/// selection) is just skipped; a kind [actions] has that [order] doesn't
/// mention (e.g. a kind added in a later app update, before the saved
/// order is migrated - see [SettingsController.selectionActionOrder]) is
/// appended at the end in its original position.
List<SelectionAction> orderSelectionActions(
List<SelectionAction> actions,
List<SelectionActionKind> order,
) {
final byKind = {for (final a in actions) a.kind: a};
final ordered = <SelectionAction>[];
for (final kind in order) {
final action = byKind.remove(kind);
if (action != null) ordered.add(action);
}
ordered.addAll(byKind.values);
return ordered;
}
+41
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/app_tab.dart';
import '../models/selection_action.dart';
import '../theme/app_theme.dart';
/// What swiping a Files list-view item left/right does, user-configurable
@@ -24,6 +25,7 @@ class SettingsController extends ChangeNotifier {
static const _prefUseDynamicColor = 'ui_use_dynamic_color';
static const _prefSeedColor = 'ui_seed_color';
static const _prefTabOrder = 'ui_tab_order';
static const _prefSelectionActionOrder = 'ui_selection_action_order';
static const _prefHiddenTabs = 'ui_hidden_tabs';
static const _prefDefaultTab = 'ui_default_tab';
static const _prefSwipeLeftAction = 'ui_swipe_left_action';
@@ -47,6 +49,9 @@ class SettingsController extends ChangeNotifier {
Set<AppTab> _hiddenTabs = {};
AppTab _defaultTab = AppTab.files;
List<SelectionActionKind> _selectionActionOrder = SelectionActionKind.values
.toList();
SwipeAction _swipeLeftAction = SwipeAction.delete;
SwipeAction _swipeRightAction = SwipeAction.favorite;
@@ -86,6 +91,12 @@ class SettingsController extends ChangeNotifier {
Set<AppTab> get hiddenTabs => _hiddenTabs;
AppTab get defaultTab => _defaultTab;
/// The priority order bulk actions (favorite, share, download, ...) show
/// in on the multi-select action bar - see [orderSelectionActions]. Every
/// [SelectionActionKind] is always present here (nothing is hidden, only
/// reordered), so [NooSelectionBar]'s fixed inline slots are always full.
List<SelectionActionKind> get selectionActionOrder => _selectionActionOrder;
/// The tabs the bottom nav bar should actually show, in order.
List<AppTab> get visibleTabs =>
_tabOrder.where((t) => !_hiddenTabs.contains(t)).toList();
@@ -134,6 +145,25 @@ class SettingsController extends ChangeNotifier {
_tabOrder = order;
}
final savedActionOrderNames = prefs.getStringList(
_prefSelectionActionOrder,
);
if (savedActionOrderNames != null) {
final order = <SelectionActionKind>[];
for (final name in savedActionOrderNames) {
final match = SelectionActionKind.values
.where((k) => k.name == name)
.firstOrNull;
if (match != null) order.add(match);
}
// Forward-compat: a kind added in a later app update won't be in an
// older saved order yet, so append anything missing.
for (final kind in SelectionActionKind.values) {
if (!order.contains(kind)) order.add(kind);
}
_selectionActionOrder = order;
}
final savedHiddenNames = prefs.getStringList(_prefHiddenTabs);
if (savedHiddenNames != null) {
_hiddenTabs = savedHiddenNames
@@ -234,6 +264,17 @@ class SettingsController extends ChangeNotifier {
);
}
void setSelectionActionOrder(List<SelectionActionKind> order) {
_selectionActionOrder = order;
notifyListeners();
_prefsFuture.then(
(p) => p.setStringList(
_prefSelectionActionOrder,
order.map((k) => k.name).toList(),
),
);
}
/// Shows or hides a tab in the bottom nav bar. Refuses to hide the last
/// remaining visible tab. If the tab being hidden is the current default,
/// the default falls back to the next visible tab.
+23 -6
View File
@@ -5,6 +5,7 @@ import '../widgets/noo/nav/noo_toolbar.dart';
import '../widgets/noo/noo_layout.dart';
import '../widgets/settings/settings_account_card.dart';
import '../widgets/settings/settings_accounts.dart';
import '../widgets/settings/settings_action_bar.dart';
import '../widgets/settings/settings_appearance.dart';
import '../widgets/settings/settings_file_sync.dart';
import '../widgets/settings/settings_files_cache.dart';
@@ -12,9 +13,11 @@ import '../widgets/settings/settings_security.dart';
import '../widgets/settings/settings_swipe.dart';
import '../widgets/settings/settings_tabs.dart';
/// Settings, pushed on top of the shell (DESIGN_SYSTEM.md 4's 8-section
/// Settings, pushed on top of the shell (DESIGN_SYSTEM.md 4's 9-section
/// order: account card, accounts, security, file sync, files cache,
/// appearance, tabs, swipe on a file). One column of [SettingsSection]s on
/// appearance, tabs, action bar, swipe on a file). One column of
/// [SettingsSection]s (and [SettingsActionBarSection], which has no option
/// rows of its own to put in one - just the reorder list) on
/// mobile; a 2-column grid of cards on desktop - see each
/// `widgets/settings/*.dart` file for a section's own content and any
/// setting that had to be slotted in or grouped under "Advanced appearance".
@@ -29,6 +32,7 @@ class AccountView extends StatelessWidget {
SettingsFilesCacheSection(),
SettingsAppearanceSection(),
SettingsTabsSection(),
SettingsActionBarSection(),
SettingsSwipeSection(),
];
@@ -48,7 +52,9 @@ class AccountView extends StatelessWidget {
),
body: SafeArea(
top: false,
child: desktop ? _DesktopGrid(sections: _sections) : _MobileList(sections: _sections),
child: desktop
? _DesktopGrid(sections: _sections)
: _MobileList(sections: _sections),
),
);
}
@@ -61,10 +67,18 @@ class _MobileList extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.fromLTRB(NooSpace.sm, NooSpace.sm, NooSpace.sm, NooSpace.xxl),
padding: const EdgeInsets.fromLTRB(
NooSpace.sm,
NooSpace.sm,
NooSpace.sm,
NooSpace.xxl,
),
physics: const BouncingScrollPhysics(),
children: [
for (final section in sections) ...[section, const SizedBox(height: NooSpace.xl)],
for (final section in sections) ...[
section,
const SizedBox(height: NooSpace.xl),
],
],
);
}
@@ -112,7 +126,10 @@ class _Column extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final child in children) ...[child, const SizedBox(height: NooSpace.xl)],
for (final child in children) ...[
child,
const SizedBox(height: NooSpace.xl),
],
],
);
}
+12 -1
View File
@@ -96,13 +96,15 @@ class _FavoritesViewState extends State<FavoritesView> {
/// The bulk actions shown in the sticky selection toolbar for the
/// currently-selected items - same set Files/Photos offer.
List<SelectionAction> _buildSelectionActions(List<NextcloudItem> selected) {
return [
final actions = [
SelectionAction(
kind: SelectionActionKind.favorite,
icon: LucideIcons.starOff,
label: 'Remove from favorites',
onTap: () => _unfavoriteSelected(selected),
),
SelectionAction(
kind: SelectionActionKind.share,
icon: LucideIcons.share2,
label: 'Share',
onTap: () => selected.length == 1
@@ -110,32 +112,41 @@ class _FavoritesViewState extends State<FavoritesView> {
: _shareSelected(context, selected),
),
SelectionAction(
kind: SelectionActionKind.download,
icon: LucideIcons.download,
label: 'Download',
onTap: () => _downloadSelected(context, selected),
),
SelectionAction(
kind: SelectionActionKind.delete,
icon: LucideIcons.trash2,
label: 'Delete',
onTap: () => _confirmDeleteSelected(context, selected),
),
SelectionAction(
kind: SelectionActionKind.copy,
icon: LucideIcons.copy,
label: 'Copy',
onTap: () => _moveOrCopySelected(selected, copy: true),
),
SelectionAction(
kind: SelectionActionKind.move,
icon: LucideIcons.folderInput,
label: 'Move',
onTap: () => _moveOrCopySelected(selected, copy: false),
),
if (selected.length == 1)
SelectionAction(
kind: SelectionActionKind.details,
icon: LucideIcons.info,
label: 'Details',
onTap: () => DetailsSheet.show(context, selected.single),
),
];
return orderSelectionActions(
actions,
context.read<SettingsController>().selectionActionOrder,
);
}
Future<void> _unfavoriteSelected(List<NextcloudItem> items) async {
+15 -1
View File
@@ -419,6 +419,7 @@ class _FilesViewState extends State<FilesView>
if (pick.isPicking) {
return [
SelectionAction(
kind: SelectionActionKind.favorite,
icon: LucideIcons.check,
label: 'Use ${selected.length} item(s)',
onTap: () => pick.confirmPick(selected),
@@ -427,13 +428,15 @@ class _FilesViewState extends State<FilesView>
}
final allFavorited = selected.every((i) => i.isFavorite);
final allSynced = selected.every((i) => sync.isPathSynced(i.path));
return [
final actions = [
SelectionAction(
kind: SelectionActionKind.favorite,
icon: allFavorited ? LucideIcons.starOff : LucideIcons.star,
label: allFavorited ? 'Remove from favorites' : 'Favorite',
onTap: () => _favoriteSelected(context, selected),
),
SelectionAction(
kind: SelectionActionKind.share,
icon: LucideIcons.share2,
label: 'Share',
onTap: () => selected.length == 1
@@ -441,43 +444,54 @@ class _FilesViewState extends State<FilesView>
: _shareSelected(context, selected),
),
SelectionAction(
kind: SelectionActionKind.download,
icon: LucideIcons.download,
label: 'Download',
onTap: () => _downloadSelected(context, selected),
),
SelectionAction(
kind: SelectionActionKind.delete,
icon: LucideIcons.trash2,
label: 'Delete',
onTap: () => _confirmDeleteSelected(context, selected),
),
SelectionAction(
kind: SelectionActionKind.copy,
icon: LucideIcons.copy,
label: 'Copy',
onTap: () => _moveOrCopySelected(context, selected, copy: true),
),
SelectionAction(
kind: SelectionActionKind.move,
icon: LucideIcons.folderInput,
label: 'Move',
onTap: () => _moveOrCopySelected(context, selected, copy: false),
),
if (selected.length == 1)
SelectionAction(
kind: SelectionActionKind.rename,
icon: LucideIcons.filePen,
label: 'Rename',
onTap: () => _renameItem(selected.single),
),
SelectionAction(
kind: SelectionActionKind.sync,
icon: LucideIcons.hardDriveDownload,
label: allSynced ? 'Stop syncing to device' : 'Sync to device',
onTap: () => _toggleSyncSelected(context, sync, selected),
),
if (selected.length == 1)
SelectionAction(
kind: SelectionActionKind.details,
icon: LucideIcons.info,
label: 'Details',
onTap: () => DetailsSheet.show(context, selected.single),
),
];
return orderSelectionActions(
actions,
context.read<SettingsController>().selectionActionOrder,
);
}
Future<void> _renameItem(NextcloudItem item) async {
+14 -1
View File
@@ -11,6 +11,7 @@ import '../providers/item_operations.dart';
import '../providers/photos_controller.dart';
import '../providers/pick_controller.dart';
import '../providers/session_controller.dart';
import '../providers/settings_controller.dart';
import '../services/download_service.dart';
import '../theme/design_tokens.dart';
import '../widgets/details/details_sheet.dart';
@@ -113,6 +114,7 @@ class _PhotosViewState extends State<PhotosView> {
if (pick.isPicking) {
return [
SelectionAction(
kind: SelectionActionKind.favorite,
icon: LucideIcons.check,
label: 'Use ${selected.length} item(s)',
onTap: () => pick.confirmPick(selected),
@@ -123,13 +125,15 @@ class _PhotosViewState extends State<PhotosView> {
// uses for favorite/unfavorite (see `files_view.dart`) rather than a
// filled heart.
final allFavorited = selected.every((i) => i.isFavorite);
return [
final actions = [
SelectionAction(
kind: SelectionActionKind.favorite,
icon: allFavorited ? LucideIcons.starOff : LucideIcons.star,
label: allFavorited ? 'Remove from favorites' : 'Favorite',
onTap: () => _favoriteSelected(selected),
),
SelectionAction(
kind: SelectionActionKind.share,
icon: LucideIcons.share2,
label: 'Share',
onTap: () => selected.length == 1
@@ -137,32 +141,41 @@ class _PhotosViewState extends State<PhotosView> {
: _shareSelected(context, selected),
),
SelectionAction(
kind: SelectionActionKind.download,
icon: LucideIcons.download,
label: 'Download',
onTap: () => _downloadSelected(context, selected),
),
SelectionAction(
kind: SelectionActionKind.delete,
icon: LucideIcons.trash2,
label: 'Delete',
onTap: () => _confirmDeleteSelected(context, selected),
),
SelectionAction(
kind: SelectionActionKind.copy,
icon: LucideIcons.copy,
label: 'Copy',
onTap: () => _moveOrCopySelected(selected, copy: true),
),
SelectionAction(
kind: SelectionActionKind.move,
icon: LucideIcons.folderInput,
label: 'Move',
onTap: () => _moveOrCopySelected(selected, copy: false),
),
if (selected.length == 1)
SelectionAction(
kind: SelectionActionKind.details,
icon: LucideIcons.info,
label: 'Details',
onTap: () => DetailsSheet.show(context, selected.single),
),
];
return orderSelectionActions(
actions,
context.read<SettingsController>().selectionActionOrder,
);
}
/// Splits an already-filtered/sorted list into contiguous month runs
+22 -15
View File
@@ -11,6 +11,11 @@ class NooTabOrderRow extends StatelessWidget {
final bool pinned;
final VoidCallback? onTogglePin;
/// False hides the trailing pin toggle entirely - for reorder-only lists
/// (Settings' Action bar section) that have no "pinned" concept, just an
/// order.
final bool showPin;
/// This row's index inside its `ReorderableListView` - when set, only the
/// grip icon starts a drag (`ReorderableDragStartListener` wraps just the
/// icon, not the whole row), so a vertical drag/scroll starting anywhere
@@ -25,6 +30,7 @@ class NooTabOrderRow extends StatelessWidget {
required this.label,
this.pinned = false,
this.onTogglePin,
this.showPin = true,
this.dragIndex,
});
@@ -50,23 +56,24 @@ class NooTabOrderRow extends StatelessWidget {
style: NooText.bodyL.copyWith(height: 1, color: colors.fg1),
),
),
GestureDetector(
onTap: onTogglePin,
child: Container(
width: 36,
height: 36,
alignment: Alignment.center,
decoration: BoxDecoration(
color: pinned ? colors.accentSoft : colors.surface2,
shape: BoxShape.circle,
),
child: Icon(
pinned ? LucideIcons.pin : LucideIcons.pinOff,
size: 16,
color: pinned ? colors.accentText : colors.fg3,
if (showPin)
GestureDetector(
onTap: onTogglePin,
child: Container(
width: 36,
height: 36,
alignment: Alignment.center,
decoration: BoxDecoration(
color: pinned ? colors.accentSoft : colors.surface2,
shape: BoxShape.circle,
),
child: Icon(
pinned ? LucideIcons.pin : LucideIcons.pinOff,
size: 16,
color: pinned ? colors.accentText : colors.fg3,
),
),
),
),
],
),
);
@@ -0,0 +1,132 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/selection_action.dart';
import '../../providers/settings_controller.dart';
import '../../theme/design_tokens.dart';
import '../noo/lists/noo_tab_order_row.dart';
import '../noo/noo_layout.dart';
/// Settings section: the priority order bulk actions (favorite, share,
/// download, ...) show in on the Files/Photos/Favorites multi-select
/// action bar. Every action stays available everywhere - this only decides
/// which ones land in `NooSelectionBar`'s fixed inline slots (3 mobile / 4
/// desktop, see its own doc comment) vs. behind its trailing "More" -
/// there's no pin/hide concept here the way Tabs has, so this is just a
/// reorder list, not a whole section of its own option rows.
class SettingsActionBarSection extends StatelessWidget {
const SettingsActionBarSection({super.key});
static const _subtitle =
'Drag to reorder - the first ones shown appear directly in the bar, the rest sit behind "More"';
void _handleReorder(SettingsController settings, int oldIndex, int newIndex) {
final order = List<SelectionActionKind>.from(settings.selectionActionOrder);
final kind = order.removeAt(oldIndex);
order.insert(newIndex, kind);
settings.setSelectionActionOrder(order);
}
@override
Widget build(BuildContext context) {
final settings = context.watch<SettingsController>();
final desktop = NooLayout.isDesktop(context);
final orderList = _ActionOrderList(
order: settings.selectionActionOrder,
onReorder: (oldIndex, newIndex) =>
_handleReorder(settings, oldIndex, newIndex),
);
final colors = context.nooColors;
if (!desktop) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
'Action bar',
style: NooText.label.copyWith(color: colors.fg2),
),
),
const SizedBox(height: 4),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
_subtitle,
style: NooText.meta.copyWith(color: colors.fg3),
),
),
const SizedBox(height: 8),
orderList,
],
);
}
return Container(
decoration: BoxDecoration(
color: colors.surface,
border: Border.all(color: colors.line),
borderRadius: BorderRadius.circular(NooRadii.card),
),
padding: const EdgeInsets.all(NooSpace.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Action bar',
style: NooText.cardTitle.copyWith(color: colors.fg1),
),
const SizedBox(height: 4),
Text(_subtitle, style: NooText.meta.copyWith(color: colors.fg3)),
const SizedBox(height: NooSpace.md),
orderList,
],
),
);
}
}
/// The reorderable [NooTabOrderRow] stack for [SelectionActionKind]s - same
/// radius-20, line-divided card `settings_tabs.dart`'s `_TabOrderList`
/// draws, minus the pin toggle (`showPin: false`): there's nothing to
/// show/hide here, only an order.
class _ActionOrderList extends StatelessWidget {
final List<SelectionActionKind> order;
final void Function(int oldIndex, int newIndex) onReorder;
const _ActionOrderList({required this.order, required this.onReorder});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final list = ReorderableListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
buildDefaultDragHandles: false,
itemCount: order.length,
onReorderItem: onReorder,
itemBuilder: (context, index) {
final kind = order[index];
return Padding(
key: ValueKey(kind),
padding: EdgeInsets.only(top: index > 0 ? 1 : 0),
child: NooTabOrderRow(
icon: kind.icon,
label: kind.label,
showPin: false,
dragIndex: index,
),
);
},
);
return ClipRRect(
borderRadius: BorderRadius.circular(NooRadii.card),
child: DecoratedBox(
decoration: BoxDecoration(color: colors.line),
child: list,
),
);
}
}
+4 -1
View File
@@ -14,12 +14,15 @@ import 'package:noo/widgets/noo/lists/noo_tab_order_row.dart';
import 'noo_test_utils.dart';
/// [n] actions labelled A, B, C, ... each incrementing an entry in [taps]
/// keyed by its own label when tapped.
/// keyed by its own label when tapped. [n] must be at most
/// `SelectionActionKind.values.length` - each generated action needs its
/// own distinct kind.
List<SelectionAction> _actions(int n, Map<String, int> taps) =>
List.generate(n, (i) {
final label = String.fromCharCode(65 + i);
taps[label] = 0;
return SelectionAction(
kind: SelectionActionKind.values[i],
icon: LucideIcons.star,
label: label,
onTap: () => taps[label] = taps[label]! + 1,