diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 1352226ad..d53ea52a1 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -388,7 +388,7 @@ "removeEntryMetadataDialogMore": "More", "@removeEntryMetadataDialogMore": {}, - "removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP is required to play the video inside this motion photo. Are you sure you want to remove it?", + "removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP is required to play the video inside a motion photo. Are you sure you want to remove it?", "@removeEntryMetadataMotionPhotoXmpWarningDialogMessage": {}, "videoSpeedDialogLabel": "Playback speed", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index e9604e93b..93b061629 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -173,7 +173,7 @@ "removeEntryMetadataDialogTitle": "메타데이터 삭제", "removeEntryMetadataDialogMore": "더 보기", - "removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP가 있어야 모션 포토에 포함되는 동영상을 재생할 수 있습니다. 삭제하시겠습니까?", + "removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP가 있어야 모션 포토에 포함되는 동영상을 재생할 수 있습니다. 삭제하시겠습니까?", "videoSpeedDialogLabel": "재생 배속", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 1facda25f..d68e9bc10 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -173,7 +173,7 @@ "removeEntryMetadataDialogTitle": "Удаление метаданных", "removeEntryMetadataDialogMore": "Дополнительно", - "removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "Для воспроизведения видео внутри этой живой фотографии требуется XMP профиль. Вы уверены, что хотите удалить его?", + "removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "Для воспроизведения видео внутри этой живой фотографии требуется XMP профиль. Вы уверены, что хотите удалить его?", "videoSpeedDialogLabel": "Скорость воспроизведения", diff --git a/lib/model/actions/entry_set_actions.dart b/lib/model/actions/entry_set_actions.dart index f7b6ed502..15149d27a 100644 --- a/lib/model/actions/entry_set_actions.dart +++ b/lib/model/actions/entry_set_actions.dart @@ -21,6 +21,7 @@ enum EntrySetAction { move, rescan, editDate, + removeMetadata, } class EntrySetActions { @@ -70,6 +71,8 @@ extension ExtraEntrySetAction on EntrySetAction { return context.l10n.collectionActionRescan; case EntrySetAction.editDate: return context.l10n.entryInfoActionEditDate; + case EntrySetAction.removeMetadata: + return context.l10n.entryInfoActionRemoveMetadata; } } @@ -111,6 +114,8 @@ extension ExtraEntrySetAction on EntrySetAction { return AIcons.refresh; case EntrySetAction.editDate: return AIcons.date; + case EntrySetAction.removeMetadata: + return AIcons.clear; } } } diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index ed410427d..5cef27c02 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -223,6 +223,7 @@ class _CollectionAppBarState extends State with SingleTickerPr title: context.l10n.collectionActionEdit, items: [ _toMenuItem(EntrySetAction.editDate, enabled: hasSelection), + _toMenuItem(EntrySetAction.removeMetadata, enabled: hasSelection), ], ), ), @@ -303,6 +304,7 @@ class _CollectionAppBarState extends State with SingleTickerPr case EntrySetAction.map: case EntrySetAction.stats: case EntrySetAction.editDate: + case EntrySetAction.removeMetadata: _actionDelegate.onActionSelected(context, action); break; case EntrySetAction.select: diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index 034c93972..c34678891 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -6,7 +6,6 @@ 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'; @@ -17,13 +16,13 @@ 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/entry_editor.dart'; 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/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'; @@ -32,7 +31,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:provider/provider.dart'; -class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { +class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { void onActionSelected(BuildContext context, EntrySetAction action) { switch (action) { case EntrySetAction.share: @@ -53,6 +52,9 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware case EntrySetAction.editDate: _editDate(context); break; + case EntrySetAction.removeMetadata: + _removeMetadata(context); + break; case EntrySetAction.map: _goToMap(context); break; @@ -163,15 +165,15 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware // do not directly use selection when moving and post-processing items // as source monitoring may remove obsolete items from the original selection - final todoEntries = selectedItems.toSet(); + final todoItems = selectedItems.toSet(); final copy = moveType == MoveType.copy; - final todoCount = todoEntries.length; + final todoCount = todoItems.length; assert(todoCount > 0); final destinationDirectory = Directory(destinationAlbum); final names = [ - ...todoEntries.map((v) => '${v.filenameWithoutExtension}${v.extension}'), + ...todoItems.map((v) => '${v.filenameWithoutExtension}${v.extension}'), // do not guard up front based on directory existence, // as conflicts could be within moved entries scattered across multiple albums if (await destinationDirectory.exists()) ...destinationDirectory.listSync().map((v) => pContext.basename(v.path)), @@ -198,7 +200,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware showOpReport( context: context, opStream: mediaFileService.move( - todoEntries, + todoItems, copy: copy, destinationAlbum: destinationAlbum, nameConflictStrategy: nameConflictStrategy, @@ -208,7 +210,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware final successOps = processed.where((e) => e.success).toSet(); final movedOps = successOps.where((e) => !e.newFields.containsKey('skipped')).toSet(); await source.updateAfterMove( - todoEntries: todoEntries, + todoEntries: todoItems, copy: copy, destinationAlbum: destinationAlbum, movedOps: movedOps, @@ -272,57 +274,23 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware ); } - Future _editDate(BuildContext context) async { - final l10n = context.l10n; + Future _edit( + BuildContext context, + Selection selection, + Set todoItems, + Future Function(AvesEntry entry) op, + ) async { + final selectionDirs = todoItems.map((e) => e.directory).whereNotNull().toSet(); + final todoCount = todoItems.length; + + if (!await checkStoragePermissionForAlbums(context, selectionDirs, entries: todoItems)) return; + final source = context.read(); - final selection = context.read>(); - final selectedItems = _getExpandedSelectedItems(selection); - - 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) => EditEntryDateDialog(entry: todoEntries.first), - ); - if (modifier == null) return; - - if (!await checkStoragePermissionForAlbums(context, selectionDirs, entries: todoEntries)) return; - source.pauseMonitoring(); showOpReport( context: context, - opStream: Stream.fromIterable(todoEntries).asyncMap((entry) async { - final success = await entry.editDate(modifier); + opStream: Stream.fromIterable(todoItems).asyncMap((entry) async { + final success = await op(entry); return ImageOpEvent(success: success, uri: entry.uri); }).asBroadcastStream(), itemCount: todoCount, @@ -332,6 +300,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware source.resumeMonitoring(); unawaited(source.refreshUris(successOps.map((v) => v.uri).toSet())); + final l10n = context.l10n; final successCount = successOps.length; if (successCount < todoCount) { final count = todoCount - successCount; @@ -344,6 +313,71 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware ); } + Future _checkEditableFormats( + BuildContext context, { + required Set supported, + required Set unsupported, + }) async { + if (unsupported.isEmpty) return true; + + final unsupportedTypes = unsupported.map((entry) => entry.mimeType).toSet().map(MimeUtils.displayType).toList()..sort(); + final confirmed = await showDialog( + context: context, + builder: (context) { + final l10n = context.l10n; + 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 (supported.isNotEmpty) + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text(l10n.continueButtonLabel), + ), + ], + ); + }, + ); + if (confirmed == null || !confirmed) return false; + + return true; + } + + Future _editDate(BuildContext context) async { + final selection = context.read>(); + final selectedItems = _getExpandedSelectedItems(selection); + + final bySupported = groupBy(selectedItems, (entry) => entry.canEditExif); + final todoItems = (bySupported[true] ?? []).toSet(); + final unsupported = (bySupported[false] ?? []).toSet(); + if (!await _checkEditableFormats(context, supported: todoItems, unsupported: unsupported)) return; + + final modifier = await selectDateModifier(context, todoItems); + if (modifier == null) return; + + await _edit(context, selection, todoItems, (entry) => entry.editDate(modifier)); + } + + Future _removeMetadata(BuildContext context) async { + final selection = context.read>(); + final selectedItems = _getExpandedSelectedItems(selection); + + final bySupported = groupBy(selectedItems, (entry) => entry.canRemoveMetadata); + final todoItems = (bySupported[true] ?? []).toSet(); + final unsupported = (bySupported[false] ?? []).toSet(); + if (!await _checkEditableFormats(context, supported: todoItems, unsupported: unsupported)) return; + + final types = await selectMetadataToRemove(context, todoItems); + if (types == null) return; + + await _edit(context, selection, todoItems, (entry) => entry.removeMetadata(types)); + } + void _goToMap(BuildContext context) { final selection = context.read>(); final collection = context.read(); diff --git a/lib/widgets/common/action_mixins/entry_editor.dart b/lib/widgets/common/action_mixins/entry_editor.dart new file mode 100644 index 000000000..3212a9827 --- /dev/null +++ b/lib/widgets/common/action_mixins/entry_editor.dart @@ -0,0 +1,60 @@ +import 'package:aves/model/entry.dart'; +import 'package:aves/model/metadata/date_modifier.dart'; +import 'package:aves/model/metadata/enums.dart'; +import 'package:aves/ref/mime_types.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/dialogs/aves_dialog.dart'; +import 'package:aves/widgets/dialogs/edit_entry_date_dialog.dart'; +import 'package:aves/widgets/dialogs/remove_entry_metadata_dialog.dart'; +import 'package:flutter/material.dart'; + +mixin EntryEditorMixin { + Future selectDateModifier(BuildContext context, Set entries) async { + if (entries.isEmpty) return null; + + final modifier = await showDialog( + context: context, + builder: (context) => EditEntryDateDialog( + entry: entries.first, + ), + ); + return modifier; + } + + Future?> selectMetadataToRemove(BuildContext context, Set entries) async { + if (entries.isEmpty) return null; + + final types = await showDialog>( + context: context, + builder: (context) => RemoveEntryMetadataDialog( + showJpegTypes: entries.any((entry) => entry.mimeType == MimeTypes.jpeg), + ), + ); + if (types == null || types.isEmpty) return null; + + if (entries.any((entry) => entry.isMotionPhoto) && types.contains(MetadataType.xmp)) { + final confirmed = await showDialog( + context: context, + builder: (context) { + return AvesDialog( + context: context, + content: Text(context.l10n.removeEntryMetadataMotionPhotoXmpWarningDialogMessage), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text(context.l10n.applyButtonLabel), + ), + ], + ); + }, + ); + if (confirmed == null || !confirmed) return null; + } + + return types; + } +} diff --git a/lib/widgets/dialogs/remove_entry_metadata_dialog.dart b/lib/widgets/dialogs/remove_entry_metadata_dialog.dart index 8ada8046a..2ecc94bef 100644 --- a/lib/widgets/dialogs/remove_entry_metadata_dialog.dart +++ b/lib/widgets/dialogs/remove_entry_metadata_dialog.dart @@ -1,7 +1,5 @@ -import 'package:aves/model/entry.dart'; import 'package:aves/model/metadata/enums.dart'; import 'package:aves/ref/brand_colors.dart'; -import 'package:aves/ref/mime_types.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/utils/color_utils.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; @@ -14,11 +12,11 @@ import 'package:provider/provider.dart'; import 'aves_dialog.dart'; class RemoveEntryMetadataDialog extends StatefulWidget { - final AvesEntry entry; + final bool showJpegTypes; const RemoveEntryMetadataDialog({ Key? key, - required this.entry, + required this.showJpegTypes, }) : super(key: key); @override @@ -31,14 +29,12 @@ class _RemoveEntryMetadataDialogState extends State { bool _showMore = false; final ValueNotifier _isValidNotifier = ValueNotifier(false); - AvesEntry get entry => widget.entry; - @override void initState() { super.initState(); final byMain = groupBy([ ...MetadataTypes.common, - if (entry.mimeType == MimeTypes.jpeg) ...MetadataTypes.jpeg, + if (widget.showJpegTypes) ...MetadataTypes.jpeg, ], MetadataTypes.main.contains); _mainOptions = (byMain[true] ?? [])..sort(_compareTypeText); _moreOptions = (byMain[false] ?? [])..sort(_compareTypeText); diff --git a/lib/widgets/viewer/info/entry_info_action_delegate.dart b/lib/widgets/viewer/info/entry_info_action_delegate.dart index 2992a711d..f5f9d0d13 100644 --- a/lib/widgets/viewer/info/entry_info_action_delegate.dart +++ b/lib/widgets/viewer/info/entry_info_action_delegate.dart @@ -1,20 +1,16 @@ import 'package:aves/app_mode.dart'; import 'package:aves/model/actions/entry_info_actions.dart'; import 'package:aves/model/entry.dart'; -import 'package:aves/model/metadata/date_modifier.dart'; -import 'package:aves/model/metadata/enums.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/widgets/common/action_mixins/entry_editor.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:aves/widgets/dialogs/aves_dialog.dart'; -import 'package:aves/widgets/dialogs/edit_entry_date_dialog.dart'; -import 'package:aves/widgets/dialogs/remove_entry_metadata_dialog.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin { +class EntryInfoActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwareMixin { final AvesEntry entry; const EntryInfoActionDelegate(this.entry); @@ -22,10 +18,10 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin { void onActionSelected(BuildContext context, EntryInfoAction action) async { switch (action) { case EntryInfoAction.editDate: - await _showDateEditDialog(context); + await _editDate(context); break; case EntryInfoAction.removeMetadata: - await _showMetadataRemovalDialog(context); + await _removeMetadata(context); break; } } @@ -52,45 +48,16 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin { source?.resumeMonitoring(); } - Future _showDateEditDialog(BuildContext context) async { - final modifier = await showDialog( - context: context, - builder: (context) => EditEntryDateDialog(entry: entry), - ); + Future _editDate(BuildContext context) async { + final modifier = await selectDateModifier(context, {entry}); if (modifier == null) return; await _edit(context, () => entry.editDate(modifier)); } - Future _showMetadataRemovalDialog(BuildContext context) async { - final types = await showDialog>( - context: context, - builder: (context) => RemoveEntryMetadataDialog(entry: entry), - ); - if (types == null || types.isEmpty) return; - - if (entry.isMotionPhoto && types.contains(MetadataType.xmp)) { - final proceed = await showDialog( - context: context, - builder: (context) { - return AvesDialog( - context: context, - content: Text(context.l10n.removeEntryMetadataMotionPhotoXmpWarningDialogMessage), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(MaterialLocalizations.of(context).cancelButtonLabel), - ), - TextButton( - onPressed: () => Navigator.pop(context, true), - child: Text(context.l10n.applyButtonLabel), - ), - ], - ); - }, - ); - if (proceed == null || !proceed) return; - } + Future _removeMetadata(BuildContext context) async { + final types = await selectMetadataToRemove(context, {entry}); + if (types == null) return; await _edit(context, () => entry.removeMetadata(types)); }