#117 collection: edit date in bulk

This commit is contained in:
Thibault Deckers 2021-10-29 16:48:41 +09:00
parent eb299c2feb
commit 0bf238245f
10 changed files with 273 additions and 59 deletions

View file

@ -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": {},

View file

@ -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": "Скопировать в альбом",

View file

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

View file

@ -636,7 +636,7 @@ class AvesEntry {
}
Future<bool> 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);

View file

@ -8,7 +8,7 @@ enum MetadataField {
enum DateEditAction {
set,
shift,
fromTitle,
extractFromTitle,
clear,
}

View file

@ -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<CollectionAppBar> with SingleTickerPr
}
Widget? _buildAppBarTitle(bool isSelecting) {
final l10n = context.l10n;
if (isSelecting) {
return Selector<Selection<AvesEntry>, 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<ValueNotifier<AppMode>>().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<CollectionAppBar> 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<EntrySetAction>(
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<CollectionAppBar> 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<CollectionAppBar> with SingleTickerPr
case EntrySetAction.group:
final value = await showDialog<EntryGroupFactor>(
context: context,
builder: (context) => AvesSelectionDialog<EntryGroupFactor>(
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<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);

View file

@ -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<void> _showDeleteDialog(BuildContext context) async {
final source = context.read<CollectionSource>();
final selection = context.read<Selection<AvesEntry>>();
final selectedItems = _getExpandedSelectedItems(selection);
final selectionDirs = selectedItems.map((e) => e.directory).whereNotNull().toSet();
final todoCount = selectedItems.length;
final confirmed = await showDialog<bool>(
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<ImageOpEvent>(
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<void> _moveSelection(BuildContext context, {required MoveType moveType}) async {
final l10n = context.l10n;
final source = context.read<CollectionSource>();
@ -213,55 +272,74 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
);
}
Future<void> _showDeleteDialog(BuildContext context) async {
Future<void> _editDate(BuildContext context) async {
final l10n = context.l10n;
final source = context.read<CollectionSource>();
final selection = context.read<Selection<AvesEntry>>();
final selectedItems = _getExpandedSelectedItems(selection);
final selectionDirs = selectedItems.map((e) => e.directory).whereNotNull().toSet();
final todoCount = selectedItems.length;
final confirmed = await showDialog<bool>(
final bySupported = groupBy<AvesEntry, bool>(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<bool>(
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<DateModifier>(
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<ImageOpEvent>(
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);
},
);
}

View file

@ -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<T> extends StatefulWidget {
final bool enabled;
final IconData icon;
final String title;
final List<PopupMenuItem<T>> items;
const PopupMenuItemExpansionPanel({
Key? key,
this.enabled = true,
required this.icon,
required this.title,
required this.items,
}) : super(key: key);
@override
_PopupMenuItemExpansionPanelState createState() => _PopupMenuItemExpansionPanelState<T>();
}
class _PopupMenuItemExpansionPanelState<T> extends State<PopupMenuItemExpansionPanel<T>> {
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<DurationsData, Duration>((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;
}
}

View file

@ -106,11 +106,11 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
),
],
);
final fromTitleTile = RadioListTile<DateEditAction>(
value: DateEditAction.fromTitle,
final extractFromTitleTile = RadioListTile<DateEditAction>(
value: DateEditAction.extractFromTitle,
groupValue: _action,
onChanged: _updateAction,
title: _tileText(l10n.editEntryDateDialogFromTitle),
title: _tileText(l10n.editEntryDateDialogExtractFromTitle),
);
final clearTile = RadioListTile<DateEditAction>(
value: DateEditAction.clear,
@ -134,7 +134,7 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
scrollableContent: [
setTile,
shiftTile,
fromTitleTile,
extractFromTitleTile,
clearTile,
Padding(
padding: const EdgeInsets.only(bottom: 1),
@ -250,7 +250,7 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
case DateEditAction.shift:
modifier = DateModifier(_action, _fields, shiftMinutes: _shiftMinutes);
break;
case DateEditAction.fromTitle:
case DateEditAction.extractFromTitle:
case DateEditAction.clear:
modifier = DateModifier(_action, _fields);
break;

View file

@ -1,10 +1,20 @@
{
"ko": [
"editEntryDateDialogFromTitle"
"unsupportedTypeDialogTitle",
"unsupportedTypeDialogMessage",
"editEntryDateDialogExtractFromTitle",
"collectionActionEdit",
"collectionEditFailureFeedback",
"collectionEditSuccessFeedback"
],
"ru": [
"editEntryDateDialogFromTitle",
"aboutCreditsTranslators"
"unsupportedTypeDialogTitle",
"unsupportedTypeDialogMessage",
"editEntryDateDialogExtractFromTitle",
"aboutCreditsTranslators",
"collectionActionEdit",
"collectionEditFailureFeedback",
"collectionEditSuccessFeedback"
]
}