collection: custom quick actions for browsing
This commit is contained in:
parent
bd47d52412
commit
08020260a4
13 changed files with 446 additions and 227 deletions
|
@ -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;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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),
|
||||
|
|
44
lib/widgets/settings/thumbnails/browsing_actions_editor.dart
Normal file
44
lib/widgets/settings/thumbnails/browsing_actions_editor.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue