#146 editing orientation/tags automatically sets a metadata date

This commit is contained in:
Thibault Deckers 2021-12-28 17:18:58 +09:00
parent da7b2ee8c1
commit 445bde2494
12 changed files with 141 additions and 96 deletions

View file

@ -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
## <a id="v1.5.9"></a>[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)

View file

@ -648,26 +648,28 @@ class AvesEntry {
await locate(background: background, force: dataTypes.contains(EntryDataType.address), geocoderLocale: geocoderLocale);
}
Future<Set<EntryDataType>> rotate({required bool clockwise, required bool persist}) async {
final newFields = await metadataEditService.rotate(this, clockwise: clockwise);
if (newFields.isEmpty) return {};
Future<Set<EntryDataType>> _changeOrientation(Future<Map<String, dynamic>> Function() apply) async {
final dataTypes = await setMetadataDateIfMissing();
await _applyNewFields(newFields, persist: persist);
return {
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<Set<EntryDataType>> flip({required bool persist}) async {
final newFields = await metadataEditService.flip(this);
if (newFields.isEmpty) return {};
Future<Set<EntryDataType>> rotate({required bool clockwise}) {
return _changeOrientation(() => metadataEditService.rotate(this, clockwise: clockwise));
}
await _applyNewFields(newFields, persist: persist);
return {
EntryDataType.basic,
EntryDataType.catalog,
};
Future<Set<EntryDataType>> flip() {
return _changeOrientation(() => metadataEditService.flip(this));
}
Future<Set<EntryDataType>> 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<Set<EntryDataType>> 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<Set<EntryDataType>> removeMetadata(Set<MetadataType> types) async {
final newFields = await metadataEditService.removeTypes(this, types);
return newFields.isEmpty

View file

@ -43,6 +43,8 @@ extension ExtraAvesEntryXmpIptc on AvesEntry {
static String prefixOf(String ns) => nsDefaultPrefixes[ns] ?? '';
Future<Set<EntryDataType>> editTags(Set<String> 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<void> _setIptcKeywords(List<Map<String, dynamic>> iptc, Set<String> tags) async {

View file

@ -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<void> _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<void> _editDate(BuildContext context) async {

View file

@ -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<void> _addShortcut(BuildContext context, AvesEntry entry) async {
Future<void> _addShortcut(BuildContext context) async {
final result = await showDialog<Tuple2<AvesEntry?, String>>(
context: context,
builder: (context) => AddShortcutDialog(
@ -131,18 +137,12 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
}
}
Future<void> _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<void> _flip(BuildContext context) async {
await edit(context, entry.flip);
}
Future<void> _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<void> _rotate(BuildContext context, {required bool clockwise}) async {
await edit(context, () => entry.rotate(clockwise: clockwise));
}
Future<void> _rotateScreen(BuildContext context) async {
@ -156,7 +156,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
}
}
Future<void> _delete(BuildContext context, AvesEntry entry) async {
Future<void> _delete(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) {
@ -190,7 +190,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
}
}
Future<void> _export(BuildContext context, AvesEntry entry) async {
Future<void> _export(BuildContext context) async {
final source = context.read<CollectionSource>();
if (!source.initialized) {
await source.init();
@ -291,7 +291,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
);
}
Future<void> _rename(BuildContext context, AvesEntry entry) async {
Future<void> _rename(BuildContext context) async {
final newName = await showDialog<String>(
context: context,
builder: (context) => RenameEntryDialog(entry: entry),
@ -311,7 +311,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
bool _isMainMode(BuildContext context) => context.read<ValueNotifier<AppMode>>().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(

View file

@ -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<ActionEvent<EntryInfoAction>> _eventStreamController = StreamController<ActionEvent<EntryInfoAction>>.broadcast();
@ -74,43 +70,11 @@ class EntryInfoActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAw
_eventStreamController.add(ActionEndedEvent(action));
}
bool _isMainMode(BuildContext context) => context.read<ValueNotifier<AppMode>>().value == AppMode.main;
Future<void> _edit(BuildContext context, Future<Set<EntryDataType>> 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<CollectionSource?>();
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<void> _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<void> _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<void> _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));
}
}

View file

@ -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<ValueNotifier<AppMode>>().value == AppMode.main;
Future<void> edit(BuildContext context, Future<Set<EntryDataType>> 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<CollectionSource?>();
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();
}
}

View file

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

View file

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

View file

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

View file

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