collection: custom quick actions for browsing

This commit is contained in:
Thibault Deckers 2021-11-01 17:16:18 +09:00
parent bd47d52412
commit 08020260a4
13 changed files with 446 additions and 227 deletions

View file

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

View file

@ -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",

View file

@ -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:

View file

@ -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<void> _applyNewFields(Map newFields, {required bool persist}) async {
final oldDateModifiedSecs = this.dateModifiedSecs;
final oldRotationDegrees = this.rotationDegrees;

View file

@ -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<String, dynamic> json)

View file

@ -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,

View file

@ -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<EntrySetAction> get collectionBrowsingQuickActions => getEnumListOrDefault(collectionBrowsingQuickActionsKey, SettingsDefaults.collectionBrowsingQuickActions, EntrySetAction.values);
set collectionBrowsingQuickActions(List<EntrySetAction> newValue) => setAndNotify(collectionBrowsingQuickActionsKey, newValue.map((v) => v.toString()).toList());
List<EntrySetAction> get collectionSelectionQuickActions => getEnumListOrDefault(collectionSelectionQuickActionsKey, SettingsDefaults.collectionSelectionQuickActions, EntrySetAction.values);
set collectionSelectionQuickActions(List<EntrySetAction> 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:

View file

@ -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<CollectionAppBar> with SingleTickerPr
@override
Widget build(BuildContext context) {
final appMode = context.watch<ValueNotifier<AppMode>>().value;
return Selector<Selection<AvesEntry>, bool>(
selector: (context, selection) => selection.isSelecting,
builder: (context, isSelecting, child) {
return Selector<Selection<AvesEntry>, Tuple2<bool, int>>(
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<bool>(
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<CollectionAppBar> with SingleTickerPr
}
}
List<Widget> _buildActions(bool isSelecting) {
List<Widget> _buildActions({
required bool isSelecting,
required int selectedItemCount,
required bool supportShortcuts,
}) {
final appMode = context.watch<ValueNotifier<AppMode>>().value;
final selectionQuickActions = settings.collectionSelectionQuickActions;
return [
if (!isSelecting && appMode.canSearch)
CollectionSearchButton(
source: source,
parentCollection: collection,
),
if (isSelecting)
...selectionQuickActions.map((action) => Selector<Selection<AvesEntry>, 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<bool>(
future: _canAddShortcutsLoader,
builder: (context, snapshot) {
final canAddShortcuts = snapshot.data ?? false;
return MenuIconTheme(
child: PopupMenuButton<EntrySetAction>(
// key is expected by test driver
key: const Key('appbar-menu-button'),
itemBuilder: (context) {
final groupable = collection.sortFactor == EntrySortFactor.date;
final selection = context.read<Selection<AvesEntry>>();
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<EntrySetAction>(
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<EntrySetAction>(
// 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<EntrySetAction>(
enabled: canApplyEditActions,
padding: EdgeInsets.zero,
child: PopupMenuItemExpansionPanel<EntrySetAction>(
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<EntrySetAction> _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<EntrySetAction> _buildRotateAndFlipMenuItems(BuildContext context, {required bool enabled}) {
PopupMenuItem<EntrySetAction> _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<CollectionAppBar> 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<CollectionAppBar> with SingleTickerPr
Future<void> _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<Selection<AvesEntry>>().select();
@ -355,77 +357,70 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
case EntrySetAction.selectNone:
context.read<Selection<AvesEntry>>().clearSelection();
break;
// browsing
case EntrySetAction.search:
case EntrySetAction.addShortcut:
unawaited(_showShortcutDialog(context));
break;
case EntrySetAction.group:
final value = await showDialog<EntryGroupFactor>(
context: context,
builder: (context) {
final l10n = context.l10n;
return AvesSelectionDialog<EntryGroupFactor>(
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<EntrySortFactor>(
context: context,
builder: (context) => AvesSelectionDialog<EntrySortFactor>(
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<void> _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<CollectionFilter>.from(filters)..sort();
defaultName = sortedFilters.first.getLabel(context).replaceAll('\n', ' ');
}
final result = await showDialog<Tuple2<AvesEntry?, String>>(
Future<void> _sort() async {
final value = await showDialog<EntrySortFactor>(
context: context,
builder: (context) => AddShortcutDialog(
collection: collection,
defaultName: defaultName ?? '',
builder: (context) => AvesSelectionDialog<EntrySortFactor>(
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<void> _group() async {
final value = await showDialog<EntryGroupFactor>(
context: context,
builder: (context) {
final l10n = context.l10n;
return AvesSelectionDialog<EntryGroupFactor>(
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() {

View file

@ -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<CollectionLens>();
Navigator.push(
context,
SearchPageRoute(
delegate: CollectionSearchDelegate(
source: collection.source,
parentCollection: collection,
),
),
);
}
Future<void> _addShortcut(BuildContext context) async {
final collection = context.read<CollectionLens>();
final filters = collection.filters;
String? defaultName;
if (filters.isNotEmpty) {
// we compute the default name beforehand
// because some filter labels need localization
final sortedFilters = List<CollectionFilter>.from(filters)..sort();
defaultName = sortedFilters.first.getLabel(context).replaceAll('\n', ' ');
}
final result = await showDialog<Tuple2<AvesEntry?, String>>(
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));
}
}

View file

@ -39,19 +39,19 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> {
set sortFactor(ChipSortFactor factor) => settings.albumSortFactor = factor;
@override
bool isValid(Set<AlbumFilter> filters, ChipSetAction action) {
bool isVisible(ChipSetAction action, Set<AlbumFilter> 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<AlbumFilter> filters, ChipSetAction action) {
bool canApply(ChipSetAction action, Set<AlbumFilter> filters) {
switch (action) {
case ChipSetAction.rename:
{
@ -61,7 +61,7 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> {
return dir != null && dir.relativeDir.isNotEmpty;
}
default:
return super.canApply(filters, action);
return super.canApply(action, filters);
}
}

View file

@ -30,7 +30,7 @@ abstract class ChipSetActionDelegate<T extends CollectionFilter> with FeedbackMi
set sortFactor(ChipSortFactor factor);
bool isValid(Set<T> filters, ChipSetAction action) {
bool isVisible(ChipSetAction action, Set<T> filters) {
final hasSelection = filters.isNotEmpty;
switch (action) {
case ChipSetAction.createAlbum:
@ -46,7 +46,7 @@ abstract class ChipSetActionDelegate<T extends CollectionFilter> with FeedbackMi
}
}
bool canApply(Set<T> filters, ChipSetAction action) {
bool canApply(ChipSetAction action, Set<T> filters) {
switch (action) {
// general
case ChipSetAction.sort:

View file

@ -133,7 +133,7 @@ class _FilterGridAppBarState<T extends CollectionFilter> extends State<FilterGri
PopupMenuItem<ChipSetAction> 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<T extends CollectionFilter> extends State<FilterGri
final buttonActions = <Widget>[];
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<T extends CollectionFilter> extends State<FilterGri
);
},
));
selectionRowActions.addAll(validActions.skip(buttonActionCount));
selectionRowActions.addAll(visibleActions.skip(buttonActionCount));
} else if (appMode.canSearch) {
buttonActions.add(CollectionSearchButton(source: source));
}
@ -202,7 +202,7 @@ class _FilterGridAppBarState<T extends CollectionFilter> extends State<FilterGri
enabled: otherViewEnabled,
),
]);
if (!isSelecting && actionDelegate.isValid(selectedFilters, ChipSetAction.createAlbum)) {
if (!isSelecting && actionDelegate.isVisible(ChipSetAction.createAlbum, selectedFilters)) {
menuItems.addAll([
const PopupMenuDivider(),
toMenuItem(ChipSetAction.createAlbum),

View file

@ -0,0 +1,44 @@
import 'package:aves/model/actions/entry_set_actions.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/settings/common/quick_actions/editor_page.dart';
import 'package:flutter/material.dart';
class SelectionActionsTile extends StatelessWidget {
const SelectionActionsTile({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(context.l10n.settingsCollectionSelectionQuickActionsTile),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: SelectionActionEditorPage.routeName),
builder: (context) => 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<EntrySetAction>(
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,
);
}
}