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:
@@ -178,7 +178,9 @@ multi-select)
|
|||||||
desktop - there can be up to 9: favorite, share, download, delete, copy,
|
desktop - there can be up to 9: favorite, share, download, delete, copy,
|
||||||
move, rename, sync, details); the rest sit behind a trailing "More"
|
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
|
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"
|
- Mobile: inline actions are plain 20px accent-text icons, no fill; "More"
|
||||||
is the same 40px `NooOverflowButton` treatment (`ellipsis`/
|
is the same 40px `NooOverflowButton` treatment (`ellipsis`/
|
||||||
`ellipsis-vertical`, fg-3) file rows use for their own overflow menu.
|
`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
|
5. Files cache
|
||||||
6. Appearance
|
6. Appearance
|
||||||
7. Tabs
|
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.
|
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:
|
- **Share sheet / dialog:** sections in this order:
|
||||||
|
|||||||
@@ -1,16 +1,91 @@
|
|||||||
import 'package:flutter/material.dart';
|
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,
|
/// One bulk action available for the current multi-selection (e.g. favorite,
|
||||||
/// download, delete), shown as an icon button in the sticky selection
|
/// download, delete), shown as an icon button in the sticky selection
|
||||||
/// toolbar each tab (Files, Photos, ...) renders inline in its own content.
|
/// toolbar each tab (Files, Photos, ...) renders inline in its own content.
|
||||||
class SelectionAction {
|
class SelectionAction {
|
||||||
|
final SelectionActionKind kind;
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String label;
|
final String label;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
const SelectionAction({
|
const SelectionAction({
|
||||||
|
required this.kind,
|
||||||
required this.icon,
|
required this.icon,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.onTap,
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../models/app_tab.dart';
|
import '../models/app_tab.dart';
|
||||||
|
import '../models/selection_action.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
|
|
||||||
/// What swiping a Files list-view item left/right does, user-configurable
|
/// 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 _prefUseDynamicColor = 'ui_use_dynamic_color';
|
||||||
static const _prefSeedColor = 'ui_seed_color';
|
static const _prefSeedColor = 'ui_seed_color';
|
||||||
static const _prefTabOrder = 'ui_tab_order';
|
static const _prefTabOrder = 'ui_tab_order';
|
||||||
|
static const _prefSelectionActionOrder = 'ui_selection_action_order';
|
||||||
static const _prefHiddenTabs = 'ui_hidden_tabs';
|
static const _prefHiddenTabs = 'ui_hidden_tabs';
|
||||||
static const _prefDefaultTab = 'ui_default_tab';
|
static const _prefDefaultTab = 'ui_default_tab';
|
||||||
static const _prefSwipeLeftAction = 'ui_swipe_left_action';
|
static const _prefSwipeLeftAction = 'ui_swipe_left_action';
|
||||||
@@ -47,6 +49,9 @@ class SettingsController extends ChangeNotifier {
|
|||||||
Set<AppTab> _hiddenTabs = {};
|
Set<AppTab> _hiddenTabs = {};
|
||||||
AppTab _defaultTab = AppTab.files;
|
AppTab _defaultTab = AppTab.files;
|
||||||
|
|
||||||
|
List<SelectionActionKind> _selectionActionOrder = SelectionActionKind.values
|
||||||
|
.toList();
|
||||||
|
|
||||||
SwipeAction _swipeLeftAction = SwipeAction.delete;
|
SwipeAction _swipeLeftAction = SwipeAction.delete;
|
||||||
SwipeAction _swipeRightAction = SwipeAction.favorite;
|
SwipeAction _swipeRightAction = SwipeAction.favorite;
|
||||||
|
|
||||||
@@ -86,6 +91,12 @@ class SettingsController extends ChangeNotifier {
|
|||||||
Set<AppTab> get hiddenTabs => _hiddenTabs;
|
Set<AppTab> get hiddenTabs => _hiddenTabs;
|
||||||
AppTab get defaultTab => _defaultTab;
|
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.
|
/// The tabs the bottom nav bar should actually show, in order.
|
||||||
List<AppTab> get visibleTabs =>
|
List<AppTab> get visibleTabs =>
|
||||||
_tabOrder.where((t) => !_hiddenTabs.contains(t)).toList();
|
_tabOrder.where((t) => !_hiddenTabs.contains(t)).toList();
|
||||||
@@ -134,6 +145,25 @@ class SettingsController extends ChangeNotifier {
|
|||||||
_tabOrder = order;
|
_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);
|
final savedHiddenNames = prefs.getStringList(_prefHiddenTabs);
|
||||||
if (savedHiddenNames != null) {
|
if (savedHiddenNames != null) {
|
||||||
_hiddenTabs = savedHiddenNames
|
_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
|
/// 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,
|
/// remaining visible tab. If the tab being hidden is the current default,
|
||||||
/// the default falls back to the next visible tab.
|
/// the default falls back to the next visible tab.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import '../widgets/noo/nav/noo_toolbar.dart';
|
|||||||
import '../widgets/noo/noo_layout.dart';
|
import '../widgets/noo/noo_layout.dart';
|
||||||
import '../widgets/settings/settings_account_card.dart';
|
import '../widgets/settings/settings_account_card.dart';
|
||||||
import '../widgets/settings/settings_accounts.dart';
|
import '../widgets/settings/settings_accounts.dart';
|
||||||
|
import '../widgets/settings/settings_action_bar.dart';
|
||||||
import '../widgets/settings/settings_appearance.dart';
|
import '../widgets/settings/settings_appearance.dart';
|
||||||
import '../widgets/settings/settings_file_sync.dart';
|
import '../widgets/settings/settings_file_sync.dart';
|
||||||
import '../widgets/settings/settings_files_cache.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_swipe.dart';
|
||||||
import '../widgets/settings/settings_tabs.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,
|
/// 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
|
/// mobile; a 2-column grid of cards on desktop - see each
|
||||||
/// `widgets/settings/*.dart` file for a section's own content and any
|
/// `widgets/settings/*.dart` file for a section's own content and any
|
||||||
/// setting that had to be slotted in or grouped under "Advanced appearance".
|
/// setting that had to be slotted in or grouped under "Advanced appearance".
|
||||||
@@ -29,6 +32,7 @@ class AccountView extends StatelessWidget {
|
|||||||
SettingsFilesCacheSection(),
|
SettingsFilesCacheSection(),
|
||||||
SettingsAppearanceSection(),
|
SettingsAppearanceSection(),
|
||||||
SettingsTabsSection(),
|
SettingsTabsSection(),
|
||||||
|
SettingsActionBarSection(),
|
||||||
SettingsSwipeSection(),
|
SettingsSwipeSection(),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -48,7 +52,9 @@ class AccountView extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
top: false,
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ListView(
|
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(),
|
physics: const BouncingScrollPhysics(),
|
||||||
children: [
|
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(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
for (final child in children) ...[child, const SizedBox(height: NooSpace.xl)],
|
for (final child in children) ...[
|
||||||
|
child,
|
||||||
|
const SizedBox(height: NooSpace.xl),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,13 +96,15 @@ class _FavoritesViewState extends State<FavoritesView> {
|
|||||||
/// The bulk actions shown in the sticky selection toolbar for the
|
/// The bulk actions shown in the sticky selection toolbar for the
|
||||||
/// currently-selected items - same set Files/Photos offer.
|
/// currently-selected items - same set Files/Photos offer.
|
||||||
List<SelectionAction> _buildSelectionActions(List<NextcloudItem> selected) {
|
List<SelectionAction> _buildSelectionActions(List<NextcloudItem> selected) {
|
||||||
return [
|
final actions = [
|
||||||
SelectionAction(
|
SelectionAction(
|
||||||
|
kind: SelectionActionKind.favorite,
|
||||||
icon: LucideIcons.starOff,
|
icon: LucideIcons.starOff,
|
||||||
label: 'Remove from favorites',
|
label: 'Remove from favorites',
|
||||||
onTap: () => _unfavoriteSelected(selected),
|
onTap: () => _unfavoriteSelected(selected),
|
||||||
),
|
),
|
||||||
SelectionAction(
|
SelectionAction(
|
||||||
|
kind: SelectionActionKind.share,
|
||||||
icon: LucideIcons.share2,
|
icon: LucideIcons.share2,
|
||||||
label: 'Share',
|
label: 'Share',
|
||||||
onTap: () => selected.length == 1
|
onTap: () => selected.length == 1
|
||||||
@@ -110,32 +112,41 @@ class _FavoritesViewState extends State<FavoritesView> {
|
|||||||
: _shareSelected(context, selected),
|
: _shareSelected(context, selected),
|
||||||
),
|
),
|
||||||
SelectionAction(
|
SelectionAction(
|
||||||
|
kind: SelectionActionKind.download,
|
||||||
icon: LucideIcons.download,
|
icon: LucideIcons.download,
|
||||||
label: 'Download',
|
label: 'Download',
|
||||||
onTap: () => _downloadSelected(context, selected),
|
onTap: () => _downloadSelected(context, selected),
|
||||||
),
|
),
|
||||||
SelectionAction(
|
SelectionAction(
|
||||||
|
kind: SelectionActionKind.delete,
|
||||||
icon: LucideIcons.trash2,
|
icon: LucideIcons.trash2,
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
onTap: () => _confirmDeleteSelected(context, selected),
|
onTap: () => _confirmDeleteSelected(context, selected),
|
||||||
),
|
),
|
||||||
SelectionAction(
|
SelectionAction(
|
||||||
|
kind: SelectionActionKind.copy,
|
||||||
icon: LucideIcons.copy,
|
icon: LucideIcons.copy,
|
||||||
label: 'Copy',
|
label: 'Copy',
|
||||||
onTap: () => _moveOrCopySelected(selected, copy: true),
|
onTap: () => _moveOrCopySelected(selected, copy: true),
|
||||||
),
|
),
|
||||||
SelectionAction(
|
SelectionAction(
|
||||||
|
kind: SelectionActionKind.move,
|
||||||
icon: LucideIcons.folderInput,
|
icon: LucideIcons.folderInput,
|
||||||
label: 'Move',
|
label: 'Move',
|
||||||
onTap: () => _moveOrCopySelected(selected, copy: false),
|
onTap: () => _moveOrCopySelected(selected, copy: false),
|
||||||
),
|
),
|
||||||
if (selected.length == 1)
|
if (selected.length == 1)
|
||||||
SelectionAction(
|
SelectionAction(
|
||||||
|
kind: SelectionActionKind.details,
|
||||||
icon: LucideIcons.info,
|
icon: LucideIcons.info,
|
||||||
label: 'Details',
|
label: 'Details',
|
||||||
onTap: () => DetailsSheet.show(context, selected.single),
|
onTap: () => DetailsSheet.show(context, selected.single),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
return orderSelectionActions(
|
||||||
|
actions,
|
||||||
|
context.read<SettingsController>().selectionActionOrder,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _unfavoriteSelected(List<NextcloudItem> items) async {
|
Future<void> _unfavoriteSelected(List<NextcloudItem> items) async {
|
||||||
|
|||||||
@@ -419,6 +419,7 @@ class _FilesViewState extends State<FilesView>
|
|||||||
if (pick.isPicking) {
|
if (pick.isPicking) {
|
||||||
return [
|
return [
|
||||||
SelectionAction(
|
SelectionAction(
|
||||||
|
kind: SelectionActionKind.favorite,
|
||||||
icon: LucideIcons.check,
|
icon: LucideIcons.check,
|
||||||
label: 'Use ${selected.length} item(s)',
|
label: 'Use ${selected.length} item(s)',
|
||||||
onTap: () => pick.confirmPick(selected),
|
onTap: () => pick.confirmPick(selected),
|
||||||
@@ -427,13 +428,15 @@ class _FilesViewState extends State<FilesView>
|
|||||||
}
|
}
|
||||||
final allFavorited = selected.every((i) => i.isFavorite);
|
final allFavorited = selected.every((i) => i.isFavorite);
|
||||||
final allSynced = selected.every((i) => sync.isPathSynced(i.path));
|
final allSynced = selected.every((i) => sync.isPathSynced(i.path));
|
||||||
return [
|
final actions = [
|
||||||
SelectionAction(
|
SelectionAction(
|
||||||
|
kind: SelectionActionKind.favorite,
|
||||||
icon: allFavorited ? LucideIcons.starOff : LucideIcons.star,
|
icon: allFavorited ? LucideIcons.starOff : LucideIcons.star,
|
||||||
label: allFavorited ? 'Remove from favorites' : 'Favorite',
|
label: allFavorited ? 'Remove from favorites' : 'Favorite',
|
||||||
onTap: () => _favoriteSelected(context, selected),
|
onTap: () => _favoriteSelected(context, selected),
|
||||||
),
|
),
|
||||||
SelectionAction(
|
SelectionAction(
|
||||||
|
kind: SelectionActionKind.share,
|
||||||
icon: LucideIcons.share2,
|
icon: LucideIcons.share2,
|
||||||
label: 'Share',
|
label: 'Share',
|
||||||
onTap: () => selected.length == 1
|
onTap: () => selected.length == 1
|
||||||
@@ -441,43 +444,54 @@ class _FilesViewState extends State<FilesView>
|
|||||||
: _shareSelected(context, selected),
|
: _shareSelected(context, selected),
|
||||||
),
|
),
|
||||||
SelectionAction(
|
SelectionAction(
|
||||||
|
kind: SelectionActionKind.download,
|
||||||
icon: LucideIcons.download,
|
icon: LucideIcons.download,
|
||||||
label: 'Download',
|
label: 'Download',
|
||||||
onTap: () => _downloadSelected(context, selected),
|
onTap: () => _downloadSelected(context, selected),
|
||||||
),
|
),
|
||||||
SelectionAction(
|
SelectionAction(
|
||||||
|
kind: SelectionActionKind.delete,
|
||||||
icon: LucideIcons.trash2,
|
icon: LucideIcons.trash2,
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
onTap: () => _confirmDeleteSelected(context, selected),
|
onTap: () => _confirmDeleteSelected(context, selected),
|
||||||
),
|
),
|
||||||
SelectionAction(
|
SelectionAction(
|
||||||
|
kind: SelectionActionKind.copy,
|
||||||
icon: LucideIcons.copy,
|
icon: LucideIcons.copy,
|
||||||
label: 'Copy',
|
label: 'Copy',
|
||||||
onTap: () => _moveOrCopySelected(context, selected, copy: true),
|
onTap: () => _moveOrCopySelected(context, selected, copy: true),
|
||||||
),
|
),
|
||||||
SelectionAction(
|
SelectionAction(
|
||||||
|
kind: SelectionActionKind.move,
|
||||||
icon: LucideIcons.folderInput,
|
icon: LucideIcons.folderInput,
|
||||||
label: 'Move',
|
label: 'Move',
|
||||||
onTap: () => _moveOrCopySelected(context, selected, copy: false),
|
onTap: () => _moveOrCopySelected(context, selected, copy: false),
|
||||||
),
|
),
|
||||||
if (selected.length == 1)
|
if (selected.length == 1)
|
||||||
SelectionAction(
|
SelectionAction(
|
||||||
|
kind: SelectionActionKind.rename,
|
||||||
icon: LucideIcons.filePen,
|
icon: LucideIcons.filePen,
|
||||||
label: 'Rename',
|
label: 'Rename',
|
||||||
onTap: () => _renameItem(selected.single),
|
onTap: () => _renameItem(selected.single),
|
||||||
),
|
),
|
||||||
SelectionAction(
|
SelectionAction(
|
||||||
|
kind: SelectionActionKind.sync,
|
||||||
icon: LucideIcons.hardDriveDownload,
|
icon: LucideIcons.hardDriveDownload,
|
||||||
label: allSynced ? 'Stop syncing to device' : 'Sync to device',
|
label: allSynced ? 'Stop syncing to device' : 'Sync to device',
|
||||||
onTap: () => _toggleSyncSelected(context, sync, selected),
|
onTap: () => _toggleSyncSelected(context, sync, selected),
|
||||||
),
|
),
|
||||||
if (selected.length == 1)
|
if (selected.length == 1)
|
||||||
SelectionAction(
|
SelectionAction(
|
||||||
|
kind: SelectionActionKind.details,
|
||||||
icon: LucideIcons.info,
|
icon: LucideIcons.info,
|
||||||
label: 'Details',
|
label: 'Details',
|
||||||
onTap: () => DetailsSheet.show(context, selected.single),
|
onTap: () => DetailsSheet.show(context, selected.single),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
return orderSelectionActions(
|
||||||
|
actions,
|
||||||
|
context.read<SettingsController>().selectionActionOrder,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _renameItem(NextcloudItem item) async {
|
Future<void> _renameItem(NextcloudItem item) async {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import '../providers/item_operations.dart';
|
|||||||
import '../providers/photos_controller.dart';
|
import '../providers/photos_controller.dart';
|
||||||
import '../providers/pick_controller.dart';
|
import '../providers/pick_controller.dart';
|
||||||
import '../providers/session_controller.dart';
|
import '../providers/session_controller.dart';
|
||||||
|
import '../providers/settings_controller.dart';
|
||||||
import '../services/download_service.dart';
|
import '../services/download_service.dart';
|
||||||
import '../theme/design_tokens.dart';
|
import '../theme/design_tokens.dart';
|
||||||
import '../widgets/details/details_sheet.dart';
|
import '../widgets/details/details_sheet.dart';
|
||||||
@@ -113,6 +114,7 @@ class _PhotosViewState extends State<PhotosView> {
|
|||||||
if (pick.isPicking) {
|
if (pick.isPicking) {
|
||||||
return [
|
return [
|
||||||
SelectionAction(
|
SelectionAction(
|
||||||
|
kind: SelectionActionKind.favorite,
|
||||||
icon: LucideIcons.check,
|
icon: LucideIcons.check,
|
||||||
label: 'Use ${selected.length} item(s)',
|
label: 'Use ${selected.length} item(s)',
|
||||||
onTap: () => pick.confirmPick(selected),
|
onTap: () => pick.confirmPick(selected),
|
||||||
@@ -123,13 +125,15 @@ class _PhotosViewState extends State<PhotosView> {
|
|||||||
// uses for favorite/unfavorite (see `files_view.dart`) rather than a
|
// uses for favorite/unfavorite (see `files_view.dart`) rather than a
|
||||||
// filled heart.
|
// filled heart.
|
||||||
final allFavorited = selected.every((i) => i.isFavorite);
|
final allFavorited = selected.every((i) => i.isFavorite);
|
||||||
return [
|
final actions = [
|
||||||
SelectionAction(
|
SelectionAction(
|
||||||
|
kind: SelectionActionKind.favorite,
|
||||||
icon: allFavorited ? LucideIcons.starOff : LucideIcons.star,
|
icon: allFavorited ? LucideIcons.starOff : LucideIcons.star,
|
||||||
label: allFavorited ? 'Remove from favorites' : 'Favorite',
|
label: allFavorited ? 'Remove from favorites' : 'Favorite',
|
||||||
onTap: () => _favoriteSelected(selected),
|
onTap: () => _favoriteSelected(selected),
|
||||||
),
|
),
|
||||||
SelectionAction(
|
SelectionAction(
|
||||||
|
kind: SelectionActionKind.share,
|
||||||
icon: LucideIcons.share2,
|
icon: LucideIcons.share2,
|
||||||
label: 'Share',
|
label: 'Share',
|
||||||
onTap: () => selected.length == 1
|
onTap: () => selected.length == 1
|
||||||
@@ -137,32 +141,41 @@ class _PhotosViewState extends State<PhotosView> {
|
|||||||
: _shareSelected(context, selected),
|
: _shareSelected(context, selected),
|
||||||
),
|
),
|
||||||
SelectionAction(
|
SelectionAction(
|
||||||
|
kind: SelectionActionKind.download,
|
||||||
icon: LucideIcons.download,
|
icon: LucideIcons.download,
|
||||||
label: 'Download',
|
label: 'Download',
|
||||||
onTap: () => _downloadSelected(context, selected),
|
onTap: () => _downloadSelected(context, selected),
|
||||||
),
|
),
|
||||||
SelectionAction(
|
SelectionAction(
|
||||||
|
kind: SelectionActionKind.delete,
|
||||||
icon: LucideIcons.trash2,
|
icon: LucideIcons.trash2,
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
onTap: () => _confirmDeleteSelected(context, selected),
|
onTap: () => _confirmDeleteSelected(context, selected),
|
||||||
),
|
),
|
||||||
SelectionAction(
|
SelectionAction(
|
||||||
|
kind: SelectionActionKind.copy,
|
||||||
icon: LucideIcons.copy,
|
icon: LucideIcons.copy,
|
||||||
label: 'Copy',
|
label: 'Copy',
|
||||||
onTap: () => _moveOrCopySelected(selected, copy: true),
|
onTap: () => _moveOrCopySelected(selected, copy: true),
|
||||||
),
|
),
|
||||||
SelectionAction(
|
SelectionAction(
|
||||||
|
kind: SelectionActionKind.move,
|
||||||
icon: LucideIcons.folderInput,
|
icon: LucideIcons.folderInput,
|
||||||
label: 'Move',
|
label: 'Move',
|
||||||
onTap: () => _moveOrCopySelected(selected, copy: false),
|
onTap: () => _moveOrCopySelected(selected, copy: false),
|
||||||
),
|
),
|
||||||
if (selected.length == 1)
|
if (selected.length == 1)
|
||||||
SelectionAction(
|
SelectionAction(
|
||||||
|
kind: SelectionActionKind.details,
|
||||||
icon: LucideIcons.info,
|
icon: LucideIcons.info,
|
||||||
label: 'Details',
|
label: 'Details',
|
||||||
onTap: () => DetailsSheet.show(context, selected.single),
|
onTap: () => DetailsSheet.show(context, selected.single),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
return orderSelectionActions(
|
||||||
|
actions,
|
||||||
|
context.read<SettingsController>().selectionActionOrder,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Splits an already-filtered/sorted list into contiguous month runs
|
/// Splits an already-filtered/sorted list into contiguous month runs
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ class NooTabOrderRow extends StatelessWidget {
|
|||||||
final bool pinned;
|
final bool pinned;
|
||||||
final VoidCallback? onTogglePin;
|
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
|
/// This row's index inside its `ReorderableListView` - when set, only the
|
||||||
/// grip icon starts a drag (`ReorderableDragStartListener` wraps just the
|
/// grip icon starts a drag (`ReorderableDragStartListener` wraps just the
|
||||||
/// icon, not the whole row), so a vertical drag/scroll starting anywhere
|
/// icon, not the whole row), so a vertical drag/scroll starting anywhere
|
||||||
@@ -25,6 +30,7 @@ class NooTabOrderRow extends StatelessWidget {
|
|||||||
required this.label,
|
required this.label,
|
||||||
this.pinned = false,
|
this.pinned = false,
|
||||||
this.onTogglePin,
|
this.onTogglePin,
|
||||||
|
this.showPin = true,
|
||||||
this.dragIndex,
|
this.dragIndex,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -50,6 +56,7 @@ class NooTabOrderRow extends StatelessWidget {
|
|||||||
style: NooText.bodyL.copyWith(height: 1, color: colors.fg1),
|
style: NooText.bodyL.copyWith(height: 1, color: colors.fg1),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (showPin)
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: onTogglePin,
|
onTap: onTogglePin,
|
||||||
child: Container(
|
child: Container(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,12 +14,15 @@ import 'package:noo/widgets/noo/lists/noo_tab_order_row.dart';
|
|||||||
import 'noo_test_utils.dart';
|
import 'noo_test_utils.dart';
|
||||||
|
|
||||||
/// [n] actions labelled A, B, C, ... each incrementing an entry in [taps]
|
/// [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<SelectionAction> _actions(int n, Map<String, int> taps) =>
|
||||||
List.generate(n, (i) {
|
List.generate(n, (i) {
|
||||||
final label = String.fromCharCode(65 + i);
|
final label = String.fromCharCode(65 + i);
|
||||||
taps[label] = 0;
|
taps[label] = 0;
|
||||||
return SelectionAction(
|
return SelectionAction(
|
||||||
|
kind: SelectionActionKind.values[i],
|
||||||
icon: LucideIcons.star,
|
icon: LucideIcons.star,
|
||||||
label: label,
|
label: label,
|
||||||
onTap: () => taps[label] = taps[label]! + 1,
|
onTap: () => taps[label] = taps[label]! + 1,
|
||||||
|
|||||||
Reference in New Issue
Block a user