diff --git a/CHANGELOG.md b/CHANGELOG.md index 6118fc9ce..195c78e88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ All notable changes to this project will be documented in this file. - Info: option to set date from other fields +### Changed + +- editing an item orientation or tags automatically sets a metadata date (from the file modified + date), if it is missing + ## [v1.5.9] - 2021-12-22 ### Added @@ -41,7 +46,8 @@ All notable changes to this project will be documented in this file. ### Changed - Settings: select hidden path directory with a custom file picker instead of the native SAF one -- Viewer: video cover (before playing the video) is now loaded at original resolution and can be zoomed +- Viewer: video cover (before playing the video) is now loaded at original resolution and can be + zoomed ### Fixed @@ -79,7 +85,8 @@ All notable changes to this project will be documented in this file. ### Changed -- use build flavors to match distribution channels: `play` (same as original) and `izzy` (no Crashlytics) +- use build flavors to match distribution channels: `play` (same as original) and `izzy` (no + Crashlytics) - use 12/24 hour format settings from device to display times - Privacy: consent request on first launch for installed app inventory access - use File API to rename and delete items, when possible (primary storage, Android <11) diff --git a/lib/model/entry.dart b/lib/model/entry.dart index cfcb70aab..93ef1d4f5 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -648,26 +648,28 @@ class AvesEntry { await locate(background: background, force: dataTypes.contains(EntryDataType.address), geocoderLocale: geocoderLocale); } - Future> rotate({required bool clockwise, required bool persist}) async { - final newFields = await metadataEditService.rotate(this, clockwise: clockwise); - if (newFields.isEmpty) return {}; + Future> _changeOrientation(Future> Function() apply) async { + final dataTypes = await setMetadataDateIfMissing(); - await _applyNewFields(newFields, persist: persist); - return { - EntryDataType.basic, - EntryDataType.catalog, - }; + final newFields = await apply(); + // applying fields is only useful for a smoother visual change, + // as proper refreshing and persistence happens at the caller level + await _applyNewFields(newFields, persist: false); + if (newFields.isNotEmpty) { + dataTypes.addAll({ + EntryDataType.basic, + EntryDataType.catalog, + }); + } + return dataTypes; } - Future> flip({required bool persist}) async { - final newFields = await metadataEditService.flip(this); - if (newFields.isEmpty) return {}; + Future> rotate({required bool clockwise}) { + return _changeOrientation(() => metadataEditService.rotate(this, clockwise: clockwise)); + } - await _applyNewFields(newFields, persist: persist); - return { - EntryDataType.basic, - EntryDataType.catalog, - }; + Future> flip() { + return _changeOrientation(() => metadataEditService.flip(this)); } Future> editDate(DateModifier modifier) async { @@ -730,6 +732,25 @@ class AvesEntry { }; } + // when editing a file that has no metadata date, + // we will set one, using the file modified date, if any + Future> setMetadataDateIfMissing() async { + if (path == null) return {}; + + // make sure entry is catalogued before we check whether is has a metadata date + if (!isCatalogued) { + await catalog(background: false, force: false, persist: true); + } + final metadataDate = catalogMetadata?.dateMillis; + if (metadataDate != null && metadataDate > 0) return {}; + + return await editDate(const DateModifier( + DateEditAction.set, + {MetadataField.exifDateOriginal}, + setSource: DateSetSource.fileModifiedDate, + )); + } + Future> removeMetadata(Set types) async { final newFields = await metadataEditService.removeTypes(this, types); return newFields.isEmpty diff --git a/lib/model/entry_xmp_iptc.dart b/lib/model/entry_xmp_iptc.dart index f5a1a80f6..b61c80cd7 100644 --- a/lib/model/entry_xmp_iptc.dart +++ b/lib/model/entry_xmp_iptc.dart @@ -43,6 +43,8 @@ extension ExtraAvesEntryXmpIptc on AvesEntry { static String prefixOf(String ns) => nsDefaultPrefixes[ns] ?? ''; Future> editTags(Set tags) async { + final dataTypes = await setMetadataDateIfMissing(); + final xmp = await metadataFetchService.getXmp(this); final extendedXmpString = xmp?.extendedXmpString; @@ -118,7 +120,10 @@ extension ExtraAvesEntryXmpIptc on AvesEntry { } final newFields = await metadataEditService.setXmp(this, editedXmp); - return newFields.isEmpty ? {} : {EntryDataType.catalog}; + if (newFields.isNotEmpty) { + dataTypes.add(EntryDataType.catalog); + } + return dataTypes; } Future _setIptcKeywords(List> iptc, Set tags) async { diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index 9bb428159..ce6f01dd2 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -489,7 +489,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canRotateAndFlip); if (todoItems == null || todoItems.isEmpty) return; - await _edit(context, selection, todoItems, (entry) => entry.rotate(clockwise: clockwise, persist: true)); + await _edit(context, selection, todoItems, (entry) => entry.rotate(clockwise: clockwise)); } Future _flip(BuildContext context) async { @@ -499,7 +499,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canRotateAndFlip); if (todoItems == null || todoItems.isEmpty) return; - await _edit(context, selection, todoItems, (entry) => entry.flip(persist: true)); + await _edit(context, selection, todoItems, (entry) => entry.flip()); } Future _editDate(BuildContext context) async { diff --git a/lib/widgets/viewer/entry_action_delegate.dart b/lib/widgets/viewer/action/entry_action_delegate.dart similarity index 87% rename from lib/widgets/viewer/entry_action_delegate.dart rename to lib/widgets/viewer/action/entry_action_delegate.dart index 5c4ba00da..cec470c8e 100644 --- a/lib/widgets/viewer/entry_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_action_delegate.dart @@ -24,20 +24,26 @@ import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/entry_editors/rename_entry_dialog.dart'; import 'package:aves/widgets/dialogs/export_entry_dialog.dart'; import 'package:aves/widgets/filter_grids/album_pick.dart'; +import 'package:aves/widgets/viewer/action/printer.dart'; +import 'package:aves/widgets/viewer/action/single_entry_editor.dart'; import 'package:aves/widgets/viewer/debug/debug_page.dart'; import 'package:aves/widgets/viewer/info/notifications.dart'; -import 'package:aves/widgets/viewer/printer.dart'; import 'package:aves/widgets/viewer/source_viewer_page.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; -class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { - void onActionSelected(BuildContext context, AvesEntry entry, EntryAction action) { +class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin, SingleEntryEditorMixin { + @override + final AvesEntry entry; + + EntryActionDelegate(this.entry); + + void onActionSelected(BuildContext context, EntryAction action) { switch (action) { case EntryAction.addShortcut: - _addShortcut(context, entry); + _addShortcut(context); break; case EntryAction.copyToClipboard: androidAppService.copyToClipboard(entry.uri, entry.bestTitle).then((success) { @@ -45,10 +51,10 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix }); break; case EntryAction.delete: - _delete(context, entry); + _delete(context); break; case EntryAction.export: - _export(context, entry); + _export(context); break; case EntryAction.info: ShowInfoNotification().dispatch(context); @@ -57,7 +63,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix EntryPrinter(entry).print(context); break; case EntryAction.rename: - _rename(context, entry); + _rename(context); break; case EntryAction.share: androidAppService.shareEntries({entry}).then((success) { @@ -69,17 +75,17 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix break; // raster case EntryAction.rotateCCW: - _rotate(context, entry, clockwise: false); + _rotate(context, clockwise: false); break; case EntryAction.rotateCW: - _rotate(context, entry, clockwise: true); + _rotate(context, clockwise: true); break; case EntryAction.flip: - _flip(context, entry); + _flip(context); break; // vector case EntryAction.viewSource: - _goToSourceViewer(context, entry); + _goToSourceViewer(context); break; // external case EntryAction.edit: @@ -108,12 +114,12 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix break; // debug case EntryAction.debug: - _goToDebug(context, entry); + _goToDebug(context); break; } } - Future _addShortcut(BuildContext context, AvesEntry entry) async { + Future _addShortcut(BuildContext context) async { final result = await showDialog>( context: context, builder: (context) => AddShortcutDialog( @@ -131,18 +137,12 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix } } - Future _flip(BuildContext context, AvesEntry entry) async { - if (!await checkStoragePermission(context, {entry})) return; - - final dataTypes = await entry.flip(persist: _isMainMode(context)); - if (dataTypes.isEmpty) showFeedback(context, context.l10n.genericFailureFeedback); + Future _flip(BuildContext context) async { + await edit(context, entry.flip); } - Future _rotate(BuildContext context, AvesEntry entry, {required bool clockwise}) async { - if (!await checkStoragePermission(context, {entry})) return; - - final dataTypes = await entry.rotate(clockwise: clockwise, persist: _isMainMode(context)); - if (dataTypes.isEmpty) showFeedback(context, context.l10n.genericFailureFeedback); + Future _rotate(BuildContext context, {required bool clockwise}) async { + await edit(context, () => entry.rotate(clockwise: clockwise)); } Future _rotateScreen(BuildContext context) async { @@ -156,7 +156,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix } } - Future _delete(BuildContext context, AvesEntry entry) async { + Future _delete(BuildContext context) async { final confirmed = await showDialog( context: context, builder: (context) { @@ -190,7 +190,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix } } - Future _export(BuildContext context, AvesEntry entry) async { + Future _export(BuildContext context) async { final source = context.read(); if (!source.initialized) { await source.init(); @@ -291,7 +291,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix ); } - Future _rename(BuildContext context, AvesEntry entry) async { + Future _rename(BuildContext context) async { final newName = await showDialog( context: context, builder: (context) => RenameEntryDialog(entry: entry), @@ -311,7 +311,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix bool _isMainMode(BuildContext context) => context.read>().value == AppMode.main; - void _goToSourceViewer(BuildContext context, AvesEntry entry) { + void _goToSourceViewer(BuildContext context) { Navigator.push( context, MaterialPageRoute( @@ -323,7 +323,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix ); } - void _goToDebug(BuildContext context, AvesEntry entry) { + void _goToDebug(BuildContext context) { Navigator.push( context, MaterialPageRoute( diff --git a/lib/widgets/viewer/info/entry_info_action_delegate.dart b/lib/widgets/viewer/action/entry_info_action_delegate.dart similarity index 62% rename from lib/widgets/viewer/info/entry_info_action_delegate.dart rename to lib/widgets/viewer/action/entry_info_action_delegate.dart index 5460b24dc..c5ea64f74 100644 --- a/lib/widgets/viewer/info/entry_info_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_info_action_delegate.dart @@ -1,22 +1,18 @@ import 'dart:async'; -import 'package:aves/app_mode.dart'; import 'package:aves/model/actions/entry_info_actions.dart'; import 'package:aves/model/actions/events.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/entry_xmp_iptc.dart'; -import 'package:aves/model/settings/settings.dart'; -import 'package:aves/model/source/collection_source.dart'; -import 'package:aves/services/common/services.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/viewer/action/single_entry_editor.dart'; import 'package:aves/widgets/viewer/embedded/notifications.dart'; import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -class EntryInfoActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwareMixin { +class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEditorMixin, SingleEntryEditorMixin { + @override final AvesEntry entry; final StreamController> _eventStreamController = StreamController>.broadcast(); @@ -74,43 +70,11 @@ class EntryInfoActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAw _eventStreamController.add(ActionEndedEvent(action)); } - bool _isMainMode(BuildContext context) => context.read>().value == AppMode.main; - - Future _edit(BuildContext context, Future> Function() apply) async { - if (!await checkStoragePermission(context, {entry})) return; - - // check before applying, because it relies on provider - // but the widget tree may be disposed if the user navigated away - final isMainMode = _isMainMode(context); - - final l10n = context.l10n; - final source = context.read(); - source?.pauseMonitoring(); - - final dataTypes = await apply(); - final success = dataTypes.isNotEmpty; - try { - if (success) { - if (isMainMode && source != null) { - await source.refreshEntry(entry, dataTypes); - } else { - await entry.refresh(background: false, persist: false, dataTypes: dataTypes, geocoderLocale: settings.appliedLocale); - } - showFeedback(context, l10n.genericSuccessFeedback); - } else { - showFeedback(context, l10n.genericFailureFeedback); - } - } catch (e, stack) { - await reportService.recordError(e, stack); - } - source?.resumeMonitoring(); - } - Future _editDate(BuildContext context) async { final modifier = await selectDateModifier(context, {entry}); if (modifier == null) return; - await _edit(context, () => entry.editDate(modifier)); + await edit(context, () => entry.editDate(modifier)); } Future _editTags(BuildContext context) async { @@ -121,13 +85,13 @@ class EntryInfoActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAw final currentTags = entry.tags; if (newTags.length == currentTags.length && newTags.every(currentTags.contains)) return; - await _edit(context, () => entry.editTags(newTags)); + await edit(context, () => entry.editTags(newTags)); } Future _removeMetadata(BuildContext context) async { final types = await selectMetadataToRemove(context, {entry}); if (types == null) return; - await _edit(context, () => entry.removeMetadata(types)); + await edit(context, () => entry.removeMetadata(types)); } } diff --git a/lib/widgets/viewer/printer.dart b/lib/widgets/viewer/action/printer.dart similarity index 100% rename from lib/widgets/viewer/printer.dart rename to lib/widgets/viewer/action/printer.dart diff --git a/lib/widgets/viewer/action/single_entry_editor.dart b/lib/widgets/viewer/action/single_entry_editor.dart new file mode 100644 index 000000000..f47d31314 --- /dev/null +++ b/lib/widgets/viewer/action/single_entry_editor.dart @@ -0,0 +1,48 @@ +import 'dart:async'; + +import 'package:aves/app_mode.dart'; +import 'package:aves/model/entry.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/services/common/services.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:flutter/material.dart'; +import 'package:provider/provider.dart'; + +mixin SingleEntryEditorMixin on FeedbackMixin, PermissionAwareMixin { + AvesEntry get entry; + + bool _isMainMode(BuildContext context) => context.read>().value == AppMode.main; + + Future edit(BuildContext context, Future> Function() apply) async { + if (!await checkStoragePermission(context, {entry})) return; + + // check before applying, because it relies on provider + // but the widget tree may be disposed if the user navigated away + final isMainMode = _isMainMode(context); + + final l10n = context.l10n; + final source = context.read(); + source?.pauseMonitoring(); + + final dataTypes = await apply(); + final success = dataTypes.isNotEmpty; + try { + if (success) { + if (isMainMode && source != null) { + await source.refreshEntry(entry, dataTypes); + } else { + await entry.refresh(background: false, persist: false, dataTypes: dataTypes, geocoderLocale: settings.appliedLocale); + } + showFeedback(context, l10n.genericSuccessFeedback); + } else { + showFeedback(context, l10n.genericFailureFeedback); + } + } catch (e, stack) { + await reportService.recordError(e, stack); + } + source?.resumeMonitoring(); + } +} diff --git a/lib/widgets/viewer/info/basic_section.dart b/lib/widgets/viewer/info/basic_section.dart index ffe5db8b8..c550e6863 100644 --- a/lib/widgets/viewer/info/basic_section.dart +++ b/lib/widgets/viewer/info/basic_section.dart @@ -13,7 +13,7 @@ import 'package:aves/utils/file_utils.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/viewer/info/common.dart'; -import 'package:aves/widgets/viewer/info/entry_info_action_delegate.dart'; +import 'package:aves/widgets/viewer/action/entry_info_action_delegate.dart'; import 'package:aves/widgets/viewer/info/owner.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; diff --git a/lib/widgets/viewer/info/info_app_bar.dart b/lib/widgets/viewer/info/info_app_bar.dart index d44e8059d..611a1b7f5 100644 --- a/lib/widgets/viewer/info/info_app_bar.dart +++ b/lib/widgets/viewer/info/info_app_bar.dart @@ -5,7 +5,7 @@ import 'package:aves/theme/icons.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/viewer/info/entry_info_action_delegate.dart'; +import 'package:aves/widgets/viewer/action/entry_info_action_delegate.dart'; import 'package:aves/widgets/viewer/info/info_search.dart'; import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart'; import 'package:flutter/material.dart'; diff --git a/lib/widgets/viewer/info/info_page.dart b/lib/widgets/viewer/info/info_page.dart index 78810f832..9ea65c17e 100644 --- a/lib/widgets/viewer/info/info_page.dart +++ b/lib/widgets/viewer/info/info_page.dart @@ -8,9 +8,9 @@ import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:aves/widgets/viewer/action/entry_info_action_delegate.dart'; import 'package:aves/widgets/viewer/embedded/embedded_data_opener.dart'; import 'package:aves/widgets/viewer/info/basic_section.dart'; -import 'package:aves/widgets/viewer/info/entry_info_action_delegate.dart'; import 'package:aves/widgets/viewer/info/info_app_bar.dart'; import 'package:aves/widgets/viewer/info/location_section.dart'; import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart'; diff --git a/lib/widgets/viewer/overlay/top.dart b/lib/widgets/viewer/overlay/top.dart index 0d255c6a2..b8b8087cb 100644 --- a/lib/widgets/viewer/overlay/top.dart +++ b/lib/widgets/viewer/overlay/top.dart @@ -9,7 +9,7 @@ import 'package:aves/widgets/common/basic/menu.dart'; import 'package:aves/widgets/common/basic/popup_menu_button.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/fx/sweeper.dart'; -import 'package:aves/widgets/viewer/entry_action_delegate.dart'; +import 'package:aves/widgets/viewer/action/entry_action_delegate.dart'; import 'package:aves/widgets/viewer/multipage/conductor.dart'; import 'package:aves/widgets/viewer/overlay/common.dart'; import 'package:aves/widgets/viewer/overlay/minimap.dart'; @@ -312,7 +312,7 @@ class _TopOverlayRow extends StatelessWidget { } } } - EntryActionDelegate().onActionSelected(context, targetEntry, action); + EntryActionDelegate(targetEntry).onActionSelected(context, action); } }