From 0bf238245f28c233136f9cc76190c11cc04dd062 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Fri, 29 Oct 2021 16:48:41 +0900 Subject: [PATCH] #117 collection: edit date in bulk --- lib/l10n/app_en.arb | 30 +++- lib/l10n/app_ru.arb | 4 +- lib/model/actions/entry_set_actions.dart | 5 + lib/model/entry.dart | 2 +- lib/model/metadata/enums.dart | 2 +- lib/widgets/collection/app_bar.dart | 47 ++++-- .../collection/entry_set_action_delegate.dart | 142 ++++++++++++++---- lib/widgets/common/basic/menu.dart | 74 +++++++++ .../dialogs/edit_entry_date_dialog.dart | 10 +- untranslated.json | 16 +- 10 files changed, 273 insertions(+), 59 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 7891609e3..1352226ad 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -289,6 +289,18 @@ } }, + "unsupportedTypeDialogTitle": "Unsupported Types", + "@unsupportedTypeDialogTitle": {}, + "unsupportedTypeDialogMessage": "{count, plural, =1{This operation is not supported for items of the following type: {types}.} other{This operation is not supported for items of the following types: {types}.}}", + "@unsupportedTypeDialogMessage": { + "placeholders": { + "count": {}, + "types": { + "type": "String" + } + } + }, + "nameConflictDialogSingleSourceMessage": "Some files in the destination folder have the same name.", "@nameConflictDialogSingleSourceMessage": {}, "nameConflictDialogMultipleSourceMessage": "Some files have the same name.", @@ -360,8 +372,8 @@ "@editEntryDateDialogSet": {}, "editEntryDateDialogShift": "Shift", "@editEntryDateDialogShift": {}, - "editEntryDateDialogFromTitle": "From title", - "@editEntryDateDialogFromTitle": {}, + "editEntryDateDialogExtractFromTitle": "Extract from title", + "@editEntryDateDialogExtractFromTitle": {}, "editEntryDateDialogClear": "Clear", "@editEntryDateDialogClear": {}, "editEntryDateDialogFieldSelection": "Field selection", @@ -493,6 +505,8 @@ "@collectionActionMove": {}, "collectionActionRescan": "Rescan", "@collectionActionRescan": {}, + "collectionActionEdit": "Edit", + "@collectionActionEdit": {}, "collectionSortTitle": "Sort", "@collectionSortTitle": {}, @@ -540,6 +554,12 @@ "count": {} } }, + "collectionEditFailureFeedback": "{count, plural, =1{Failed to edit 1 item} other{Failed to edit {count} items}}", + "@collectionEditFailureFeedback": { + "placeholders": { + "count": {} + } + }, "collectionExportFailureFeedback": "{count, plural, =1{Failed to export 1 page} other{Failed to export {count} pages}}", "@collectionExportFailureFeedback": { "placeholders": { @@ -558,6 +578,12 @@ "count": {} } }, + "collectionEditSuccessFeedback": "{count, plural, =1{Edited 1 item} other{Edited {count} items}}", + "@collectionEditSuccessFeedback": { + "placeholders": { + "count": {} + } + }, "collectionEmptyFavourites": "No favourites", "@collectionEmptyFavourites": {}, diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 3ba644bc5..1facda25f 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -139,7 +139,7 @@ "noMatchingAppDialogTitle": "Нет подходящего приложения", "noMatchingAppDialogMessage": "Нет приложений, которые могли бы с этим справиться.", - "deleteEntriesConfirmationDialogMessage": "{count, plural, =1{Вы уверены, что хотите удалить этот объект?} few{Вы уверены, что хотите удалить эти {count} объекта?} other{Вы уверены, что хотите удалить эти {count} объектов)?}}", + "deleteEntriesConfirmationDialogMessage": "{count, plural, =1{Вы уверены, что хотите удалить этот объект?} few{Вы уверены, что хотите удалить эти {count} объекта?} other{Вы уверены, что хотите удалить эти {count} объектов?}}", "setCoverDialogTitle": "Установить обложку", "setCoverDialogLatest": "Последний объект", @@ -228,7 +228,7 @@ "collectionPageTitle": "Коллекция", "collectionPickPageTitle": "Выбрать", - "collectionSelectionPageTitle": "{count, plural, =0{Выберите объекты} =1{1 объект} few{{count} объекта} other{{count} объектов)}}", + "collectionSelectionPageTitle": "{count, plural, =0{Выберите объекты} =1{1 объект} few{{count} объекта} other{{count} объектов}}", "collectionActionAddShortcut": "Добавить ярлык", "collectionActionCopy": "Скопировать в альбом", diff --git a/lib/model/actions/entry_set_actions.dart b/lib/model/actions/entry_set_actions.dart index b156e3b88..f7b6ed502 100644 --- a/lib/model/actions/entry_set_actions.dart +++ b/lib/model/actions/entry_set_actions.dart @@ -20,6 +20,7 @@ enum EntrySetAction { copy, move, rescan, + editDate, } class EntrySetActions { @@ -67,6 +68,8 @@ extension ExtraEntrySetAction on EntrySetAction { return context.l10n.collectionActionMove; case EntrySetAction.rescan: return context.l10n.collectionActionRescan; + case EntrySetAction.editDate: + return context.l10n.entryInfoActionEditDate; } } @@ -106,6 +109,8 @@ extension ExtraEntrySetAction on EntrySetAction { return AIcons.move; case EntrySetAction.rescan: return AIcons.refresh; + case EntrySetAction.editDate: + return AIcons.date; } } } diff --git a/lib/model/entry.dart b/lib/model/entry.dart index 4117e774b..3b40805a7 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -636,7 +636,7 @@ class AvesEntry { } Future editDate(DateModifier modifier) async { - if (modifier.action == DateEditAction.fromTitle) { + if (modifier.action == DateEditAction.extractFromTitle) { final _title = bestTitle; if (_title == null) return false; final date = parseUnknownDateFormat(_title); diff --git a/lib/model/metadata/enums.dart b/lib/model/metadata/enums.dart index 750725b2b..351d441ec 100644 --- a/lib/model/metadata/enums.dart +++ b/lib/model/metadata/enums.dart @@ -8,7 +8,7 @@ enum MetadataField { enum DateEditAction { set, shift, - fromTitle, + extractFromTitle, clear, } diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index 52699fbd2..ed410427d 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -11,6 +11,7 @@ import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/theme/durations.dart'; +import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/collection/entry_set_action_delegate.dart'; import 'package:aves/widgets/collection/filter_bar.dart'; import 'package:aves/widgets/common/app_bar_subtitle.dart'; @@ -143,14 +144,16 @@ class _CollectionAppBarState extends State with SingleTickerPr } Widget? _buildAppBarTitle(bool isSelecting) { + final l10n = context.l10n; + if (isSelecting) { return Selector, int>( selector: (context, selection) => selection.selectedItems.length, - builder: (context, count, child) => Text(context.l10n.collectionSelectionPageTitle(count)), + builder: (context, count, child) => Text(l10n.collectionSelectionPageTitle(count)), ); } else { final appMode = context.watch>().value; - Widget title = Text(appMode.isPicking ? context.l10n.collectionPickPageTitle : context.l10n.collectionPageTitle); + Widget title = Text(appMode.isPicking ? l10n.collectionPickPageTitle : l10n.collectionPageTitle); if (appMode == AppMode.main) { title = SourceStateAwareAppBarTitle( title: title, @@ -209,7 +212,21 @@ class _CollectionAppBarState extends State with SingleTickerPr enabled: hasItems, ), const PopupMenuDivider(), - if (isSelecting) ...EntrySetActions.selection.where((v) => !selectionQuickActions.contains(v)).map((v) => _toMenuItem(v, enabled: hasSelection)), + 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: [ + _toMenuItem(EntrySetAction.editDate, enabled: hasSelection), + ], + ), + ), + ], if (!isSelecting) ...[ EntrySetAction.map, @@ -285,6 +302,7 @@ class _CollectionAppBarState extends State with SingleTickerPr case EntrySetAction.rescan: case EntrySetAction.map: case EntrySetAction.stats: + case EntrySetAction.editDate: _actionDelegate.onActionSelected(context, action); break; case EntrySetAction.select: @@ -302,16 +320,19 @@ class _CollectionAppBarState extends State with SingleTickerPr case EntrySetAction.group: final value = await showDialog( context: context, - builder: (context) => AvesSelectionDialog( - initialValue: settings.collectionSectionFactor, - options: { - EntryGroupFactor.album: context.l10n.collectionGroupAlbum, - EntryGroupFactor.month: context.l10n.collectionGroupMonth, - EntryGroupFactor.day: context.l10n.collectionGroupDay, - EntryGroupFactor.none: context.l10n.collectionGroupNone, - }, - title: context.l10n.collectionGroupTitle, - ), + 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); diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index a46b63497..034c93972 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -6,6 +6,7 @@ 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/highlight.dart'; +import 'package:aves/model/metadata/date_modifier.dart'; import 'package:aves/model/selection.dart'; import 'package:aves/model/source/analysis_controller.dart'; import 'package:aves/model/source/collection_lens.dart'; @@ -14,6 +15,7 @@ import 'package:aves/services/common/image_op_events.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/services/media/enums.dart'; import 'package:aves/theme/durations.dart'; +import 'package:aves/utils/mime_utils.dart'; import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; @@ -21,6 +23,7 @@ import 'package:aves/widgets/common/action_mixins/size_aware.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; +import 'package:aves/widgets/dialogs/edit_entry_date_dialog.dart'; import 'package:aves/widgets/filter_grids/album_pick.dart'; import 'package:aves/widgets/map/map_page.dart'; import 'package:aves/widgets/stats/stats_page.dart'; @@ -47,6 +50,9 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware case EntrySetAction.rescan: _rescan(context); break; + case EntrySetAction.editDate: + _editDate(context); + break; case EntrySetAction.map: _goToMap(context); break; @@ -81,6 +87,59 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware selection.browse(); } + Future _showDeleteDialog(BuildContext context) async { + final source = context.read(); + final selection = context.read>(); + final selectedItems = _getExpandedSelectedItems(selection); + final selectionDirs = selectedItems.map((e) => e.directory).whereNotNull().toSet(); + final todoCount = selectedItems.length; + + final confirmed = await showDialog( + context: context, + builder: (context) { + return AvesDialog( + context: context, + content: Text(context.l10n.deleteEntriesConfirmationDialogMessage(todoCount)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text(context.l10n.deleteButtonLabel), + ), + ], + ); + }, + ); + if (confirmed == null || !confirmed) return; + + if (!await checkStoragePermissionForAlbums(context, selectionDirs, entries: selectedItems)) return; + + source.pauseMonitoring(); + showOpReport( + context: context, + opStream: mediaFileService.delete(selectedItems), + itemCount: todoCount, + onDone: (processed) async { + final deletedUris = processed.where((event) => event.success).map((event) => event.uri).toSet(); + await source.removeEntries(deletedUris); + selection.browse(); + source.resumeMonitoring(); + + final deletedCount = deletedUris.length; + if (deletedCount < todoCount) { + final count = todoCount - deletedCount; + showFeedback(context, context.l10n.collectionDeleteFailureFeedback(count)); + } + + // cleanup + await storageService.deleteEmptyDirectories(selectionDirs); + }, + ); + } + Future _moveSelection(BuildContext context, {required MoveType moveType}) async { final l10n = context.l10n; final source = context.read(); @@ -213,55 +272,74 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware ); } - Future _showDeleteDialog(BuildContext context) async { + Future _editDate(BuildContext context) async { + final l10n = context.l10n; final source = context.read(); final selection = context.read>(); final selectedItems = _getExpandedSelectedItems(selection); - final selectionDirs = selectedItems.map((e) => e.directory).whereNotNull().toSet(); - final todoCount = selectedItems.length; - final confirmed = await showDialog( + final bySupported = groupBy(selectedItems, (entry) => entry.canEditExif); + final todoEntries = (bySupported[true] ?? []).toSet(); + final unsupportedItems = (bySupported[false] ?? []); + if (unsupportedItems.isNotEmpty) { + final unsupportedTypes = unsupportedItems.map((entry) => entry.mimeType).toSet().map(MimeUtils.displayType).toList()..sort(); + final confirmed = await showDialog( + context: context, + builder: (context) { + return AvesDialog( + context: context, + title: l10n.unsupportedTypeDialogTitle, + content: Text(l10n.unsupportedTypeDialogMessage(unsupportedTypes.length, unsupportedTypes.join(', '))), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), + ), + if (todoEntries.isNotEmpty) + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text(l10n.continueButtonLabel), + ), + ], + ); + }, + ); + if (confirmed == null || !confirmed) return; + } + + final selectionDirs = todoEntries.map((e) => e.directory).whereNotNull().toSet(); + final todoCount = todoEntries.length; + + final modifier = await showDialog( context: context, - builder: (context) { - return AvesDialog( - context: context, - content: Text(context.l10n.deleteEntriesConfirmationDialogMessage(todoCount)), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(MaterialLocalizations.of(context).cancelButtonLabel), - ), - TextButton( - onPressed: () => Navigator.pop(context, true), - child: Text(context.l10n.deleteButtonLabel), - ), - ], - ); - }, + builder: (context) => EditEntryDateDialog(entry: todoEntries.first), ); - if (confirmed == null || !confirmed) return; + if (modifier == null) return; - if (!await checkStoragePermissionForAlbums(context, selectionDirs, entries: selectedItems)) return; + if (!await checkStoragePermissionForAlbums(context, selectionDirs, entries: todoEntries)) return; source.pauseMonitoring(); showOpReport( context: context, - opStream: mediaFileService.delete(selectedItems), + opStream: Stream.fromIterable(todoEntries).asyncMap((entry) async { + final success = await entry.editDate(modifier); + return ImageOpEvent(success: success, uri: entry.uri); + }).asBroadcastStream(), itemCount: todoCount, onDone: (processed) async { - final deletedUris = processed.where((event) => event.success).map((event) => event.uri).toSet(); - await source.removeEntries(deletedUris); + final successOps = processed.where((e) => e.success).toSet(); selection.browse(); source.resumeMonitoring(); + unawaited(source.refreshUris(successOps.map((v) => v.uri).toSet())); - final deletedCount = deletedUris.length; - if (deletedCount < todoCount) { - final count = todoCount - deletedCount; - showFeedback(context, context.l10n.collectionDeleteFailureFeedback(count)); + final successCount = successOps.length; + if (successCount < todoCount) { + final count = todoCount - successCount; + showFeedback(context, l10n.collectionEditFailureFeedback(count)); + } else { + final count = successCount; + showFeedback(context, l10n.collectionEditSuccessFeedback(count)); } - - // cleanup - await storageService.deleteEmptyDirectories(selectionDirs); }, ); } diff --git a/lib/widgets/common/basic/menu.dart b/lib/widgets/common/basic/menu.dart index 3fe9f5583..ad96aa9ef 100644 --- a/lib/widgets/common/basic/menu.dart +++ b/lib/widgets/common/basic/menu.dart @@ -1,4 +1,6 @@ +import 'package:aves/theme/durations.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; class MenuRow extends StatelessWidget { final String text; @@ -45,3 +47,75 @@ class MenuIconTheme extends StatelessWidget { ); } } + +class PopupMenuItemExpansionPanel extends StatefulWidget { + final bool enabled; + final IconData icon; + final String title; + final List> items; + + const PopupMenuItemExpansionPanel({ + Key? key, + this.enabled = true, + required this.icon, + required this.title, + required this.items, + }) : super(key: key); + + @override + _PopupMenuItemExpansionPanelState createState() => _PopupMenuItemExpansionPanelState(); +} + +class _PopupMenuItemExpansionPanelState extends State> { + bool _isExpanded = false; + + // ref `_kMenuHorizontalPadding` used in `PopupMenuItem` + static const double _horizontalPadding = 16; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + var style = PopupMenuTheme.of(context).textStyle ?? theme.textTheme.subtitle1!; + if (!widget.enabled) { + style = style.copyWith(color: theme.disabledColor); + } + final animationDuration = context.select((v) => v.expansionTileAnimation); + + Widget child = ExpansionPanelList( + expansionCallback: (index, isExpanded) { + setState(() => _isExpanded = !isExpanded); + }, + animationDuration: animationDuration, + expandedHeaderPadding: EdgeInsets.zero, + elevation: 0, + children: [ + ExpansionPanel( + headerBuilder: (context, isExpanded) => DefaultTextStyle( + style: style, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: _horizontalPadding), + child: MenuRow( + text: widget.title, + icon: Icon(widget.icon), + ), + ), + ), + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const PopupMenuDivider(height: 0), + ...widget.items, + const PopupMenuDivider(height: 0), + ], + ), + isExpanded: _isExpanded, + canTapOnHeader: true, + ), + ], + ); + if (!widget.enabled) { + child = IgnorePointer(child: child); + } + return child; + } +} diff --git a/lib/widgets/dialogs/edit_entry_date_dialog.dart b/lib/widgets/dialogs/edit_entry_date_dialog.dart index 0a20d05aa..0d6056f79 100644 --- a/lib/widgets/dialogs/edit_entry_date_dialog.dart +++ b/lib/widgets/dialogs/edit_entry_date_dialog.dart @@ -106,11 +106,11 @@ class _EditEntryDateDialogState extends State { ), ], ); - final fromTitleTile = RadioListTile( - value: DateEditAction.fromTitle, + final extractFromTitleTile = RadioListTile( + value: DateEditAction.extractFromTitle, groupValue: _action, onChanged: _updateAction, - title: _tileText(l10n.editEntryDateDialogFromTitle), + title: _tileText(l10n.editEntryDateDialogExtractFromTitle), ); final clearTile = RadioListTile( value: DateEditAction.clear, @@ -134,7 +134,7 @@ class _EditEntryDateDialogState extends State { scrollableContent: [ setTile, shiftTile, - fromTitleTile, + extractFromTitleTile, clearTile, Padding( padding: const EdgeInsets.only(bottom: 1), @@ -250,7 +250,7 @@ class _EditEntryDateDialogState extends State { case DateEditAction.shift: modifier = DateModifier(_action, _fields, shiftMinutes: _shiftMinutes); break; - case DateEditAction.fromTitle: + case DateEditAction.extractFromTitle: case DateEditAction.clear: modifier = DateModifier(_action, _fields); break; diff --git a/untranslated.json b/untranslated.json index 3ca3faa57..47d21d41a 100644 --- a/untranslated.json +++ b/untranslated.json @@ -1,10 +1,20 @@ { "ko": [ - "editEntryDateDialogFromTitle" + "unsupportedTypeDialogTitle", + "unsupportedTypeDialogMessage", + "editEntryDateDialogExtractFromTitle", + "collectionActionEdit", + "collectionEditFailureFeedback", + "collectionEditSuccessFeedback" ], "ru": [ - "editEntryDateDialogFromTitle", - "aboutCreditsTranslators" + "unsupportedTypeDialogTitle", + "unsupportedTypeDialogMessage", + "editEntryDateDialogExtractFromTitle", + "aboutCreditsTranslators", + "collectionActionEdit", + "collectionEditFailureFeedback", + "collectionEditSuccessFeedback" ] }