diff --git a/lib/app_mode.dart b/lib/app_mode.dart index 5238cf612..b93ec33e6 100644 --- a/lib/app_mode.dart +++ b/lib/app_mode.dart @@ -3,6 +3,8 @@ enum AppMode { main, pickExternal, pickInternal, view } extension ExtraAppMode on AppMode { bool get canSearch => this == AppMode.main || this == AppMode.pickExternal; + bool get canSelect => this == AppMode.main; + bool get hasDrawer => this == AppMode.main || this == AppMode.pickExternal; bool get isPicking => this == AppMode.pickExternal || this == AppMode.pickInternal; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index d53ea52a1..e6feef825 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -735,6 +735,13 @@ "settingsThumbnailShowVideoDuration": "Show video duration", "@settingsThumbnailShowVideoDuration": {}, + "settingsCollectionBrowsingQuickActionsTile": "Quick actions for item browsing", + "@settingsCollectionBrowsingQuickActionsTile": {}, + "settingsCollectionBrowsingQuickActionEditorTitle": "Quick Actions", + "@settingsCollectionBrowsingQuickActionEditorTitle": {}, + "settingsCollectionBrowsingQuickActionEditorBanner": "Touch and hold to move buttons and select which actions are displayed when browsing items.", + "@settingsCollectionBrowsingQuickActionEditorBanner": {}, + "settingsCollectionSelectionQuickActionsTile": "Quick actions for item selection", "@settingsCollectionSelectionQuickActionsTile": {}, "settingsCollectionSelectionQuickActionEditorTitle": "Quick Actions", diff --git a/lib/model/actions/entry_set_actions.dart b/lib/model/actions/entry_set_actions.dart index 180ea59e1..f272b98cd 100644 --- a/lib/model/actions/entry_set_actions.dart +++ b/lib/model/actions/entry_set_actions.dart @@ -1,6 +1,6 @@ import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; enum EntrySetAction { // general @@ -9,12 +9,13 @@ enum EntrySetAction { select, selectAll, selectNone, - // all + // browsing + search, addShortcut, - // all or entry selection + // browsing or selecting map, stats, - // entry selection + // selecting share, delete, copy, @@ -28,6 +29,21 @@ enum EntrySetAction { } class EntrySetActions { + static const general = [ + EntrySetAction.sort, + EntrySetAction.group, + EntrySetAction.select, + EntrySetAction.selectAll, + EntrySetAction.selectNone, + ]; + + static const browsing = [ + EntrySetAction.search, + EntrySetAction.addShortcut, + EntrySetAction.map, + EntrySetAction.stats, + ]; + static const selection = [ EntrySetAction.share, EntrySetAction.delete, @@ -36,6 +52,7 @@ class EntrySetActions { EntrySetAction.rescan, EntrySetAction.map, EntrySetAction.stats, + // editing actions are in their subsection ]; } @@ -53,15 +70,17 @@ extension ExtraEntrySetAction on EntrySetAction { return context.l10n.menuActionSelectAll; case EntrySetAction.selectNone: return context.l10n.menuActionSelectNone; - // all + // browsing + case EntrySetAction.search: + return MaterialLocalizations.of(context).searchFieldLabel; case EntrySetAction.addShortcut: return context.l10n.collectionActionAddShortcut; - // all or entry selection + // browsing or selecting case EntrySetAction.map: return context.l10n.menuActionMap; case EntrySetAction.stats: return context.l10n.menuActionStats; - // entry selection + // selecting case EntrySetAction.share: return context.l10n.entryActionShare; case EntrySetAction.delete: @@ -102,15 +121,17 @@ extension ExtraEntrySetAction on EntrySetAction { return AIcons.selected; case EntrySetAction.selectNone: return AIcons.unselected; - // all + // browsing + case EntrySetAction.search: + return AIcons.search; case EntrySetAction.addShortcut: return AIcons.addShortcut; - // all or entry selection + // browsing or selecting case EntrySetAction.map: return AIcons.map; case EntrySetAction.stats: return AIcons.stats; - // entry selection + // selecting case EntrySetAction.share: return AIcons.share; case EntrySetAction.delete: diff --git a/lib/model/entry.dart b/lib/model/entry.dart index 3b40805a7..6357ae9a7 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -549,14 +549,6 @@ class AvesEntry { }.whereNotNull().where((v) => v.isNotEmpty).join(', '); } - bool search(String query) => { - bestTitle, - _catalogMetadata?.xmpSubjects, - _addressDetails?.countryName, - _addressDetails?.adminArea, - _addressDetails?.locality, - }.any((s) => s != null && s.toUpperCase().contains(query)); - Future _applyNewFields(Map newFields, {required bool persist}) async { final oldDateModifiedSecs = this.dateModifiedSecs; final oldRotationDegrees = this.rotationDegrees; diff --git a/lib/model/filters/query.dart b/lib/model/filters/query.dart index f4d9497eb..0ea2ea03f 100644 --- a/lib/model/filters/query.dart +++ b/lib/model/filters/query.dart @@ -1,3 +1,4 @@ +import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; @@ -19,7 +20,7 @@ class QueryFilter extends CollectionFilter { QueryFilter(this.query, {this.colorful = true}) { var upQuery = query.toUpperCase(); - if (upQuery.startsWith('ID=')) { + if (upQuery.startsWith('id:')) { final id = int.tryParse(upQuery.substring(3)); _test = (entry) => entry.contentId == id; return; @@ -37,7 +38,9 @@ class QueryFilter extends CollectionFilter { upQuery = matches.first.group(1)!; } - _test = not ? (entry) => !entry.search(upQuery) : (entry) => entry.search(upQuery); + // default to title search + bool testTitle(AvesEntry entry) => entry.bestTitle?.toUpperCase().contains(upQuery) == true; + _test = not ? (entry) => !testTitle(entry) : testTitle; } QueryFilter.fromMap(Map json) diff --git a/lib/model/settings/defaults.dart b/lib/model/settings/defaults.dart index 347d8fd5e..b4d6ee2d4 100644 --- a/lib/model/settings/defaults.dart +++ b/lib/model/settings/defaults.dart @@ -34,6 +34,9 @@ class SettingsDefaults { // collection static const collectionSectionFactor = EntryGroupFactor.month; static const collectionSortFactor = EntrySortFactor.date; + static const collectionBrowsingQuickActions = [ + EntrySetAction.search, + ]; static const collectionSelectionQuickActions = [ EntrySetAction.share, EntrySetAction.delete, diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index cccc52f81..28cea9c90 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -57,6 +57,7 @@ class Settings extends ChangeNotifier { // collection static const collectionGroupFactorKey = 'collection_group_factor'; static const collectionSortFactorKey = 'collection_sort_factor'; + static const collectionBrowsingQuickActionsKey = 'collection_browsing_quick_actions'; static const collectionSelectionQuickActionsKey = 'collection_selection_quick_actions'; static const showThumbnailLocationKey = 'show_thumbnail_location'; static const showThumbnailMotionPhotoKey = 'show_thumbnail_motion_photo'; @@ -265,6 +266,10 @@ class Settings extends ChangeNotifier { set collectionSortFactor(EntrySortFactor newValue) => setAndNotify(collectionSortFactorKey, newValue.toString()); + List get collectionBrowsingQuickActions => getEnumListOrDefault(collectionBrowsingQuickActionsKey, SettingsDefaults.collectionBrowsingQuickActions, EntrySetAction.values); + + set collectionBrowsingQuickActions(List newValue) => setAndNotify(collectionBrowsingQuickActionsKey, newValue.map((v) => v.toString()).toList()); + List get collectionSelectionQuickActions => getEnumListOrDefault(collectionSelectionQuickActionsKey, SettingsDefaults.collectionSelectionQuickActions, EntrySetAction.values); set collectionSelectionQuickActions(List newValue) => setAndNotify(collectionSelectionQuickActionsKey, newValue.map((v) => v.toString()).toList()); @@ -613,6 +618,7 @@ class Settings extends ChangeNotifier { case drawerPageBookmarksKey: case pinnedFiltersKey: case hiddenFiltersKey: + case collectionBrowsingQuickActionsKey: case collectionSelectionQuickActionsKey: case viewerQuickActionsKey: case videoQuickActionsKey: diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index a50bd38cb..8e72df6d5 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -3,7 +3,6 @@ import 'dart:async'; import 'package:aves/app_mode.dart'; import 'package:aves/model/actions/entry_set_actions.dart'; import 'package:aves/model/entry.dart'; -import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/selection.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; @@ -18,9 +17,7 @@ import 'package:aves/widgets/common/app_bar_subtitle.dart'; import 'package:aves/widgets/common/app_bar_title.dart'; import 'package:aves/widgets/common/basic/menu.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; -import 'package:aves/widgets/search/search_button.dart'; import 'package:aves/widgets/search/search_delegate.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -93,27 +90,39 @@ class _CollectionAppBarState extends State with SingleTickerPr @override Widget build(BuildContext context) { final appMode = context.watch>().value; - return Selector, bool>( - selector: (context, selection) => selection.isSelecting, - builder: (context, isSelecting, child) { + return Selector, Tuple2>( + selector: (context, selection) => Tuple2(selection.isSelecting, selection.selectedItems.length), + builder: (context, s, child) { + final isSelecting = s.item1; + final selectedItemCount = s.item2; _isSelectingNotifier.value = isSelecting; return AnimatedBuilder( animation: collection.filterChangeNotifier, builder: (context, child) { final removableFilters = appMode != AppMode.pickInternal; - return SliverAppBar( - leading: appMode.hasDrawer ? _buildAppBarLeading(isSelecting) : null, - title: _buildAppBarTitle(isSelecting), - actions: _buildActions(isSelecting), - bottom: hasFilters - ? FilterBar( - filters: collection.filters, - removable: removableFilters, - onTap: removableFilters ? collection.removeFilter : null, - ) - : null, - titleSpacing: 0, - floating: true, + return FutureBuilder( + future: _canAddShortcutsLoader, + builder: (context, snapshot) { + final canAddShortcuts = snapshot.data ?? false; + return SliverAppBar( + leading: appMode.hasDrawer ? _buildAppBarLeading(isSelecting) : null, + title: _buildAppBarTitle(isSelecting), + actions: _buildActions( + isSelecting: isSelecting, + selectedItemCount: selectedItemCount, + supportShortcuts: canAddShortcuts, + ), + bottom: hasFilters + ? FilterBar( + filters: collection.filters, + removable: removableFilters, + onTap: removableFilters ? collection.removeFilter : null, + ) + : null, + titleSpacing: 0, + floating: true, + ); + }, ); }, ); @@ -167,114 +176,114 @@ class _CollectionAppBarState extends State with SingleTickerPr } } - List _buildActions(bool isSelecting) { + List _buildActions({ + required bool isSelecting, + required int selectedItemCount, + required bool supportShortcuts, + }) { final appMode = context.watch>().value; - final selectionQuickActions = settings.collectionSelectionQuickActions; - return [ - if (!isSelecting && appMode.canSearch) - CollectionSearchButton( - source: source, - parentCollection: collection, - ), - if (isSelecting) - ...selectionQuickActions.map((action) => Selector, bool>( - selector: (context, selection) => selection.selectedItems.isEmpty, - builder: (context, isEmpty, child) => IconButton( - icon: action.getIcon(), - onPressed: isEmpty ? null : () => _onCollectionActionSelected(action), - tooltip: action.getText(context), - ), - )), - FutureBuilder( - future: _canAddShortcutsLoader, - builder: (context, snapshot) { - final canAddShortcuts = snapshot.data ?? false; - return MenuIconTheme( - child: PopupMenuButton( - // key is expected by test driver - key: const Key('appbar-menu-button'), - itemBuilder: (context) { - final groupable = collection.sortFactor == EntrySortFactor.date; - final selection = context.read>(); - final isSelecting = selection.isSelecting; - final selectedItems = selection.selectedItems; - final hasSelection = selectedItems.isNotEmpty; - final hasItems = !collection.isEmpty; - final otherViewEnabled = (!isSelecting && hasItems) || (isSelecting && hasSelection); + bool isVisible(EntrySetAction action) => _actionDelegate.isVisible( + action, + appMode: appMode, + isSelecting: isSelecting, + supportShortcuts: supportShortcuts, + sortFactor: collection.sortFactor, + itemCount: collection.entryCount, + selectedItemCount: selectedItemCount, + ); + bool canApply(EntrySetAction action) => _actionDelegate.canApply( + action, + isSelecting: isSelecting, + itemCount: collection.entryCount, + selectedItemCount: selectedItemCount, + ); + final canApplyEditActions = selectedItemCount > 0; - return [ - _toMenuItem(EntrySetAction.sort), - if (groupable) _toMenuItem(EntrySetAction.group), - if (appMode == AppMode.main) ...[ - if (!isSelecting) - _toMenuItem( - EntrySetAction.select, - enabled: hasItems, - ), - const PopupMenuDivider(), - if (isSelecting) ...[ - ...EntrySetActions.selection.where((v) => !selectionQuickActions.contains(v)).map((v) => _toMenuItem(v, enabled: hasSelection)), - PopupMenuItem( - enabled: hasSelection, - padding: EdgeInsets.zero, - child: PopupMenuItemExpansionPanel( - enabled: hasSelection, - icon: AIcons.edit, - title: context.l10n.collectionActionEdit, - items: [ - _buildRotateAndFlipMenuItems(context, enabled: hasSelection), - _toMenuItem(EntrySetAction.editDate, enabled: hasSelection), - _toMenuItem(EntrySetAction.removeMetadata, enabled: hasSelection), - ], - ), - ), - ], - if (!isSelecting) + final browsingQuickActions = settings.collectionBrowsingQuickActions; + final selectionQuickActions = settings.collectionSelectionQuickActions; + final quickActions = (isSelecting ? selectionQuickActions : browsingQuickActions).where(isVisible).map( + (action) => _toActionButton(action, enabled: canApply(action)), + ); + + return [ + ...quickActions, + MenuIconTheme( + child: PopupMenuButton( + // key is expected by test driver + key: const Key('appbar-menu-button'), + itemBuilder: (context) { + final generalMenuItems = EntrySetActions.general.where(isVisible).map( + (action) => _toMenuItem(action, enabled: canApply(action)), + ); + + final browsingMenuActions = EntrySetActions.browsing.where((v) => !browsingQuickActions.contains(v)); + final selectionMenuActions = EntrySetActions.selection.where((v) => !selectionQuickActions.contains(v)); + final contextualMenuItems = [ + ...(isSelecting ? selectionMenuActions : browsingMenuActions).where(isVisible).map( + (action) => _toMenuItem(action, enabled: canApply(action)), + ), + if (isSelecting) + PopupMenuItem( + enabled: canApplyEditActions, + padding: EdgeInsets.zero, + child: PopupMenuItemExpansionPanel( + enabled: canApplyEditActions, + icon: AIcons.edit, + title: context.l10n.collectionActionEdit, + items: [ + _buildRotateAndFlipMenuItems(context, canApply: canApply), ...[ - EntrySetAction.map, - EntrySetAction.stats, - ].map((v) => _toMenuItem(v, enabled: otherViewEnabled)), - if (!isSelecting && canAddShortcuts) ...[ - const PopupMenuDivider(), - _toMenuItem(EntrySetAction.addShortcut), + EntrySetAction.editDate, + EntrySetAction.removeMetadata, + ].map((action) => _toMenuItem(action, enabled: canApply(action))), ], - ], - if (isSelecting) ...[ - const PopupMenuDivider(), - _toMenuItem( - EntrySetAction.selectAll, - enabled: selectedItems.length < collection.entryCount, - ), - _toMenuItem( - EntrySetAction.selectNone, - enabled: hasSelection, - ), - ] - ]; - }, - onSelected: (action) async { - // wait for the popup menu to hide before proceeding with the action - await Future.delayed(Durations.popupMenuAnimation * timeDilation); - await _onCollectionActionSelected(action); - }, - ), - ); - }, + ), + ), + ]; + + return [ + ...generalMenuItems, + if (contextualMenuItems.isNotEmpty) ...[ + const PopupMenuDivider(), + ...contextualMenuItems, + ], + ]; + }, + onSelected: (action) async { + // wait for the popup menu to hide before proceeding with the action + await Future.delayed(Durations.popupMenuAnimation * timeDilation); + await _onCollectionActionSelected(action); + }, + ), ), ]; } + // key is expected by test driver (e.g. 'menu-sort', 'menu-group', 'menu-map') + Key _getActionKey(EntrySetAction action) => Key('menu-${action.toString().substring('EntrySetAction.'.length)}'); + + Widget _toActionButton(EntrySetAction action, {bool enabled = true}) { + return IconButton( + key: _getActionKey(action), + icon: action.getIcon(), + onPressed: enabled ? () => _onCollectionActionSelected(action) : null, + tooltip: action.getText(context), + ); + } + PopupMenuItem _toMenuItem(EntrySetAction action, {bool enabled = true}) { return PopupMenuItem( - // key is expected by test driver (e.g. 'menu-sort', 'menu-group', 'menu-map') - key: Key('menu-${action.toString().substring('EntrySetAction.'.length)}'), + key: _getActionKey(action), value: action, enabled: enabled, child: MenuRow(text: action.getText(context), icon: action.getIcon()), ); } - PopupMenuItem _buildRotateAndFlipMenuItems(BuildContext context, {required bool enabled}) { + PopupMenuItem _buildRotateAndFlipMenuItems( + BuildContext context, { + required bool Function(EntrySetAction action) canApply, + }) { Widget buildDivider() => const SizedBox( height: 16, child: VerticalDivider( @@ -286,7 +295,7 @@ class _CollectionAppBarState extends State with SingleTickerPr Widget buildItem(EntrySetAction action) => Expanded( child: PopupMenuItem( value: action, - enabled: enabled, + enabled: canApply(action), child: Tooltip( message: action.getText(context), child: Center(child: action.getIcon()), @@ -332,19 +341,12 @@ class _CollectionAppBarState extends State with SingleTickerPr Future _onCollectionActionSelected(EntrySetAction action) async { switch (action) { - case EntrySetAction.share: - case EntrySetAction.delete: - case EntrySetAction.copy: - case EntrySetAction.move: - case EntrySetAction.rescan: - case EntrySetAction.map: - case EntrySetAction.stats: - case EntrySetAction.rotateCCW: - case EntrySetAction.rotateCW: - case EntrySetAction.flip: - case EntrySetAction.editDate: - case EntrySetAction.removeMetadata: - _actionDelegate.onActionSelected(context, action); + // general + case EntrySetAction.sort: + await _sort(); + break; + case EntrySetAction.group: + await _group(); break; case EntrySetAction.select: context.read>().select(); @@ -355,77 +357,70 @@ class _CollectionAppBarState extends State with SingleTickerPr case EntrySetAction.selectNone: context.read>().clearSelection(); break; + // browsing + case EntrySetAction.search: case EntrySetAction.addShortcut: - unawaited(_showShortcutDialog(context)); - break; - case EntrySetAction.group: - final value = await showDialog( - context: context, - builder: (context) { - final l10n = context.l10n; - return AvesSelectionDialog( - initialValue: settings.collectionSectionFactor, - options: { - EntryGroupFactor.album: l10n.collectionGroupAlbum, - EntryGroupFactor.month: l10n.collectionGroupMonth, - EntryGroupFactor.day: l10n.collectionGroupDay, - EntryGroupFactor.none: l10n.collectionGroupNone, - }, - title: l10n.collectionGroupTitle, - ); - }, - ); - // wait for the dialog to hide as applying the change may block the UI - await Future.delayed(Durations.dialogTransitionAnimation * timeDilation); - if (value != null) { - settings.collectionSectionFactor = value; - } - break; - case EntrySetAction.sort: - final value = await showDialog( - context: context, - builder: (context) => AvesSelectionDialog( - initialValue: settings.collectionSortFactor, - options: { - EntrySortFactor.date: context.l10n.collectionSortDate, - EntrySortFactor.size: context.l10n.collectionSortSize, - EntrySortFactor.name: context.l10n.collectionSortName, - }, - title: context.l10n.collectionSortTitle, - ), - ); - // wait for the dialog to hide as applying the change may block the UI - await Future.delayed(Durations.dialogTransitionAnimation * timeDilation); - if (value != null) { - settings.collectionSortFactor = value; - } + // browsing or selecting + case EntrySetAction.map: + case EntrySetAction.stats: + // selecting + case EntrySetAction.share: + case EntrySetAction.delete: + case EntrySetAction.copy: + case EntrySetAction.move: + case EntrySetAction.rescan: + case EntrySetAction.rotateCCW: + case EntrySetAction.rotateCW: + case EntrySetAction.flip: + case EntrySetAction.editDate: + case EntrySetAction.removeMetadata: + _actionDelegate.onActionSelected(context, action); break; } } - Future _showShortcutDialog(BuildContext context) async { - final filters = collection.filters; - String? defaultName; - if (filters.isNotEmpty) { - // we compute the default name beforehand - // because some filter labels need localization - final sortedFilters = List.from(filters)..sort(); - defaultName = sortedFilters.first.getLabel(context).replaceAll('\n', ' '); - } - final result = await showDialog>( + Future _sort() async { + final value = await showDialog( context: context, - builder: (context) => AddShortcutDialog( - collection: collection, - defaultName: defaultName ?? '', + builder: (context) => AvesSelectionDialog( + initialValue: settings.collectionSortFactor, + options: { + EntrySortFactor.date: context.l10n.collectionSortDate, + EntrySortFactor.size: context.l10n.collectionSortSize, + EntrySortFactor.name: context.l10n.collectionSortName, + }, + title: context.l10n.collectionSortTitle, ), ); - if (result == null) return; + // wait for the dialog to hide as applying the change may block the UI + await Future.delayed(Durations.dialogTransitionAnimation * timeDilation); + if (value != null) { + settings.collectionSortFactor = value; + } + } - final coverEntry = result.item1; - final name = result.item2; - if (name.isEmpty) return; - - unawaited(androidAppService.pinToHomeScreen(name, coverEntry, filters)); + Future _group() async { + final value = await showDialog( + context: context, + builder: (context) { + final l10n = context.l10n; + return AvesSelectionDialog( + initialValue: settings.collectionSectionFactor, + options: { + EntryGroupFactor.album: l10n.collectionGroupAlbum, + EntryGroupFactor.month: l10n.collectionGroupMonth, + EntryGroupFactor.day: l10n.collectionGroupDay, + EntryGroupFactor.none: l10n.collectionGroupNone, + }, + title: l10n.collectionGroupTitle, + ); + }, + ); + // wait for the dialog to hide as applying the change may block the UI + await Future.delayed(Durations.dialogTransitionAnimation * timeDilation); + if (value != null) { + settings.collectionSectionFactor = value; + } } void _goToSearch() { diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index 00f9c5d47..b5a99bcbd 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -1,15 +1,18 @@ import 'dart:async'; import 'dart:io'; +import 'package:aves/app_mode.dart'; import 'package:aves/model/actions/entry_set_actions.dart'; import 'package:aves/model/actions/move_type.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/highlight.dart'; import 'package:aves/model/selection.dart'; import 'package:aves/model/source/analysis_controller.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/model/source/enums.dart'; import 'package:aves/services/common/image_op_events.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/services/media/enums.dart'; @@ -21,19 +24,129 @@ import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; import 'package:aves/widgets/common/action_mixins/size_aware.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/filter_grids/album_pick.dart'; import 'package:aves/widgets/map/map_page.dart'; +import 'package:aves/widgets/search/search_delegate.dart'; import 'package:aves/widgets/stats/stats_page.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:provider/provider.dart'; +import 'package:tuple/tuple.dart'; class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { + bool isVisible( + EntrySetAction action, { + required AppMode appMode, + required bool isSelecting, + required bool supportShortcuts, + required EntrySortFactor sortFactor, + required int itemCount, + required int selectedItemCount, + }) { + switch (action) { + // general + case EntrySetAction.sort: + return true; + case EntrySetAction.group: + return sortFactor == EntrySortFactor.date; + case EntrySetAction.select: + return appMode.canSelect && !isSelecting; + case EntrySetAction.selectAll: + return isSelecting && selectedItemCount < itemCount; + case EntrySetAction.selectNone: + return isSelecting && selectedItemCount == itemCount; + // browsing + case EntrySetAction.search: + return appMode.canSearch && !isSelecting; + case EntrySetAction.addShortcut: + return appMode == AppMode.main && !isSelecting && supportShortcuts; + // browsing or selecting + case EntrySetAction.map: + case EntrySetAction.stats: + return appMode == AppMode.main; + // selecting + case EntrySetAction.share: + case EntrySetAction.delete: + case EntrySetAction.copy: + case EntrySetAction.move: + case EntrySetAction.rescan: + case EntrySetAction.rotateCCW: + case EntrySetAction.rotateCW: + case EntrySetAction.flip: + case EntrySetAction.editDate: + case EntrySetAction.removeMetadata: + return appMode == AppMode.main && isSelecting; + } + } + + bool canApply( + EntrySetAction action, { + required bool isSelecting, + required int itemCount, + required int selectedItemCount, + }) { + final hasItems = itemCount > 0; + final hasSelection = selectedItemCount > 0; + + switch (action) { + case EntrySetAction.sort: + case EntrySetAction.group: + return true; + case EntrySetAction.select: + return hasItems; + case EntrySetAction.selectAll: + return selectedItemCount < itemCount; + case EntrySetAction.selectNone: + return hasSelection; + case EntrySetAction.search: + case EntrySetAction.addShortcut: + return true; + case EntrySetAction.map: + case EntrySetAction.stats: + return (!isSelecting && hasItems) || (isSelecting && hasSelection); + // selecting + case EntrySetAction.share: + case EntrySetAction.delete: + case EntrySetAction.copy: + case EntrySetAction.move: + case EntrySetAction.rescan: + case EntrySetAction.rotateCCW: + case EntrySetAction.rotateCW: + case EntrySetAction.flip: + case EntrySetAction.editDate: + case EntrySetAction.removeMetadata: + return hasSelection; + } + } + void onActionSelected(BuildContext context, EntrySetAction action) { switch (action) { + // general + case EntrySetAction.sort: + case EntrySetAction.group: + case EntrySetAction.select: + case EntrySetAction.selectAll: + case EntrySetAction.selectNone: + break; + // browsing + case EntrySetAction.search: + _goToSearch(context); + break; + case EntrySetAction.addShortcut: + _addShortcut(context); + break; + // browsing or selecting + case EntrySetAction.map: + _goToMap(context); + break; + case EntrySetAction.stats: + _goToStats(context); + break; + // selecting case EntrySetAction.share: _share(context); break; @@ -64,14 +177,6 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa case EntrySetAction.removeMetadata: _removeMetadata(context); break; - case EntrySetAction.map: - _goToMap(context); - break; - case EntrySetAction.stats: - _goToStats(context); - break; - default: - break; } } @@ -445,4 +550,45 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa ), ); } + + void _goToSearch(BuildContext context) { + final collection = context.read(); + + Navigator.push( + context, + SearchPageRoute( + delegate: CollectionSearchDelegate( + source: collection.source, + parentCollection: collection, + ), + ), + ); + } + + Future _addShortcut(BuildContext context) async { + final collection = context.read(); + final filters = collection.filters; + + String? defaultName; + if (filters.isNotEmpty) { + // we compute the default name beforehand + // because some filter labels need localization + final sortedFilters = List.from(filters)..sort(); + defaultName = sortedFilters.first.getLabel(context).replaceAll('\n', ' '); + } + final result = await showDialog>( + context: context, + builder: (context) => AddShortcutDialog( + collection: collection, + defaultName: defaultName ?? '', + ), + ); + if (result == null) return; + + final coverEntry = result.item1; + final name = result.item2; + if (name.isEmpty) return; + + unawaited(androidAppService.pinToHomeScreen(name, coverEntry, filters)); + } } diff --git a/lib/widgets/filter_grids/common/action_delegates/album_set.dart b/lib/widgets/filter_grids/common/action_delegates/album_set.dart index 334f455b7..e0ead6a48 100644 --- a/lib/widgets/filter_grids/common/action_delegates/album_set.dart +++ b/lib/widgets/filter_grids/common/action_delegates/album_set.dart @@ -39,19 +39,19 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate { set sortFactor(ChipSortFactor factor) => settings.albumSortFactor = factor; @override - bool isValid(Set filters, ChipSetAction action) { + bool isVisible(ChipSetAction action, Set filters) { switch (action) { case ChipSetAction.createAlbum: case ChipSetAction.delete: case ChipSetAction.rename: return true; default: - return super.isValid(filters, action); + return super.isVisible(action, filters); } } @override - bool canApply(Set filters, ChipSetAction action) { + bool canApply(ChipSetAction action, Set filters) { switch (action) { case ChipSetAction.rename: { @@ -61,7 +61,7 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate { return dir != null && dir.relativeDir.isNotEmpty; } default: - return super.canApply(filters, action); + return super.canApply(action, filters); } } diff --git a/lib/widgets/filter_grids/common/action_delegates/chip_set.dart b/lib/widgets/filter_grids/common/action_delegates/chip_set.dart index 9f18ae074..4169036c4 100644 --- a/lib/widgets/filter_grids/common/action_delegates/chip_set.dart +++ b/lib/widgets/filter_grids/common/action_delegates/chip_set.dart @@ -30,7 +30,7 @@ abstract class ChipSetActionDelegate with FeedbackMi set sortFactor(ChipSortFactor factor); - bool isValid(Set filters, ChipSetAction action) { + bool isVisible(ChipSetAction action, Set filters) { final hasSelection = filters.isNotEmpty; switch (action) { case ChipSetAction.createAlbum: @@ -46,7 +46,7 @@ abstract class ChipSetActionDelegate with FeedbackMi } } - bool canApply(Set filters, ChipSetAction action) { + bool canApply(ChipSetAction action, Set filters) { switch (action) { // general case ChipSetAction.sort: diff --git a/lib/widgets/filter_grids/common/app_bar.dart b/lib/widgets/filter_grids/common/app_bar.dart index cc5bbbfc5..c8d8fdd87 100644 --- a/lib/widgets/filter_grids/common/app_bar.dart +++ b/lib/widgets/filter_grids/common/app_bar.dart @@ -133,7 +133,7 @@ class _FilterGridAppBarState extends State toMenuItem(ChipSetAction action, {bool enabled = true}) { return PopupMenuItem( value: action, - enabled: enabled && actionDelegate.canApply(selectedFilters, action), + enabled: enabled && actionDelegate.canApply(action, selectedFilters), child: MenuRow(text: action.getText(context), icon: action.getIcon()), ); } @@ -151,10 +151,10 @@ class _FilterGridAppBarState extends State[]; if (isSelecting) { final selectedFilters = selection.selectedItems.map((v) => v.filter).toSet(); - final validActions = filterSelectionActions.where((action) => actionDelegate.isValid(selectedFilters, action)).toList(); - buttonActions.addAll(validActions.take(buttonActionCount).map( + final visibleActions = filterSelectionActions.where((action) => actionDelegate.isVisible(action, selectedFilters)).toList(); + buttonActions.addAll(visibleActions.take(buttonActionCount).map( (action) { - final enabled = actionDelegate.canApply(selectedFilters, action); + final enabled = actionDelegate.canApply(action, selectedFilters); return IconButton( icon: action.getIcon(), onPressed: enabled ? () => applyAction(action) : null, @@ -162,7 +162,7 @@ class _FilterGridAppBarState extends State extends State const SelectionActionEditorPage(), + ), + ); + }, + ); + } +} + +class SelectionActionEditorPage extends StatelessWidget { + static const routeName = '/settings/collection_selection_actions'; + + const SelectionActionEditorPage({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return QuickActionEditorPage( + title: context.l10n.settingsCollectionSelectionQuickActionEditorTitle, + bannerText: context.l10n.settingsCollectionSelectionQuickActionEditorBanner, + allAvailableActions: EntrySetActions.selection, + actionIcon: (action) => action.getIcon(), + actionText: (context, action) => action.getText(context), + load: () => settings.collectionSelectionQuickActions.toList(), + save: (actions) => settings.collectionSelectionQuickActions = actions, + ); + } +}