From da7b2ee8c1aff0097a93211fe0f970034d99cdff Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Tue, 28 Dec 2021 10:37:52 +0900 Subject: [PATCH 01/28] #146 info: option to set date from other fields --- CHANGELOG.md | 4 + .../channel/calls/MetadataFetchHandler.kt | 52 ++ lib/l10n/app_de.arb | 2 - lib/l10n/app_en.arb | 7 +- lib/l10n/app_fr.arb | 7 +- lib/l10n/app_ko.arb | 7 +- lib/l10n/app_ru.arb | 2 - lib/model/entry.dart | 54 +- lib/model/metadata/date_modifier.dart | 13 +- lib/model/metadata/enums.dart | 45 +- .../metadata/metadata_edit_service.dart | 17 +- .../metadata/metadata_fetch_service.dart | 21 + lib/theme/durations.dart | 3 + lib/widgets/common/basic/wheel.dart | 82 +++ .../entry_editors/edit_entry_date_dialog.dart | 612 ++++++++---------- .../remove_entry_metadata_dialog.dart | 2 +- untranslated.json | 18 +- 17 files changed, 568 insertions(+), 380 deletions(-) create mode 100644 lib/widgets/common/basic/wheel.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index f76ab7a9a..6118fc9ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added + +- Info: option to set date from other fields + ## [v1.5.9] - 2021-12-22 ### Added diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt index 29c52af31..2bdb8919b 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt @@ -92,6 +92,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { "getXmp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getXmp) } "hasContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::hasContentResolverProp) } "getContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverProp) } + "getDate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getDate) } else -> result.notImplemented() } } @@ -876,6 +877,57 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { result.success(value?.toString()) } + private fun getDate(call: MethodCall, result: MethodChannel.Result) { + val mimeType = call.argument("mimeType") + val uri = call.argument("uri")?.let { Uri.parse(it) } + val sizeBytes = call.argument("sizeBytes")?.toLong() + val field = call.argument("field") + if (mimeType == null || uri == null || field == null) { + result.error("getDate-args", "failed because of missing arguments", null) + return + } + + var dateMillis: Long? = null + if (canReadWithMetadataExtractor(mimeType)) { + try { + Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> + val metadata = ImageMetadataReader.readMetadata(input) + val tag = when (field) { + ExifInterface.TAG_DATETIME -> ExifDirectoryBase.TAG_DATETIME + ExifInterface.TAG_DATETIME_DIGITIZED -> ExifDirectoryBase.TAG_DATETIME_DIGITIZED + ExifInterface.TAG_DATETIME_ORIGINAL -> ExifDirectoryBase.TAG_DATETIME_ORIGINAL + ExifInterface.TAG_GPS_DATESTAMP -> GpsDirectory.TAG_DATE_STAMP + else -> { + result.error("getDate-field", "unsupported ExifInterface field=$field", null) + return + } + } + + when (tag) { + ExifDirectoryBase.TAG_DATETIME, + ExifDirectoryBase.TAG_DATETIME_DIGITIZED, + ExifDirectoryBase.TAG_DATETIME_ORIGINAL -> { + for (dir in metadata.getDirectoriesOfType(ExifDirectoryBase::class.java)) { + dir.getSafeDateMillis(tag) { dateMillis = it } + } + } + GpsDirectory.TAG_DATE_STAMP -> { + for (dir in metadata.getDirectoriesOfType(GpsDirectory::class.java)) { + dir.gpsDate?.let { dateMillis = it.time } + } + } + } + } + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e) + } catch (e: NoClassDefFoundError) { + Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e) + } + } + + result.success(dateMillis) + } + companion object { private val LOG_TAG = LogUtils.createTag() const val CHANNEL = "deckers.thibault/aves/metadata_fetch" diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 8a61c59b7..1ab2eea74 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -180,9 +180,7 @@ "editEntryDateDialogTitle": "Datum & Uhrzeit", "editEntryDateDialogSet": "Festlegen", "editEntryDateDialogShift": "Verschieben", - "editEntryDateDialogExtractFromTitle": "Auszug aus dem Titel", "editEntryDateDialogClear": "Aufräumen", - "editEntryDateDialogFieldSelection": "Feldauswahl", "editEntryDateDialogHours": "Stunden", "editEntryDateDialogMinutes": "Minuten", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 53c770b61..25f6bf07e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -273,9 +273,12 @@ "editEntryDateDialogTitle": "Date & Time", "editEntryDateDialogSet": "Set", "editEntryDateDialogShift": "Shift", - "editEntryDateDialogExtractFromTitle": "Extract from title", "editEntryDateDialogClear": "Clear", - "editEntryDateDialogFieldSelection": "Field selection", + "editEntryDateDialogSourceFieldLabel": "Value:", + "editEntryDateDialogSourceCustomDate": "custom date", + "editEntryDateDialogSourceTitle": "extracted from title", + "editEntryDateDialogSourceFileModifiedDate": "file modified date", + "editEntryDateDialogTargetFieldsHeader": "Fields to modify", "editEntryDateDialogHours": "Hours", "editEntryDateDialogMinutes": "Minutes", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index d1c2e2b7c..a00495769 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -180,9 +180,12 @@ "editEntryDateDialogTitle": "Date & Heure", "editEntryDateDialogSet": "Régler", "editEntryDateDialogShift": "Décaler", - "editEntryDateDialogExtractFromTitle": "Extraire du titre", "editEntryDateDialogClear": "Effacer", - "editEntryDateDialogFieldSelection": "Champs affectés", + "editEntryDateDialogSourceFieldLabel": "Valeur :", + "editEntryDateDialogSourceCustomDate": "date personnalisée", + "editEntryDateDialogSourceTitle": "extraite du titre", + "editEntryDateDialogSourceFileModifiedDate": "date de modification du fichier", + "editEntryDateDialogTargetFieldsHeader": "Champs à modifier", "editEntryDateDialogHours": "Heures", "editEntryDateDialogMinutes": "Minutes", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 5ec0b4db0..a9de9ce8d 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -180,9 +180,12 @@ "editEntryDateDialogTitle": "날짜 및 시간", "editEntryDateDialogSet": "편집", "editEntryDateDialogShift": "시간 이동", - "editEntryDateDialogExtractFromTitle": "제목에서 추출", "editEntryDateDialogClear": "삭제", - "editEntryDateDialogFieldSelection": "필드 선택", + "editEntryDateDialogSourceFieldLabel": "값:", + "editEntryDateDialogSourceCustomDate": "지정 날짜", + "editEntryDateDialogSourceTitle": "제목에서 추출", + "editEntryDateDialogSourceFileModifiedDate": "파일 수정한 날짜", + "editEntryDateDialogTargetFieldsHeader": "수정할 필드", "editEntryDateDialogHours": "시간", "editEntryDateDialogMinutes": "분", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index af5601f72..f85ede8ec 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -180,9 +180,7 @@ "editEntryDateDialogTitle": "Дата и время", "editEntryDateDialogSet": "Задать", "editEntryDateDialogShift": "Сдвиг", - "editEntryDateDialogExtractFromTitle": "Извлечь из названия", "editEntryDateDialogClear": "Очистить", - "editEntryDateDialogFieldSelection": "Выбор поля", "editEntryDateDialogHours": "Часов", "editEntryDateDialogMinutes": "Минут", diff --git a/lib/model/entry.dart b/lib/model/entry.dart index 1584e22e5..cfcb70aab 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -671,15 +671,55 @@ class AvesEntry { } Future> editDate(DateModifier modifier) async { - if (modifier.action == DateEditAction.extractFromTitle) { - final _title = bestTitle; - if (_title == null) return {}; - final date = parseUnknownDateFormat(_title); - if (date == null) { - await reportService.recordError('failed to parse date from title=$_title', null); + final action = modifier.action; + if (action == DateEditAction.set) { + final source = modifier.setSource; + if (source == null) { + await reportService.recordError('edit date with action=$action but source is null', null); return {}; } - modifier = DateModifier(DateEditAction.set, modifier.fields, dateTime: date); + + switch (source) { + case DateSetSource.title: + final _title = bestTitle; + if (_title == null) return {}; + final date = parseUnknownDateFormat(_title); + if (date == null) { + await reportService.recordError('failed to parse date from title=$_title', null); + return {}; + } + modifier = DateModifier(DateEditAction.set, modifier.fields, setDateTime: date); + break; + case DateSetSource.fileModifiedDate: + final _path = path; + if (_path == null) { + await reportService.recordError('edit date with action=$action, source=$source but entry has no path, uri=$uri', null); + return {}; + } + try { + final fileModifiedDate = await File(_path).lastModified(); + modifier = DateModifier(DateEditAction.set, modifier.fields, setDateTime: fileModifiedDate); + } on FileSystemException catch (error, stack) { + await reportService.recordError(error, stack); + return {}; + } + break; + case DateSetSource.custom: + break; + default: + final field = source.toMetadataField(); + if (field == null) { + await reportService.recordError('failed to get field for action=$action, source=$source, uri=$uri', null); + return {}; + } + final fieldDate = await metadataFetchService.getDate(this, field); + if (fieldDate == null) { + await reportService.recordError('failed to get date for field=$field, source=$source, uri=$uri', null); + return {}; + } + modifier = DateModifier(DateEditAction.set, modifier.fields, setDateTime: fieldDate); + break; + } } final newFields = await metadataEditService.editDate(this, modifier); return newFields.isEmpty diff --git a/lib/model/metadata/date_modifier.dart b/lib/model/metadata/date_modifier.dart index 9c7e364f7..2924306eb 100644 --- a/lib/model/metadata/date_modifier.dart +++ b/lib/model/metadata/date_modifier.dart @@ -4,7 +4,7 @@ import 'package:flutter/widgets.dart'; @immutable class DateModifier { - static const allDateFields = [ + static const writableDateFields = [ MetadataField.exifDate, MetadataField.exifDateOriginal, MetadataField.exifDateDigitized, @@ -13,8 +13,15 @@ class DateModifier { final DateEditAction action; final Set fields; - final DateTime? dateTime; + final DateSetSource? setSource; + final DateTime? setDateTime; final int? shiftMinutes; - const DateModifier(this.action, this.fields, {this.dateTime, this.shiftMinutes}); + const DateModifier( + this.action, + this.fields, { + this.setSource, + this.setDateTime, + this.shiftMinutes, + }); } diff --git a/lib/model/metadata/enums.dart b/lib/model/metadata/enums.dart index 145ce2ea7..dc148cd49 100644 --- a/lib/model/metadata/enums.dart +++ b/lib/model/metadata/enums.dart @@ -8,10 +8,19 @@ enum MetadataField { enum DateEditAction { set, shift, - extractFromTitle, clear, } +enum DateSetSource { + custom, + title, + fileModifiedDate, + exifDate, + exifDateOriginal, + exifDateDigitized, + exifGpsDate, +} + enum MetadataType { // JPEG COM marker or GIF comment comment, @@ -80,3 +89,37 @@ extension ExtraMetadataType on MetadataType { } } } + +extension ExtraMetadataField on MetadataField { + String toExifInterfaceTag() { + switch (this) { + case MetadataField.exifDate: + return 'DateTime'; + case MetadataField.exifDateOriginal: + return 'DateTimeOriginal'; + case MetadataField.exifDateDigitized: + return 'DateTimeDigitized'; + case MetadataField.exifGpsDate: + return 'GPSDateStamp'; + } + } +} + +extension ExtraDateSetSource on DateSetSource { + MetadataField? toMetadataField() { + switch (this) { + case DateSetSource.custom: + case DateSetSource.title: + case DateSetSource.fileModifiedDate: + return null; + case DateSetSource.exifDate: + return MetadataField.exifDate; + case DateSetSource.exifDateOriginal: + return MetadataField.exifDateOriginal; + case DateSetSource.exifDateDigitized: + return MetadataField.exifDateDigitized; + case DateSetSource.exifGpsDate: + return MetadataField.exifGpsDate; + } + } +} diff --git a/lib/services/metadata/metadata_edit_service.dart b/lib/services/metadata/metadata_edit_service.dart index 0bb1cfaa4..2635c83a0 100644 --- a/lib/services/metadata/metadata_edit_service.dart +++ b/lib/services/metadata/metadata_edit_service.dart @@ -77,9 +77,9 @@ class PlatformMetadataEditService implements MetadataEditService { try { final result = await platform.invokeMethod('editDate', { 'entry': _toPlatformEntryMap(entry), - 'dateMillis': modifier.dateTime?.millisecondsSinceEpoch, + 'dateMillis': modifier.setDateTime?.millisecondsSinceEpoch, 'shiftMinutes': modifier.shiftMinutes, - 'fields': modifier.fields.map(_toExifInterfaceTag).toList(), + 'fields': modifier.fields.map((v) => v.toExifInterfaceTag()).toList(), }); if (result != null) return (result as Map).cast(); } on PlatformException catch (e, stack) { @@ -140,19 +140,6 @@ class PlatformMetadataEditService implements MetadataEditService { return {}; } - String _toExifInterfaceTag(MetadataField field) { - switch (field) { - case MetadataField.exifDate: - return 'DateTime'; - case MetadataField.exifDateOriginal: - return 'DateTimeOriginal'; - case MetadataField.exifDateDigitized: - return 'DateTimeDigitized'; - case MetadataField.exifGpsDate: - return 'GPSDateStamp'; - } - } - String _toPlatformMetadataType(MetadataType type) { switch (type) { case MetadataType.comment: diff --git a/lib/services/metadata/metadata_fetch_service.dart b/lib/services/metadata/metadata_fetch_service.dart index 4ed8210d9..39ebc5fbd 100644 --- a/lib/services/metadata/metadata_fetch_service.dart +++ b/lib/services/metadata/metadata_fetch_service.dart @@ -1,6 +1,7 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/entry_xmp_iptc.dart'; import 'package:aves/model/metadata/catalog.dart'; +import 'package:aves/model/metadata/enums.dart'; import 'package:aves/model/metadata/overlay.dart'; import 'package:aves/model/multipage.dart'; import 'package:aves/model/panorama.dart'; @@ -28,6 +29,8 @@ abstract class MetadataFetchService { Future hasContentResolverProp(String prop); Future getContentResolverProp(AvesEntry entry, String prop); + + Future getDate(AvesEntry entry, MetadataField field); } class PlatformMetadataFetchService implements MetadataFetchService { @@ -223,4 +226,22 @@ class PlatformMetadataFetchService implements MetadataFetchService { } return null; } + + @override + Future getDate(AvesEntry entry, MetadataField field) async { + try { + final result = await platform.invokeMethod('getDate', { + 'mimeType': entry.mimeType, + 'uri': entry.uri, + 'sizeBytes': entry.sizeBytes, + 'field': field.toExifInterfaceTag(), + }); + if (result is int) return DateTime.fromMillisecondsSinceEpoch(result); + } on PlatformException catch (e, stack) { + if (!entry.isMissingAtPath) { + await reportService.recordError(e, stack); + } + } + return null; + } } diff --git a/lib/theme/durations.dart b/lib/theme/durations.dart index 33060b455..92909675c 100644 --- a/lib/theme/durations.dart +++ b/lib/theme/durations.dart @@ -97,6 +97,7 @@ class DurationsProvider extends StatelessWidget { class DurationsData { // common animations final Duration expansionTileAnimation; + final Duration formTransition; final Duration iconAnimation; final Duration staggeredAnimation; final Duration staggeredAnimationPageTarget; @@ -111,6 +112,7 @@ class DurationsData { const DurationsData({ this.expansionTileAnimation = const Duration(milliseconds: 200), + this.formTransition = const Duration(milliseconds: 200), this.iconAnimation = const Duration(milliseconds: 300), this.staggeredAnimation = const Duration(milliseconds: 375), this.staggeredAnimationPageTarget = const Duration(milliseconds: 800), @@ -123,6 +125,7 @@ class DurationsData { return DurationsData( // as of Flutter v2.5.1, `ExpansionPanelList` throws if animation duration is zero expansionTileAnimation: const Duration(microseconds: 1), + formTransition: Duration.zero, iconAnimation: Duration.zero, staggeredAnimation: Duration.zero, staggeredAnimationPageTarget: Duration.zero, diff --git a/lib/widgets/common/basic/wheel.dart b/lib/widgets/common/basic/wheel.dart new file mode 100644 index 000000000..0c258668f --- /dev/null +++ b/lib/widgets/common/basic/wheel.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; + +class WheelSelector extends StatefulWidget { + final ValueNotifier valueNotifier; + final List values; + final TextStyle textStyle; + final TextAlign textAlign; + + const WheelSelector({ + Key? key, + required this.valueNotifier, + required this.values, + required this.textStyle, + required this.textAlign, + }) : super(key: key); + + @override + _WheelSelectorState createState() => _WheelSelectorState(); +} + +class _WheelSelectorState extends State> { + late final ScrollController _controller; + + static const itemSize = Size(40, 40); + + ValueNotifier get valueNotifier => widget.valueNotifier; + + List get values => widget.values; + + @override + void initState() { + super.initState(); + var indexOf = values.indexOf(valueNotifier.value); + _controller = FixedExtentScrollController( + initialItem: indexOf, + ); + } + + @override + Widget build(BuildContext context) { + const background = Colors.transparent; + final foreground = DefaultTextStyle.of(context).style.color!; + + return Padding( + padding: const EdgeInsets.all(8), + child: SizedBox( + width: itemSize.width, + height: itemSize.height * 3, + child: ShaderMask( + shaderCallback: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + background, + foreground, + foreground, + background, + ], + ).createShader, + child: ListWheelScrollView( + controller: _controller, + physics: const FixedExtentScrollPhysics(parent: BouncingScrollPhysics()), + diameterRatio: 1.2, + itemExtent: itemSize.height, + squeeze: 1.3, + onSelectedItemChanged: (i) => valueNotifier.value = values[i], + children: values + .map((i) => SizedBox.fromSize( + size: itemSize, + child: Text( + '$i', + textAlign: widget.textAlign, + style: widget.textStyle, + ), + )) + .toList(), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart b/lib/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart index e323847c6..9341213d8 100644 --- a/lib/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart @@ -4,13 +4,13 @@ import 'package:aves/model/metadata/enums.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/format.dart'; import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/basic/wheel.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../aves_dialog.dart'; - class EditEntryDateDialog extends StatefulWidget { final AvesEntry entry; @@ -25,169 +25,291 @@ class EditEntryDateDialog extends StatefulWidget { class _EditEntryDateDialogState extends State { DateEditAction _action = DateEditAction.set; - late Set _fields; - late DateTime _dateTime; - int _shiftMinutes = 60; + DateSetSource _setSource = DateSetSource.custom; + late DateTime _setDateTime; + late ValueNotifier _shiftHour, _shiftMinute; + late ValueNotifier _shiftSign; bool _showOptions = false; + final Set _fields = { + MetadataField.exifDate, + MetadataField.exifDateDigitized, + MetadataField.exifDateOriginal, + }; - AvesEntry get entry => widget.entry; + // use a different shade to avoid having the same background + // on the dialog (using the theme `dialogBackgroundColor`) + // and on the dropdown (using the theme `canvasColor`) + static final dropdownColor = Colors.grey.shade800; @override void initState() { super.initState(); - _fields = { - MetadataField.exifDate, - MetadataField.exifDateDigitized, - MetadataField.exifDateOriginal, - }; - _dateTime = entry.bestDate ?? DateTime.now(); + _initSet(); + _initShift(60); + } + + void _initSet() { + _setDateTime = widget.entry.bestDate ?? DateTime.now(); + } + + void _initShift(int initialMinutes) { + final abs = initialMinutes.abs(); + _shiftHour = ValueNotifier(abs ~/ 60); + _shiftMinute = ValueNotifier(abs % 60); + _shiftSign = ValueNotifier(initialMinutes.isNegative ? '-' : '+'); } @override Widget build(BuildContext context) { return MediaQueryDataProvider( - child: Builder( - builder: (context) { + child: TooltipTheme( + data: TooltipTheme.of(context).copyWith( + preferBelow: false, + ), + child: Builder(builder: (context) { final l10n = context.l10n; - final locale = l10n.localeName; - final use24hour = context.select((v) => v.alwaysUse24HourFormat); - void _updateAction(DateEditAction? action) { - if (action == null) return; - setState(() => _action = action); - } - - Widget _tileText(String text) => Text( - text, - softWrap: false, - overflow: TextOverflow.fade, - maxLines: 1, - ); - - final setTile = Row( - children: [ - Expanded( - child: RadioListTile( - value: DateEditAction.set, - groupValue: _action, - onChanged: _updateAction, - title: _tileText(l10n.editEntryDateDialogSet), - subtitle: Text(formatDateTime(_dateTime, locale, use24hour)), + return AvesDialog( + title: l10n.editEntryDateDialogTitle, + scrollableContent: [ + Padding( + padding: const EdgeInsets.only(left: 16, top: 8, right: 16), + child: DropdownButton( + items: DateEditAction.values + .map((v) => DropdownMenuItem( + value: v, + child: Text(_actionText(context, v)), + )) + .toList(), + value: _action, + onChanged: (v) => setState(() => _action = v!), + isExpanded: true, + dropdownColor: dropdownColor, ), ), - Padding( - padding: const EdgeInsetsDirectional.only(end: 12), - child: IconButton( - icon: const Icon(AIcons.edit), - onPressed: _action == DateEditAction.set ? _editDate : null, - tooltip: l10n.changeTooltip, + AnimatedSwitcher( + duration: context.read().formTransition, + switchInCurve: Curves.easeInOutCubic, + switchOutCurve: Curves.easeInOutCubic, + transitionBuilder: _formTransitionBuilder, + child: Column( + key: ValueKey(_action), + mainAxisSize: MainAxisSize.min, + children: [ + if (_action == DateEditAction.set) ..._buildSetContent(context), + if (_action == DateEditAction.shift) _buildShiftContent(context), + ], ), ), + _buildDestinationFields(context), + ], + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), + ), + TextButton( + onPressed: () => _submit(context), + child: Text(l10n.applyButtonLabel), + ), ], ); - final shiftTile = Row( - children: [ - Expanded( - child: RadioListTile( - value: DateEditAction.shift, - groupValue: _action, - onChanged: _updateAction, - title: _tileText(l10n.editEntryDateDialogShift), - subtitle: Text(_formatShiftDuration()), - ), - ), - Padding( - padding: const EdgeInsetsDirectional.only(end: 12), - child: IconButton( - icon: const Icon(AIcons.edit), - onPressed: _action == DateEditAction.shift ? _editShift : null, - tooltip: l10n.changeTooltip, - ), - ), - ], - ); - final extractFromTitleTile = RadioListTile( - value: DateEditAction.extractFromTitle, - groupValue: _action, - onChanged: _updateAction, - title: _tileText(l10n.editEntryDateDialogExtractFromTitle), - ); - final clearTile = RadioListTile( - value: DateEditAction.clear, - groupValue: _action, - onChanged: _updateAction, - title: _tileText(l10n.editEntryDateDialogClear), - ); - - final animationDuration = context.select((v) => v.expansionTileAnimation); - final theme = Theme.of(context); - return Theme( - data: theme.copyWith( - textTheme: theme.textTheme.copyWith( - // dense style font for tile subtitles, without modifying title font - bodyText2: const TextStyle(fontSize: 12), - ), - ), - child: AvesDialog( - title: l10n.editEntryDateDialogTitle, - scrollableContent: [ - setTile, - shiftTile, - extractFromTitleTile, - clearTile, - Padding( - padding: const EdgeInsets.only(bottom: 1), - child: ExpansionPanelList( - expansionCallback: (index, isExpanded) { - setState(() => _showOptions = !isExpanded); - }, - animationDuration: animationDuration, - expandedHeaderPadding: EdgeInsets.zero, - elevation: 0, - children: [ - ExpansionPanel( - headerBuilder: (context, isExpanded) => ListTile( - title: Text(l10n.editEntryDateDialogFieldSelection), - ), - body: Column( - children: DateModifier.allDateFields - .map((field) => SwitchListTile( - value: _fields.contains(field), - onChanged: (selected) => setState(() => selected ? _fields.add(field) : _fields.remove(field)), - title: Text(_fieldTitle(field)), - )) - .toList(), - ), - isExpanded: _showOptions, - canTapOnHeader: true, - backgroundColor: Theme.of(context).dialogBackgroundColor, - ), - ], - ), - ), - ], - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(MaterialLocalizations.of(context).cancelButtonLabel), - ), - TextButton( - onPressed: () => _submit(context), - child: Text(l10n.applyButtonLabel), - ), - ], - ), - ); - }, + }), ), ); } - String _formatShiftDuration() { - final abs = _shiftMinutes.abs(); - final h = abs ~/ 60; - final m = abs % 60; - return '${_shiftMinutes.isNegative ? '-' : '+'}$h:${m.toString().padLeft(2, '0')}'; + Widget _formTransitionBuilder(Widget child, Animation animation) => FadeTransition( + opacity: animation, + child: SizeTransition( + sizeFactor: animation, + axisAlignment: -1, + child: child, + ), + ); + + List _buildSetContent(BuildContext context) { + final l10n = context.l10n; + final locale = l10n.localeName; + final use24hour = context.select((v) => v.alwaysUse24HourFormat); + + return [ + Padding( + padding: const EdgeInsets.only(left: 16, right: 16), + child: Row( + children: [ + Text(l10n.editEntryDateDialogSourceFieldLabel), + const SizedBox(width: 8), + Expanded( + child: DropdownButton( + items: DateSetSource.values + .map((v) => DropdownMenuItem( + value: v, + child: Text(_setSourceText(context, v)), + )) + .toList(), + selectedItemBuilder: (context) => DateSetSource.values + .map((v) => DropdownMenuItem( + value: v, + child: Text( + _setSourceText(context, v), + softWrap: false, + overflow: TextOverflow.fade, + ), + )) + .toList(), + value: _setSource, + onChanged: (v) => setState(() => _setSource = v!), + isExpanded: true, + dropdownColor: dropdownColor, + ), + ), + ], + ), + ), + AnimatedSwitcher( + duration: context.read().formTransition, + switchInCurve: Curves.easeInOutCubic, + switchOutCurve: Curves.easeInOutCubic, + transitionBuilder: _formTransitionBuilder, + child: _setSource == DateSetSource.custom + ? Padding( + padding: const EdgeInsets.only(left: 16, right: 12), + child: Row( + children: [ + Expanded(child: Text(formatDateTime(_setDateTime, locale, use24hour))), + IconButton( + icon: const Icon(AIcons.edit), + onPressed: _editDate, + tooltip: l10n.changeTooltip, + ), + ], + ), + ) + : const SizedBox(), + ), + ]; + } + + Widget _buildShiftContent(BuildContext context) { + const textStyle = TextStyle(fontSize: 34); + return Center( + child: Table( + children: [ + TableRow( + children: [ + const SizedBox(), + Center(child: Text(context.l10n.editEntryDateDialogHours)), + const SizedBox(), + Center(child: Text(context.l10n.editEntryDateDialogMinutes)), + ], + ), + TableRow( + children: [ + WheelSelector( + valueNotifier: _shiftSign, + values: const ['+', '-'], + textStyle: textStyle, + textAlign: TextAlign.center, + ), + Align( + alignment: Alignment.centerRight, + child: WheelSelector( + valueNotifier: _shiftHour, + values: List.generate(24, (i) => i), + textStyle: textStyle, + textAlign: TextAlign.end, + ), + ), + const Padding( + padding: EdgeInsets.only(bottom: 2), + child: Text( + ':', + style: textStyle, + ), + ), + Align( + alignment: Alignment.centerLeft, + child: WheelSelector( + valueNotifier: _shiftMinute, + values: List.generate(60, (i) => i), + textStyle: textStyle, + textAlign: TextAlign.end, + ), + ), + ], + ) + ], + defaultColumnWidth: const IntrinsicColumnWidth(), + defaultVerticalAlignment: TableCellVerticalAlignment.middle, + ), + ); + } + + Widget _buildDestinationFields(BuildContext context) { + return Padding( + // small padding as a workaround to show dialog action divider + padding: const EdgeInsets.only(bottom: 1), + child: ExpansionPanelList( + expansionCallback: (index, isExpanded) { + setState(() => _showOptions = !isExpanded); + }, + animationDuration: context.read().expansionTileAnimation, + expandedHeaderPadding: EdgeInsets.zero, + elevation: 0, + children: [ + ExpansionPanel( + headerBuilder: (context, isExpanded) => ListTile( + title: Text(context.l10n.editEntryDateDialogTargetFieldsHeader), + ), + body: Column( + children: DateModifier.writableDateFields + .map((field) => SwitchListTile( + value: _fields.contains(field), + onChanged: (selected) => setState(() => selected ? _fields.add(field) : _fields.remove(field)), + title: Text(_fieldTitle(field)), + )) + .toList(), + ), + isExpanded: _showOptions, + canTapOnHeader: true, + backgroundColor: Colors.transparent, + ), + ], + ), + ); + } + + String _actionText(BuildContext context, DateEditAction action) { + final l10n = context.l10n; + switch (action) { + case DateEditAction.set: + return l10n.editEntryDateDialogSet; + case DateEditAction.shift: + return l10n.editEntryDateDialogShift; + case DateEditAction.clear: + return l10n.editEntryDateDialogClear; + } + } + + String _setSourceText(BuildContext context, DateSetSource source) { + final l10n = context.l10n; + switch (source) { + case DateSetSource.custom: + return l10n.editEntryDateDialogSourceCustomDate; + case DateSetSource.title: + return l10n.editEntryDateDialogSourceTitle; + case DateSetSource.fileModifiedDate: + return l10n.editEntryDateDialogSourceFileModifiedDate; + case DateSetSource.exifDate: + return 'Exif date'; + case DateSetSource.exifDateOriginal: + return 'Exif original date'; + case DateSetSource.exifDateDigitized: + return 'Exif digitized date'; + case DateSetSource.exifGpsDate: + return 'Exif GPS date'; + } } String _fieldTitle(MetadataField field) { @@ -206,7 +328,7 @@ class _EditEntryDateDialogState extends State { Future _editDate() async { final _date = await showDatePicker( context: context, - initialDate: _dateTime, + initialDate: _setDateTime, firstDate: DateTime(0), lastDate: DateTime.now(), confirmText: context.l10n.nextButtonLabel, @@ -215,11 +337,11 @@ class _EditEntryDateDialogState extends State { final _time = await showTimePicker( context: context, - initialTime: TimeOfDay.fromDateTime(_dateTime), + initialTime: TimeOfDay.fromDateTime(_setDateTime), ); if (_time == null) return; - setState(() => _dateTime = DateTime( + setState(() => _setDateTime = DateTime( _date.year, _date.month, _date.day, @@ -228,28 +350,16 @@ class _EditEntryDateDialogState extends State { )); } - void _editShift() async { - final picked = await showDialog( - context: context, - builder: (context) => TimeShiftDialog( - initialShiftMinutes: _shiftMinutes, - ), - ); - if (picked == null) return; - - setState(() => _shiftMinutes = picked); - } - void _submit(BuildContext context) { late DateModifier modifier; switch (_action) { case DateEditAction.set: - modifier = DateModifier(_action, _fields, dateTime: _dateTime); + modifier = DateModifier(_action, _fields, setSource: _setSource, setDateTime: _setDateTime); break; case DateEditAction.shift: - modifier = DateModifier(_action, _fields, shiftMinutes: _shiftMinutes); + final shiftTotalMinutes = (_shiftHour.value * 60 + _shiftMinute.value) * (_shiftSign.value == '+' ? 1 : -1); + modifier = DateModifier(_action, _fields, shiftMinutes: shiftTotalMinutes); break; - case DateEditAction.extractFromTitle: case DateEditAction.clear: modifier = DateModifier(_action, _fields); break; @@ -257,185 +367,3 @@ class _EditEntryDateDialogState extends State { Navigator.pop(context, modifier); } } - -class TimeShiftDialog extends StatefulWidget { - final int initialShiftMinutes; - - const TimeShiftDialog({ - Key? key, - required this.initialShiftMinutes, - }) : super(key: key); - - @override - _TimeShiftDialogState createState() => _TimeShiftDialogState(); -} - -class _TimeShiftDialogState extends State { - late ValueNotifier _hour, _minute; - late ValueNotifier _sign; - - @override - void initState() { - super.initState(); - final initial = widget.initialShiftMinutes; - final abs = initial.abs(); - _hour = ValueNotifier(abs ~/ 60); - _minute = ValueNotifier(abs % 60); - _sign = ValueNotifier(initial.isNegative ? '-' : '+'); - } - - @override - Widget build(BuildContext context) { - const textStyle = TextStyle(fontSize: 34); - return AvesDialog( - scrollableContent: [ - Center( - child: Padding( - padding: const EdgeInsets.only(top: 8), - child: Table( - children: [ - TableRow( - children: [ - const SizedBox(), - Center(child: Text(context.l10n.editEntryDateDialogHours)), - const SizedBox(), - Center(child: Text(context.l10n.editEntryDateDialogMinutes)), - ], - ), - TableRow( - children: [ - _Wheel( - valueNotifier: _sign, - values: const ['+', '-'], - textStyle: textStyle, - textAlign: TextAlign.center, - ), - Align( - alignment: Alignment.centerRight, - child: _Wheel( - valueNotifier: _hour, - values: List.generate(24, (i) => i), - textStyle: textStyle, - textAlign: TextAlign.end, - ), - ), - const Padding( - padding: EdgeInsets.only(bottom: 2), - child: Text( - ':', - style: textStyle, - ), - ), - Align( - alignment: Alignment.centerLeft, - child: _Wheel( - valueNotifier: _minute, - values: List.generate(60, (i) => i), - textStyle: textStyle, - textAlign: TextAlign.end, - ), - ), - ], - ) - ], - defaultColumnWidth: const IntrinsicColumnWidth(), - defaultVerticalAlignment: TableCellVerticalAlignment.middle, - ), - ), - ), - ], - hasScrollBar: false, - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(MaterialLocalizations.of(context).cancelButtonLabel), - ), - TextButton( - onPressed: () => Navigator.pop(context, (_hour.value * 60 + _minute.value) * (_sign.value == '+' ? 1 : -1)), - child: Text(MaterialLocalizations.of(context).okButtonLabel), - ), - ], - ); - } -} - -class _Wheel extends StatefulWidget { - final ValueNotifier valueNotifier; - final List values; - final TextStyle textStyle; - final TextAlign textAlign; - - const _Wheel({ - Key? key, - required this.valueNotifier, - required this.values, - required this.textStyle, - required this.textAlign, - }) : super(key: key); - - @override - _WheelState createState() => _WheelState(); -} - -class _WheelState extends State<_Wheel> { - late final ScrollController _controller; - - static const itemSize = Size(40, 40); - - ValueNotifier get valueNotifier => widget.valueNotifier; - - List get values => widget.values; - - @override - void initState() { - super.initState(); - var indexOf = values.indexOf(valueNotifier.value); - _controller = FixedExtentScrollController( - initialItem: indexOf, - ); - } - - @override - Widget build(BuildContext context) { - final background = Theme.of(context).dialogBackgroundColor; - final foreground = DefaultTextStyle.of(context).style.color!; - - return Padding( - padding: const EdgeInsets.all(8), - child: SizedBox( - width: itemSize.width, - height: itemSize.height * 3, - child: ShaderMask( - shaderCallback: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - background, - foreground, - foreground, - background, - ], - ).createShader, - child: ListWheelScrollView( - controller: _controller, - physics: const FixedExtentScrollPhysics(parent: BouncingScrollPhysics()), - diameterRatio: 1.2, - itemExtent: itemSize.height, - squeeze: 1.3, - onSelectedItemChanged: (i) => valueNotifier.value = values[i], - children: values - .map((i) => SizedBox.fromSize( - size: itemSize, - child: Text( - '$i', - textAlign: widget.textAlign, - style: widget.textStyle, - ), - )) - .toList(), - ), - ), - ), - ); - } -} diff --git a/lib/widgets/dialogs/entry_editors/remove_entry_metadata_dialog.dart b/lib/widgets/dialogs/entry_editors/remove_entry_metadata_dialog.dart index 581cafdaf..4967af2e4 100644 --- a/lib/widgets/dialogs/entry_editors/remove_entry_metadata_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/remove_entry_metadata_dialog.dart @@ -69,7 +69,7 @@ class _RemoveEntryMetadataDialogState extends State { ), isExpanded: _showMore, canTapOnHeader: true, - backgroundColor: Theme.of(context).dialogBackgroundColor, + backgroundColor: Colors.transparent, ), ], ), diff --git a/untranslated.json b/untranslated.json index 9e26dfeeb..347926e60 100644 --- a/untranslated.json +++ b/untranslated.json @@ -1 +1,17 @@ -{} \ No newline at end of file +{ + "de": [ + "editEntryDateDialogSourceFieldLabel", + "editEntryDateDialogSourceCustomDate", + "editEntryDateDialogSourceTitle", + "editEntryDateDialogSourceFileModifiedDate", + "editEntryDateDialogTargetFieldsHeader" + ], + + "ru": [ + "editEntryDateDialogSourceFieldLabel", + "editEntryDateDialogSourceCustomDate", + "editEntryDateDialogSourceTitle", + "editEntryDateDialogSourceFileModifiedDate", + "editEntryDateDialogTargetFieldsHeader" + ] +} From 445bde2494398b1afa7353eb7e327c9eb0c585ab Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Tue, 28 Dec 2021 17:18:58 +0900 Subject: [PATCH 02/28] #146 editing orientation/tags automatically sets a metadata date --- CHANGELOG.md | 11 +++- lib/model/entry.dart | 53 ++++++++++++------ lib/model/entry_xmp_iptc.dart | 7 ++- .../collection/entry_set_action_delegate.dart | 4 +- .../{ => action}/entry_action_delegate.dart | 56 +++++++++---------- .../entry_info_action_delegate.dart | 48 ++-------------- lib/widgets/viewer/{ => action}/printer.dart | 0 .../viewer/action/single_entry_editor.dart | 48 ++++++++++++++++ lib/widgets/viewer/info/basic_section.dart | 2 +- lib/widgets/viewer/info/info_app_bar.dart | 2 +- lib/widgets/viewer/info/info_page.dart | 2 +- lib/widgets/viewer/overlay/top.dart | 4 +- 12 files changed, 141 insertions(+), 96 deletions(-) rename lib/widgets/viewer/{ => action}/entry_action_delegate.dart (87%) rename lib/widgets/viewer/{info => action}/entry_info_action_delegate.dart (62%) rename lib/widgets/viewer/{ => action}/printer.dart (100%) create mode 100644 lib/widgets/viewer/action/single_entry_editor.dart 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); } } From 23c13c21f8a5592276928c64b10236e32773ba11 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Tue, 28 Dec 2021 17:51:21 +0900 Subject: [PATCH 03/28] info: fixed reading IPTC from PNG --- .../deckers/thibault/aves/metadata/MetadataExtractorHelper.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt index 5c84e153b..f0d73d1e3 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt @@ -87,7 +87,7 @@ object MetadataExtractorHelper { if (dataBytes != null) { val start = dataBytes.indexOf(Metadata.IPTC_MARKER_BYTE) if (start != -1) { - val segmentBytes = dataBytes.copyOfRange(fromIndex = start, toIndex = dataBytes.size - start) + val segmentBytes = dataBytes.copyOfRange(fromIndex = start, toIndex = dataBytes.size) val metadata = com.drew.metadata.Metadata() IptcReader().extract(SequentialByteArrayReader(segmentBytes), metadata, segmentBytes.size.toLong()) return metadata.directories From 039983b8f78ceac1557be7372138f237a3508b50 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Wed, 29 Dec 2021 15:28:07 +0900 Subject: [PATCH 04/28] #143 rating: cataloguing, thumbnail overlay, info stars --- .../channel/calls/MetadataFetchHandler.kt | 31 +++- .../deckers/thibault/aves/metadata/XMP.kt | 13 +- lib/l10n/app_en.arb | 1 + lib/model/entry.dart | 2 + lib/model/metadata/catalog.dart | 9 +- lib/model/metadata_db.dart | 3 +- lib/model/metadata_db_upgrade.dart | 8 + lib/model/settings/defaults.dart | 1 + lib/model/settings/settings.dart | 6 + .../metadata/metadata_fetch_service.dart | 1 + lib/theme/icons.dart | 3 +- lib/widgets/common/grid/theme.dart | 4 +- lib/widgets/common/identity/aves_icons.dart | 24 +++ lib/widgets/common/thumbnail/overlay.dart | 5 +- .../settings/thumbnails/thumbnails.dart | 137 +++++++++++------- lib/widgets/viewer/debug/db.dart | 1 + lib/widgets/viewer/info/basic_section.dart | 25 +++- untranslated.json | 14 +- 18 files changed, 209 insertions(+), 79 deletions(-) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt index 2bdb8919b..81d30936f 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt @@ -78,6 +78,7 @@ import java.nio.charset.Charset import java.nio.charset.StandardCharsets import java.text.ParseException import java.util.* +import kotlin.math.roundToInt import kotlin.math.roundToLong class MetadataFetchHandler(private val context: Context) : MethodCallHandler { @@ -374,6 +375,10 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { // set `KEY_XMP_SUBJECTS` from these fields (by precedence): // - ME / XMP / dc:subject // - ME / IPTC / keywords + // set `KEY_RATING` from these fields (by precedence): + // - ME / XMP / xmp:Rating + // - ME / XMP / MicrosoftPhoto:Rating + // - ME / XMP / acdsee:rating private fun getCatalogMetadata(call: MethodCall, result: MethodChannel.Result) { val mimeType = call.argument("mimeType") val uri = call.argument("uri")?.let { Uri.parse(it) } @@ -459,22 +464,34 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) { val xmpMeta = dir.xmpMeta try { - if (xmpMeta.doesPropertyExist(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME)) { - val count = xmpMeta.countArrayItems(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME) - val values = (1 until count + 1).map { xmpMeta.getArrayItem(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME, it).value } + if (xmpMeta.doesPropertyExist(XMP.DC_SCHEMA_NS, XMP.DC_SUBJECT_PROP_NAME)) { + val count = xmpMeta.countArrayItems(XMP.DC_SCHEMA_NS, XMP.DC_SUBJECT_PROP_NAME) + val values = (1 until count + 1).map { xmpMeta.getArrayItem(XMP.DC_SCHEMA_NS, XMP.DC_SUBJECT_PROP_NAME, it).value } metadataMap[KEY_XMP_SUBJECTS] = values.joinToString(XMP_SUBJECTS_SEPARATOR) } - xmpMeta.getSafeLocalizedText(XMP.DC_SCHEMA_NS, XMP.TITLE_PROP_NAME, acceptBlank = false) { metadataMap[KEY_XMP_TITLE_DESCRIPTION] = it } + xmpMeta.getSafeLocalizedText(XMP.DC_SCHEMA_NS, XMP.DC_TITLE_PROP_NAME, acceptBlank = false) { metadataMap[KEY_XMP_TITLE_DESCRIPTION] = it } if (!metadataMap.containsKey(KEY_XMP_TITLE_DESCRIPTION)) { - xmpMeta.getSafeLocalizedText(XMP.DC_SCHEMA_NS, XMP.DESCRIPTION_PROP_NAME, acceptBlank = false) { metadataMap[KEY_XMP_TITLE_DESCRIPTION] = it } + xmpMeta.getSafeLocalizedText(XMP.DC_SCHEMA_NS, XMP.DC_DESCRIPTION_PROP_NAME, acceptBlank = false) { metadataMap[KEY_XMP_TITLE_DESCRIPTION] = it } } if (!metadataMap.containsKey(KEY_DATE_MILLIS)) { - xmpMeta.getSafeDateMillis(XMP.XMP_SCHEMA_NS, XMP.CREATE_DATE_PROP_NAME) { metadataMap[KEY_DATE_MILLIS] = it } + xmpMeta.getSafeDateMillis(XMP.XMP_SCHEMA_NS, XMP.XMP_CREATE_DATE_PROP_NAME) { metadataMap[KEY_DATE_MILLIS] = it } if (!metadataMap.containsKey(KEY_DATE_MILLIS)) { xmpMeta.getSafeDateMillis(XMP.PHOTOSHOP_SCHEMA_NS, XMP.PS_DATE_CREATED_PROP_NAME) { metadataMap[KEY_DATE_MILLIS] = it } } } + xmpMeta.getSafeInt(XMP.XMP_SCHEMA_NS, XMP.XMP_RATING_PROP_NAME) { if (it in RATING_RANGE) metadataMap[KEY_RATING] = it } + if (!metadataMap.containsKey(KEY_RATING)) { + xmpMeta.getSafeInt(XMP.MICROSOFTPHOTO_SCHEMA_NS, XMP.MS_RATING_PROP_NAME) { percentRating -> + // values of 1,25,50,75,99% correspond to 1,2,3,4,5 stars + val standardRating = (percentRating / 25f).roundToInt() + 1 + if (standardRating in RATING_RANGE) metadataMap[KEY_RATING] = standardRating + } + if (!metadataMap.containsKey(KEY_RATING)) { + xmpMeta.getSafeInt(XMP.ACDSEE_SCHEMA_NS, XMP.ACDSEE_RATING_PROP_NAME) { if (it in RATING_RANGE) metadataMap[KEY_RATING] = it } + } + } + // identification of panorama (aka photo sphere) if (xmpMeta.isPanorama()) { flags = flags or MASK_IS_360 @@ -966,6 +983,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { private const val KEY_LONGITUDE = "longitude" private const val KEY_XMP_SUBJECTS = "xmpSubjects" private const val KEY_XMP_TITLE_DESCRIPTION = "xmpTitleDescription" + private const val KEY_RATING = "rating" private const val MASK_IS_ANIMATED = 1 shl 0 private const val MASK_IS_FLIPPED = 1 shl 1 @@ -973,6 +991,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { private const val MASK_IS_360 = 1 shl 3 private const val MASK_IS_MULTIPAGE = 1 shl 4 private const val XMP_SUBJECTS_SEPARATOR = ";" + private val RATING_RANGE = 1..5 // overlay metadata private const val KEY_APERTURE = "aperture" diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt index 72c38f447..97f099fe4 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt @@ -14,7 +14,9 @@ object XMP { // standard namespaces // cf com.adobe.internal.xmp.XMPConst + const val ACDSEE_SCHEMA_NS = "http://ns.acdsee.com/iptc/1.0/" const val DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/" + const val MICROSOFTPHOTO_SCHEMA_NS = "http://ns.microsoft.com/photo/1.0/" const val PHOTOSHOP_SCHEMA_NS = "http://ns.adobe.com/photoshop/1.0/" const val XMP_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/" private const val XMP_GIMG_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/g/img/" @@ -27,11 +29,14 @@ object XMP { const val CONTAINER_SCHEMA_NS = "http://ns.google.com/photos/1.0/container/" private const val CONTAINER_ITEM_SCHEMA_NS = "http://ns.google.com/photos/1.0/container/item/" - const val SUBJECT_PROP_NAME = "dc:subject" - const val TITLE_PROP_NAME = "dc:title" - const val DESCRIPTION_PROP_NAME = "dc:description" + const val ACDSEE_RATING_PROP_NAME = "acdsee:rating" + const val DC_DESCRIPTION_PROP_NAME = "dc:description" + const val DC_SUBJECT_PROP_NAME = "dc:subject" + const val DC_TITLE_PROP_NAME = "dc:title" + const val MS_RATING_PROP_NAME = "MicrosoftPhoto:Rating" const val PS_DATE_CREATED_PROP_NAME = "photoshop:DateCreated" - const val CREATE_DATE_PROP_NAME = "xmp:CreateDate" + const val XMP_CREATE_DATE_PROP_NAME = "xmp:CreateDate" + const val XMP_RATING_PROP_NAME = "xmp:Rating" private const val GENERIC_LANG = "" private const val SPECIFIC_LANG = "en-US" diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 25f6bf07e..fa91e031e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -519,6 +519,7 @@ "settingsSectionThumbnails": "Thumbnails", "settingsThumbnailShowLocationIcon": "Show location icon", "settingsThumbnailShowMotionPhotoIcon": "Show motion photo icon", + "settingsThumbnailShowRatingIcon": "Show rating icon", "settingsThumbnailShowRawIcon": "Show raw icon", "settingsThumbnailShowVideoDuration": "Show video duration", diff --git a/lib/model/entry.dart b/lib/model/entry.dart index 93ef1d4f5..9d0925459 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -361,6 +361,8 @@ class AvesEntry { return _bestDate; } + int? get rating => _catalogMetadata?.rating; + int get rotationDegrees => _catalogMetadata?.rotationDegrees ?? sourceRotationDegrees; set rotationDegrees(int rotationDegrees) { diff --git a/lib/model/metadata/catalog.dart b/lib/model/metadata/catalog.dart index 1c532673e..3f204ea72 100644 --- a/lib/model/metadata/catalog.dart +++ b/lib/model/metadata/catalog.dart @@ -5,7 +5,7 @@ class CatalogMetadata { final int? contentId, dateMillis; final bool isAnimated, isGeotiff, is360, isMultiPage; bool isFlipped; - int? rotationDegrees; + int? rating, rotationDegrees; final String? mimeType, xmpSubjects, xmpTitleDescription; double? latitude, longitude; Address? address; @@ -31,6 +31,7 @@ class CatalogMetadata { this.xmpTitleDescription, double? latitude, double? longitude, + this.rating, }) { // Geocoder throws an `IllegalArgumentException` when a coordinate has a funky value like `1.7056881853375E7` // We also exclude zero coordinates, taking into account precision errors (e.g. {5.952380952380953e-11,-2.7777777777777777e-10}), @@ -67,6 +68,7 @@ class CatalogMetadata { xmpTitleDescription: xmpTitleDescription, latitude: latitude, longitude: longitude, + rating: rating, ); } @@ -87,6 +89,8 @@ class CatalogMetadata { xmpTitleDescription: map['xmpTitleDescription'] ?? '', latitude: map['latitude'], longitude: map['longitude'], + // `rotationDegrees` should default to `null`, not 0 + rating: map['rating'], ); } @@ -100,8 +104,9 @@ class CatalogMetadata { 'xmpTitleDescription': xmpTitleDescription, 'latitude': latitude, 'longitude': longitude, + 'rating': rating, }; @override - String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, is360=$is360, isMultiPage=$isMultiPage, rotationDegrees=$rotationDegrees, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}'; + String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, is360=$is360, isMultiPage=$isMultiPage, rotationDegrees=$rotationDegrees, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription, latitude=$latitude, longitude=$longitude, rating=$rating}'; } diff --git a/lib/model/metadata_db.dart b/lib/model/metadata_db.dart index bb0a63201..ca9f09623 100644 --- a/lib/model/metadata_db.dart +++ b/lib/model/metadata_db.dart @@ -145,6 +145,7 @@ class SqfliteMetadataDb implements MetadataDb { ', xmpTitleDescription TEXT' ', latitude REAL' ', longitude REAL' + ', rating INTEGER' ')'); await db.execute('CREATE TABLE $addressTable(' 'contentId INTEGER PRIMARY KEY' @@ -168,7 +169,7 @@ class SqfliteMetadataDb implements MetadataDb { ')'); }, onUpgrade: MetadataDbUpgrader.upgradeDb, - version: 5, + version: 6, ); } diff --git a/lib/model/metadata_db_upgrade.dart b/lib/model/metadata_db_upgrade.dart index 578934b8d..d69e7857c 100644 --- a/lib/model/metadata_db_upgrade.dart +++ b/lib/model/metadata_db_upgrade.dart @@ -25,6 +25,9 @@ class MetadataDbUpgrader { case 4: await _upgradeFrom4(db); break; + case 5: + await _upgradeFrom5(db); + break; } oldVersion++; } @@ -121,4 +124,9 @@ class MetadataDbUpgrader { ', resumeTimeMillis INTEGER' ')'); } + + static Future _upgradeFrom5(Database db) async { + debugPrint('upgrading DB from v5'); + await db.execute('ALTER TABLE $metadataTable ADD COLUMN rating INTEGER;'); + } } diff --git a/lib/model/settings/defaults.dart b/lib/model/settings/defaults.dart index 96ff24077..cfd155b42 100644 --- a/lib/model/settings/defaults.dart +++ b/lib/model/settings/defaults.dart @@ -45,6 +45,7 @@ class SettingsDefaults { ]; static const showThumbnailLocation = true; static const showThumbnailMotionPhoto = true; + static const showThumbnailRating = true; static const showThumbnailRaw = true; static const showThumbnailVideoDuration = true; diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index f76a97765..3abbdea7a 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -63,6 +63,7 @@ class Settings extends ChangeNotifier { static const collectionSelectionQuickActionsKey = 'collection_selection_quick_actions'; static const showThumbnailLocationKey = 'show_thumbnail_location'; static const showThumbnailMotionPhotoKey = 'show_thumbnail_motion_photo'; + static const showThumbnailRatingKey = 'show_thumbnail_rating'; static const showThumbnailRawKey = 'show_thumbnail_raw'; static const showThumbnailVideoDurationKey = 'show_thumbnail_video_duration'; @@ -310,6 +311,10 @@ class Settings extends ChangeNotifier { set showThumbnailMotionPhoto(bool newValue) => setAndNotify(showThumbnailMotionPhotoKey, newValue); + bool get showThumbnailRating => getBoolOrDefault(showThumbnailRatingKey, SettingsDefaults.showThumbnailRating); + + set showThumbnailRating(bool newValue) => setAndNotify(showThumbnailRatingKey, newValue); + bool get showThumbnailRaw => getBoolOrDefault(showThumbnailRawKey, SettingsDefaults.showThumbnailRaw); set showThumbnailRaw(bool newValue) => setAndNotify(showThumbnailRawKey, newValue); @@ -619,6 +624,7 @@ class Settings extends ChangeNotifier { case mustBackTwiceToExitKey: case showThumbnailLocationKey: case showThumbnailMotionPhotoKey: + case showThumbnailRatingKey: case showThumbnailRawKey: case showThumbnailVideoDurationKey: case showOverlayOnOpeningKey: diff --git a/lib/services/metadata/metadata_fetch_service.dart b/lib/services/metadata/metadata_fetch_service.dart index 39ebc5fbd..574269843 100644 --- a/lib/services/metadata/metadata_fetch_service.dart +++ b/lib/services/metadata/metadata_fetch_service.dart @@ -66,6 +66,7 @@ class PlatformMetadataFetchService implements MetadataFetchService { // 'dateMillis': date taken in milliseconds since Epoch (long) // 'isAnimated': animated gif/webp (bool) // 'isFlipped': flipped according to EXIF orientation (bool) + // 'rating': rating in [1,5] (int) // 'rotationDegrees': rotation degrees according to EXIF orientation or other metadata (int) // 'latitude': latitude (double) // 'longitude': longitude (double) diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index 7bfb34635..87c5380db 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -111,8 +111,9 @@ class AIcons { static const IconData geo = Icons.language_outlined; static const IconData motionPhoto = Icons.motion_photos_on_outlined; static const IconData multiPage = Icons.burst_mode_outlined; - static const IconData videoThumb = Icons.play_circle_outline; + static const IconData rating = Icons.star_border_outlined; static const IconData threeSixty = Icons.threesixty_outlined; + static const IconData videoThumb = Icons.play_circle_outline; static const IconData selected = Icons.check_circle_outline; static const IconData unselected = Icons.radio_button_unchecked; diff --git a/lib/widgets/common/grid/theme.dart b/lib/widgets/common/grid/theme.dart index 3c4fda8d5..442754db6 100644 --- a/lib/widgets/common/grid/theme.dart +++ b/lib/widgets/common/grid/theme.dart @@ -30,6 +30,7 @@ class GridTheme extends StatelessWidget { highlightBorderWidth: highlightBorderWidth, showLocation: showLocation ?? settings.showThumbnailLocation, showMotionPhoto: settings.showThumbnailMotionPhoto, + showRating: settings.showThumbnailRating, showRaw: settings.showThumbnailRaw, showVideoDuration: settings.showThumbnailVideoDuration, ); @@ -41,7 +42,7 @@ class GridTheme extends StatelessWidget { class GridThemeData { final double iconSize, fontSize, highlightBorderWidth; - final bool showLocation, showMotionPhoto, showRaw, showVideoDuration; + final bool showLocation, showMotionPhoto, showRating, showRaw, showVideoDuration; const GridThemeData({ required this.iconSize, @@ -49,6 +50,7 @@ class GridThemeData { required this.highlightBorderWidth, required this.showLocation, required this.showMotionPhoto, + required this.showRating, required this.showRaw, required this.showVideoDuration, }); diff --git a/lib/widgets/common/identity/aves_icons.dart b/lib/widgets/common/identity/aves_icons.dart index a64941ac1..a2f85c220 100644 --- a/lib/widgets/common/identity/aves_icons.dart +++ b/lib/widgets/common/identity/aves_icons.dart @@ -139,6 +139,30 @@ class MultiPageIcon extends StatelessWidget { } } +class RatingIcon extends StatelessWidget { + final AvesEntry entry; + + const RatingIcon({ + Key? key, + required this.entry, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final gridTheme = context.watch(); + return DefaultTextStyle( + style: TextStyle( + color: Colors.grey.shade200, + fontSize: gridTheme.fontSize, + ), + child: OverlayIcon( + icon: AIcons.rating, + text: '${entry.rating}', + ), + ); + } +} + class OverlayIcon extends StatelessWidget { final IconData icon; final String? text; diff --git a/lib/widgets/common/thumbnail/overlay.dart b/lib/widgets/common/thumbnail/overlay.dart index 4e72d1c0e..cd0c9de0d 100644 --- a/lib/widgets/common/thumbnail/overlay.dart +++ b/lib/widgets/common/thumbnail/overlay.dart @@ -21,12 +21,11 @@ class ThumbnailEntryOverlay extends StatelessWidget { final children = [ if (entry.hasGps && context.select((t) => t.showLocation)) const GpsIcon(), if (entry.isVideo) - VideoIcon( - entry: entry, - ) + VideoIcon(entry: entry) else if (entry.isAnimated) const AnimatedImageIcon() else ...[ + if (entry.rating != null && context.select((t) => t.showRating)) RatingIcon(entry: entry), if (entry.isRaw && context.select((t) => t.showRaw)) const RawIcon(), if (entry.isGeotiff) const GeotiffIcon(), if (entry.is360) const SphericalImageIcon(), diff --git a/lib/widgets/settings/thumbnails/thumbnails.dart b/lib/widgets/settings/thumbnails/thumbnails.dart index e2e167a76..c10c87717 100644 --- a/lib/widgets/settings/thumbnails/thumbnails.dart +++ b/lib/widgets/settings/thumbnails/thumbnails.dart @@ -20,11 +20,6 @@ class ThumbnailsSection extends StatelessWidget { @override Widget build(BuildContext context) { - final currentShowThumbnailLocation = context.select((s) => s.showThumbnailLocation); - final currentShowThumbnailMotionPhoto = context.select((s) => s.showThumbnailMotionPhoto); - final currentShowThumbnailRaw = context.select((s) => s.showThumbnailRaw); - final currentShowThumbnailVideoDuration = context.select((s) => s.showThumbnailVideoDuration); - final iconSize = IconTheme.of(context).size! * MediaQuery.textScaleFactorOf(context); double opacityFor(bool enabled) => enabled ? 1 : .2; @@ -38,64 +33,96 @@ class ThumbnailsSection extends StatelessWidget { showHighlight: false, children: [ const CollectionActionsTile(), - SwitchListTile( - value: currentShowThumbnailLocation, - onChanged: (v) => settings.showThumbnailLocation = v, - title: Row( - children: [ - Expanded(child: Text(context.l10n.settingsThumbnailShowLocationIcon)), - AnimatedOpacity( - opacity: opacityFor(currentShowThumbnailLocation), - duration: Durations.toggleableTransitionAnimation, - child: Icon( - AIcons.location, - size: iconSize, - ), - ), - ], - ), - ), - SwitchListTile( - value: currentShowThumbnailMotionPhoto, - onChanged: (v) => settings.showThumbnailMotionPhoto = v, - title: Row( - children: [ - Expanded(child: Text(context.l10n.settingsThumbnailShowMotionPhotoIcon)), - AnimatedOpacity( - opacity: opacityFor(currentShowThumbnailMotionPhoto), - duration: Durations.toggleableTransitionAnimation, - child: Padding( - padding: EdgeInsets.symmetric(horizontal: iconSize * (1 - MotionPhotoIcon.scale) / 2), + Selector( + selector: (context, s) => s.showThumbnailLocation, + builder: (context, current, child) => SwitchListTile( + value: current, + onChanged: (v) => settings.showThumbnailLocation = v, + title: Row( + children: [ + Expanded(child: Text(context.l10n.settingsThumbnailShowLocationIcon)), + AnimatedOpacity( + opacity: opacityFor(current), + duration: Durations.toggleableTransitionAnimation, child: Icon( - AIcons.motionPhoto, - size: iconSize * MotionPhotoIcon.scale, + AIcons.location, + size: iconSize, ), ), - ), - ], + ], + ), ), ), - SwitchListTile( - value: currentShowThumbnailRaw, - onChanged: (v) => settings.showThumbnailRaw = v, - title: Row( - children: [ - Expanded(child: Text(context.l10n.settingsThumbnailShowRawIcon)), - AnimatedOpacity( - opacity: opacityFor(currentShowThumbnailRaw), - duration: Durations.toggleableTransitionAnimation, - child: Icon( - AIcons.raw, - size: iconSize, + Selector( + selector: (context, s) => s.showThumbnailMotionPhoto, + builder: (context, current, child) => SwitchListTile( + value: current, + onChanged: (v) => settings.showThumbnailMotionPhoto = v, + title: Row( + children: [ + Expanded(child: Text(context.l10n.settingsThumbnailShowMotionPhotoIcon)), + AnimatedOpacity( + opacity: opacityFor(current), + duration: Durations.toggleableTransitionAnimation, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: iconSize * (1 - MotionPhotoIcon.scale) / 2), + child: Icon( + AIcons.motionPhoto, + size: iconSize * MotionPhotoIcon.scale, + ), + ), ), - ), - ], + ], + ), ), ), - SwitchListTile( - value: currentShowThumbnailVideoDuration, - onChanged: (v) => settings.showThumbnailVideoDuration = v, - title: Text(context.l10n.settingsThumbnailShowVideoDuration), + Selector( + selector: (context, s) => s.showThumbnailRating, + builder: (context, current, child) => SwitchListTile( + value: current, + onChanged: (v) => settings.showThumbnailRating = v, + title: Row( + children: [ + Expanded(child: Text(context.l10n.settingsThumbnailShowRatingIcon)), + AnimatedOpacity( + opacity: opacityFor(current), + duration: Durations.toggleableTransitionAnimation, + child: Icon( + AIcons.rating, + size: iconSize, + ), + ), + ], + ), + ), + ), + Selector( + selector: (context, s) => s.showThumbnailRaw, + builder: (context, current, child) => SwitchListTile( + value: current, + onChanged: (v) => settings.showThumbnailRaw = v, + title: Row( + children: [ + Expanded(child: Text(context.l10n.settingsThumbnailShowRawIcon)), + AnimatedOpacity( + opacity: opacityFor(current), + duration: Durations.toggleableTransitionAnimation, + child: Icon( + AIcons.raw, + size: iconSize, + ), + ), + ], + ), + ), + ), + Selector( + selector: (context, s) => s.showThumbnailVideoDuration, + builder: (context, current, child) => SwitchListTile( + value: current, + onChanged: (v) => settings.showThumbnailVideoDuration = v, + title: Text(context.l10n.settingsThumbnailShowVideoDuration), + ), ), ], ); diff --git a/lib/widgets/viewer/debug/db.dart b/lib/widgets/viewer/debug/db.dart index 11279a2f3..ecc490ee5 100644 --- a/lib/widgets/viewer/debug/db.dart +++ b/lib/widgets/viewer/debug/db.dart @@ -123,6 +123,7 @@ class _DbTabState extends State { 'longitude': '${data.longitude}', 'xmpSubjects': data.xmpSubjects ?? '', 'xmpTitleDescription': data.xmpTitleDescription ?? '', + 'rating': '${data.rating}', }, ), ], diff --git a/lib/widgets/viewer/info/basic_section.dart b/lib/widgets/viewer/info/basic_section.dart index c550e6863..b53ea7bfa 100644 --- a/lib/widgets/viewer/info/basic_section.dart +++ b/lib/widgets/viewer/info/basic_section.dart @@ -12,8 +12,8 @@ import 'package:aves/theme/icons.dart'; 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/action/entry_info_action_delegate.dart'; +import 'package:aves/widgets/viewer/info/common.dart'; import 'package:aves/widgets/viewer/info/owner.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; @@ -74,15 +74,32 @@ class BasicSection extends StatelessWidget { if (path != null) l10n.viewerInfoLabelPath: path, }, ), - OwnerProp( - entry: entry, - ), + OwnerProp(entry: entry), + _buildRatingRow(), _buildChips(context), ], ); }); } + Widget _buildRatingRow() { + final rating = entry.rating; + return rating != null + ? Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: List.generate( + 5, + (i) => Icon( + Icons.star, + color: rating > i ? Colors.amber : Colors.grey[800], + ), + ), + ), + ) + : const SizedBox(); + } + Widget _buildChips(BuildContext context) { final tags = entry.tags.toList()..sort(compareAsciiUpperCase); final album = entry.directory; diff --git a/untranslated.json b/untranslated.json index 347926e60..723499108 100644 --- a/untranslated.json +++ b/untranslated.json @@ -4,7 +4,16 @@ "editEntryDateDialogSourceCustomDate", "editEntryDateDialogSourceTitle", "editEntryDateDialogSourceFileModifiedDate", - "editEntryDateDialogTargetFieldsHeader" + "editEntryDateDialogTargetFieldsHeader", + "settingsThumbnailShowRatingIcon" + ], + + "fr": [ + "settingsThumbnailShowRatingIcon" + ], + + "ko": [ + "settingsThumbnailShowRatingIcon" ], "ru": [ @@ -12,6 +21,7 @@ "editEntryDateDialogSourceCustomDate", "editEntryDateDialogSourceTitle", "editEntryDateDialogSourceFileModifiedDate", - "editEntryDateDialogTargetFieldsHeader" + "editEntryDateDialogTargetFieldsHeader", + "settingsThumbnailShowRatingIcon" ] } From 713ef3d782cdb175ecfebdeda0f99620645043a6 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Wed, 29 Dec 2021 18:27:32 +0900 Subject: [PATCH 05/28] #143 rating: sort/group/filter --- .../channel/calls/MetadataFetchHandler.kt | 7 +- lib/l10n/app_en.arb | 4 ++ lib/l10n/app_fr.arb | 4 ++ lib/l10n/app_ko.arb | 4 ++ lib/model/entry.dart | 26 +++++--- lib/model/filters/filters.dart | 4 ++ lib/model/filters/rating.dart | 64 +++++++++++++++++++ lib/model/metadata/catalog.dart | 8 +-- lib/model/source/collection_lens.dart | 49 +++++++++----- lib/model/source/enums.dart | 2 +- lib/model/source/section_keys.dart | 9 +++ .../metadata/metadata_fetch_service.dart | 2 +- lib/theme/icons.dart | 2 + lib/widgets/collection/app_bar.dart | 2 +- .../collection/draggable_thumb_label.dart | 6 ++ .../collection/entry_set_action_delegate.dart | 2 - lib/widgets/collection/grid/headers/any.dart | 3 + .../collection/grid/headers/rating.dart | 21 ++++++ lib/widgets/common/grid/scaling.dart | 1 - lib/widgets/common/thumbnail/overlay.dart | 2 +- lib/widgets/search/search_delegate.dart | 6 ++ lib/widgets/viewer/info/basic_section.dart | 21 +----- test/model/filters_test.dart | 4 ++ untranslated.json | 8 +++ 24 files changed, 203 insertions(+), 58 deletions(-) create mode 100644 lib/model/filters/rating.dart create mode 100644 lib/widgets/collection/grid/headers/rating.dart diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt index 81d30936f..aced1a04a 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt @@ -480,15 +480,15 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { } } - xmpMeta.getSafeInt(XMP.XMP_SCHEMA_NS, XMP.XMP_RATING_PROP_NAME) { if (it in RATING_RANGE) metadataMap[KEY_RATING] = it } + xmpMeta.getSafeInt(XMP.XMP_SCHEMA_NS, XMP.XMP_RATING_PROP_NAME) { metadataMap[KEY_RATING] = it } if (!metadataMap.containsKey(KEY_RATING)) { xmpMeta.getSafeInt(XMP.MICROSOFTPHOTO_SCHEMA_NS, XMP.MS_RATING_PROP_NAME) { percentRating -> // values of 1,25,50,75,99% correspond to 1,2,3,4,5 stars val standardRating = (percentRating / 25f).roundToInt() + 1 - if (standardRating in RATING_RANGE) metadataMap[KEY_RATING] = standardRating + metadataMap[KEY_RATING] = standardRating } if (!metadataMap.containsKey(KEY_RATING)) { - xmpMeta.getSafeInt(XMP.ACDSEE_SCHEMA_NS, XMP.ACDSEE_RATING_PROP_NAME) { if (it in RATING_RANGE) metadataMap[KEY_RATING] = it } + xmpMeta.getSafeInt(XMP.ACDSEE_SCHEMA_NS, XMP.ACDSEE_RATING_PROP_NAME) { metadataMap[KEY_RATING] = it } } } @@ -991,7 +991,6 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { private const val MASK_IS_360 = 1 shl 3 private const val MASK_IS_MULTIPAGE = 1 shl 4 private const val XMP_SUBJECTS_SEPARATOR = ";" - private val RATING_RANGE = 1..5 // overlay metadata private const val KEY_APERTURE = "aperture" diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index fa91e031e..3dd1d5446 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -94,6 +94,8 @@ "filterFavouriteLabel": "Favourite", "filterLocationEmptyLabel": "Unlocated", "filterTagEmptyLabel": "Untagged", + "filterRatingUnratedLabel": "Unrated", + "filterRatingRejectedLabel": "Rejected", "filterTypeAnimatedLabel": "Animated", "filterTypeMotionPhotoLabel": "Motion Photo", "filterTypePanoramaLabel": "Panorama", @@ -381,6 +383,7 @@ "collectionSortDate": "By date", "collectionSortSize": "By size", "collectionSortName": "By album & file name", + "collectionSortRating": "By rating", "collectionGroupAlbum": "By album", "collectionGroupMonth": "By month", @@ -494,6 +497,7 @@ "searchSectionCountries": "Countries", "searchSectionPlaces": "Places", "searchSectionTags": "Tags", + "searchSectionRating": "Ratings", "settingsPageTitle": "Settings", "settingsSystemDefault": "System", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index a00495769..9825b9225 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -79,6 +79,8 @@ "filterFavouriteLabel": "Favori", "filterLocationEmptyLabel": "Sans lieu", "filterTagEmptyLabel": "Sans libellé", + "filterRatingUnratedLabel": "Sans notation", + "filterRatingRejectedLabel": "Rejeté", "filterTypeAnimatedLabel": "Animation", "filterTypeMotionPhotoLabel": "Photo animée", "filterTypePanoramaLabel": "Panorama", @@ -273,6 +275,7 @@ "collectionSortDate": "par date", "collectionSortSize": "par taille", "collectionSortName": "alphabétique", + "collectionSortRating": "par notation", "collectionGroupAlbum": "par album", "collectionGroupMonth": "par mois", @@ -346,6 +349,7 @@ "searchSectionCountries": "Pays", "searchSectionPlaces": "Lieux", "searchSectionTags": "Libellés", + "searchSectionRating": "Notations", "settingsPageTitle": "Réglages", "settingsSystemDefault": "Système", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index a9de9ce8d..84b4c2c3d 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -79,6 +79,8 @@ "filterFavouriteLabel": "즐겨찾기", "filterLocationEmptyLabel": "장소 없음", "filterTagEmptyLabel": "태그 없음", + "filterRatingUnratedLabel": "별점 없음", + "filterRatingRejectedLabel": "거부됨", "filterTypeAnimatedLabel": "애니메이션", "filterTypeMotionPhotoLabel": "모션 포토", "filterTypePanoramaLabel": "파노라마", @@ -273,6 +275,7 @@ "collectionSortDate": "날짜", "collectionSortSize": "크기", "collectionSortName": "이름", + "collectionSortRating": "별점", "collectionGroupAlbum": "앨범별로", "collectionGroupMonth": "월별로", @@ -346,6 +349,7 @@ "searchSectionCountries": "국가", "searchSectionPlaces": "장소", "searchSectionTags": "태그", + "searchSectionRating": "별점", "settingsPageTitle": "설정", "settingsSystemDefault": "시스템", diff --git a/lib/model/entry.dart b/lib/model/entry.dart index 9d0925459..0153f4590 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -361,7 +361,7 @@ class AvesEntry { return _bestDate; } - int? get rating => _catalogMetadata?.rating; + int get rating => _catalogMetadata?.rating ?? 0; int get rotationDegrees => _catalogMetadata?.rotationDegrees ?? sourceRotationDegrees; @@ -861,14 +861,6 @@ class AvesEntry { return c != 0 ? c : compareAsciiUpperCase(a.extension ?? '', b.extension ?? ''); } - // compare by: - // 1) size descending - // 2) name ascending - static int compareBySize(AvesEntry a, AvesEntry b) { - final c = (b.sizeBytes ?? 0).compareTo(a.sizeBytes ?? 0); - return c != 0 ? c : compareByName(a, b); - } - static final _epoch = DateTime.fromMillisecondsSinceEpoch(0); // compare by: @@ -879,4 +871,20 @@ class AvesEntry { if (c != 0) return c; return compareByName(b, a); } + + // compare by: + // 1) rating descending + // 2) date descending + static int compareByRating(AvesEntry a, AvesEntry b) { + final c = b.rating.compareTo(a.rating); + return c != 0 ? c : compareByDate(a, b); + } + + // compare by: + // 1) size descending + // 2) date descending + static int compareBySize(AvesEntry a, AvesEntry b) { + final c = (b.sizeBytes ?? 0).compareTo(a.sizeBytes ?? 0); + return c != 0 ? c : compareByDate(a, b); + } } diff --git a/lib/model/filters/filters.dart b/lib/model/filters/filters.dart index 2da4daaa9..f2389dce7 100644 --- a/lib/model/filters/filters.dart +++ b/lib/model/filters/filters.dart @@ -8,6 +8,7 @@ import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/filters/path.dart'; import 'package:aves/model/filters/query.dart'; +import 'package:aves/model/filters/rating.dart'; import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/filters/type.dart'; import 'package:aves/utils/color_utils.dart'; @@ -26,6 +27,7 @@ abstract class CollectionFilter extends Equatable implements Comparable get props => [rating]; + + const RatingFilter(this.rating); + + RatingFilter.fromMap(Map json) + : this( + json['rating'] ?? 0, + ); + + @override + Map toMap() => { + 'type': type, + 'rating': rating, + }; + + @override + EntryFilter get test => (entry) => entry.rating == rating; + + @override + String get universalLabel => '$rating'; + + @override + String getLabel(BuildContext context) => formatRating(context, rating); + + @override + Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) { + switch (rating) { + case -1: + return Icon(AIcons.ratingRejected, size: size); + case 0: + return Icon(AIcons.ratingUnrated, size: size); + default: + return null; + } + } + + @override + String get category => type; + + @override + String get key => '$type-$rating'; + + static String formatRating(BuildContext context, int rating) { + switch (rating) { + case -1: + return context.l10n.filterRatingRejectedLabel; + case 0: + return context.l10n.filterRatingUnratedLabel; + default: + return '\u2B50' * rating; + } + } +} diff --git a/lib/model/metadata/catalog.dart b/lib/model/metadata/catalog.dart index 3f204ea72..008065451 100644 --- a/lib/model/metadata/catalog.dart +++ b/lib/model/metadata/catalog.dart @@ -5,10 +5,11 @@ class CatalogMetadata { final int? contentId, dateMillis; final bool isAnimated, isGeotiff, is360, isMultiPage; bool isFlipped; - int? rating, rotationDegrees; + int? rotationDegrees; final String? mimeType, xmpSubjects, xmpTitleDescription; double? latitude, longitude; Address? address; + int rating; static const double _precisionErrorTolerance = 1e-9; static const _isAnimatedMask = 1 << 0; @@ -31,7 +32,7 @@ class CatalogMetadata { this.xmpTitleDescription, double? latitude, double? longitude, - this.rating, + this.rating = 0, }) { // Geocoder throws an `IllegalArgumentException` when a coordinate has a funky value like `1.7056881853375E7` // We also exclude zero coordinates, taking into account precision errors (e.g. {5.952380952380953e-11,-2.7777777777777777e-10}), @@ -89,8 +90,7 @@ class CatalogMetadata { xmpTitleDescription: map['xmpTitleDescription'] ?? '', latitude: map['latitude'], longitude: map['longitude'], - // `rotationDegrees` should default to `null`, not 0 - rating: map['rating'], + rating: map['rating'] ?? 0, ); } diff --git a/lib/model/source/collection_lens.dart b/lib/model/source/collection_lens.dart index 59166c762..f94a085a8 100644 --- a/lib/model/source/collection_lens.dart +++ b/lib/model/source/collection_lens.dart @@ -9,6 +9,7 @@ import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/query.dart'; +import 'package:aves/model/filters/rating.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/events.dart'; @@ -108,15 +109,27 @@ class CollectionLens with ChangeNotifier { } bool get showHeaders { - if (sortFactor == EntrySortFactor.size) return false; + bool showAlbumHeaders() => !filters.any((f) => f is AlbumFilter); - if (sortFactor == EntrySortFactor.date && sectionFactor == EntryGroupFactor.none) return false; - - final albumSections = sortFactor == EntrySortFactor.name || (sortFactor == EntrySortFactor.date && sectionFactor == EntryGroupFactor.album); - final filterByAlbum = filters.any((f) => f is AlbumFilter); - if (albumSections && filterByAlbum) return false; - - return true; + switch (sortFactor) { + case EntrySortFactor.date: + switch (sectionFactor) { + case EntryGroupFactor.none: + return false; + case EntryGroupFactor.album: + return showAlbumHeaders(); + case EntryGroupFactor.month: + return true; + case EntryGroupFactor.day: + return true; + } + case EntrySortFactor.name: + return showAlbumHeaders(); + case EntrySortFactor.rating: + return !filters.any((f) => f is RatingFilter); + case EntrySortFactor.size: + return false; + } } void addFilter(CollectionFilter filter) { @@ -181,12 +194,15 @@ class CollectionLens with ChangeNotifier { case EntrySortFactor.date: _filteredSortedEntries.sort(AvesEntry.compareByDate); break; - case EntrySortFactor.size: - _filteredSortedEntries.sort(AvesEntry.compareBySize); - break; case EntrySortFactor.name: _filteredSortedEntries.sort(AvesEntry.compareByName); break; + case EntrySortFactor.rating: + _filteredSortedEntries.sort(AvesEntry.compareByRating); + break; + case EntrySortFactor.size: + _filteredSortedEntries.sort(AvesEntry.compareBySize); + break; } } @@ -210,15 +226,18 @@ class CollectionLens with ChangeNotifier { break; } break; + case EntrySortFactor.name: + final byAlbum = groupBy(_filteredSortedEntries, (entry) => EntryAlbumSectionKey(entry.directory)); + sections = SplayTreeMap>.of(byAlbum, (a, b) => source.compareAlbumsByName(a.directory!, b.directory!)); + break; + case EntrySortFactor.rating: + sections = groupBy(_filteredSortedEntries, (entry) => EntryRatingSectionKey(entry.rating)); + break; case EntrySortFactor.size: sections = Map.fromEntries([ MapEntry(const SectionKey(), _filteredSortedEntries), ]); break; - case EntrySortFactor.name: - final byAlbum = groupBy(_filteredSortedEntries, (entry) => EntryAlbumSectionKey(entry.directory)); - sections = SplayTreeMap>.of(byAlbum, (a, b) => source.compareAlbumsByName(a.directory!, b.directory!)); - break; } sections = Map.unmodifiable(sections); _sortedEntries = null; diff --git a/lib/model/source/enums.dart b/lib/model/source/enums.dart index ec4f816b5..e39413950 100644 --- a/lib/model/source/enums.dart +++ b/lib/model/source/enums.dart @@ -4,7 +4,7 @@ enum ChipSortFactor { date, name, count } enum AlbumChipGroupFactor { none, importance, volume } -enum EntrySortFactor { date, size, name } +enum EntrySortFactor { date, name, rating, size } enum EntryGroupFactor { none, album, month, day } diff --git a/lib/model/source/section_keys.dart b/lib/model/source/section_keys.dart index fd455cf02..52cfb4879 100644 --- a/lib/model/source/section_keys.dart +++ b/lib/model/source/section_keys.dart @@ -23,3 +23,12 @@ class EntryDateSectionKey extends SectionKey with EquatableMixin { const EntryDateSectionKey(this.date); } + +class EntryRatingSectionKey extends SectionKey with EquatableMixin { + final int rating; + + @override + List get props => [rating]; + + const EntryRatingSectionKey(this.rating); +} diff --git a/lib/services/metadata/metadata_fetch_service.dart b/lib/services/metadata/metadata_fetch_service.dart index 574269843..3bf7e42e8 100644 --- a/lib/services/metadata/metadata_fetch_service.dart +++ b/lib/services/metadata/metadata_fetch_service.dart @@ -66,7 +66,7 @@ class PlatformMetadataFetchService implements MetadataFetchService { // 'dateMillis': date taken in milliseconds since Epoch (long) // 'isAnimated': animated gif/webp (bool) // 'isFlipped': flipped according to EXIF orientation (bool) - // 'rating': rating in [1,5] (int) + // 'rating': rating in [-1,5] (int) // 'rotationDegrees': rotation degrees according to EXIF orientation or other metadata (int) // 'latitude': latitude (double) // 'longitude': longitude (double) diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index 87c5380db..19830fd48 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -22,6 +22,8 @@ class AIcons { static const IconData locationOff = Icons.location_off_outlined; static const IconData mainStorage = Icons.smartphone_outlined; static const IconData privacy = MdiIcons.shieldAccountOutline; + static const IconData ratingRejected = MdiIcons.starRemoveOutline; + static const IconData ratingUnrated = MdiIcons.starOffOutline; static const IconData raw = Icons.raw_on_outlined; static const IconData shooting = Icons.camera_outlined; static const IconData removableStorage = Icons.sd_storage_outlined; diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index fe2f0a577..491819f81 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -210,7 +210,6 @@ class _CollectionAppBarState extends State with SingleTickerPr action, appMode: appMode, isSelecting: isSelecting, - sortFactor: collection.sortFactor, itemCount: collection.entryCount, selectedItemCount: selectedItemCount, ); @@ -448,6 +447,7 @@ class _CollectionAppBarState extends State with SingleTickerPr EntrySortFactor.date: l10n.collectionSortDate, EntrySortFactor.size: l10n.collectionSortSize, EntrySortFactor.name: l10n.collectionSortName, + EntrySortFactor.rating: l10n.collectionSortRating, }, groupOptions: { EntryGroupFactor.album: l10n.collectionGroupAlbum, diff --git a/lib/widgets/collection/draggable_thumb_label.dart b/lib/widgets/collection/draggable_thumb_label.dart index b96b7e599..90017bdc0 100644 --- a/lib/widgets/collection/draggable_thumb_label.dart +++ b/lib/widgets/collection/draggable_thumb_label.dart @@ -1,4 +1,5 @@ import 'package:aves/model/entry.dart'; +import 'package:aves/model/filters/rating.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; @@ -47,6 +48,11 @@ class CollectionDraggableThumbLabel extends StatelessWidget { if (_showAlbumName(context, entry)) _getAlbumName(context, entry), if (entry.bestTitle != null) entry.bestTitle!, ]; + case EntrySortFactor.rating: + return [ + RatingFilter.formatRating(context, entry.rating), + DraggableThumbLabel.formatMonthThumbLabel(context, entry.bestDate), + ]; case EntrySortFactor.size: return [ if (entry.sizeBytes != null) formatFileSize(context.l10n.localeName, entry.sizeBytes!, round: 0), diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index ce6f01dd2..36f36a0bb 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -15,7 +15,6 @@ import 'package:aves/model/selection.dart'; import 'package:aves/model/source/analysis_controller.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; -import 'package:aves/model/source/enums.dart'; import 'package:aves/services/common/image_op_events.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/services/media/enums.dart'; @@ -44,7 +43,6 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa EntrySetAction action, { required AppMode appMode, required bool isSelecting, - required EntrySortFactor sortFactor, required int itemCount, required int selectedItemCount, }) { diff --git a/lib/widgets/collection/grid/headers/any.dart b/lib/widgets/collection/grid/headers/any.dart index d2654a8db..7e1340005 100644 --- a/lib/widgets/collection/grid/headers/any.dart +++ b/lib/widgets/collection/grid/headers/any.dart @@ -7,6 +7,7 @@ import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/section_keys.dart'; import 'package:aves/widgets/collection/grid/headers/album.dart'; import 'package:aves/widgets/collection/grid/headers/date.dart'; +import 'package:aves/widgets/collection/grid/headers/rating.dart'; import 'package:aves/widgets/common/grid/header.dart'; import 'package:flutter/material.dart'; @@ -49,6 +50,8 @@ class CollectionSectionHeader extends StatelessWidget { break; case EntrySortFactor.name: return _buildAlbumHeader(context); + case EntrySortFactor.rating: + return RatingSectionHeader(key: ValueKey(sectionKey), rating: (sectionKey as EntryRatingSectionKey).rating); case EntrySortFactor.size: break; } diff --git a/lib/widgets/collection/grid/headers/rating.dart b/lib/widgets/collection/grid/headers/rating.dart new file mode 100644 index 000000000..225e6923e --- /dev/null +++ b/lib/widgets/collection/grid/headers/rating.dart @@ -0,0 +1,21 @@ +import 'package:aves/model/filters/rating.dart'; +import 'package:aves/model/source/section_keys.dart'; +import 'package:aves/widgets/common/grid/header.dart'; +import 'package:flutter/material.dart'; + +class RatingSectionHeader extends StatelessWidget { + final int rating; + + const RatingSectionHeader({ + Key? key, + required this.rating, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return SectionHeader( + sectionKey: EntryRatingSectionKey(rating), + title: RatingFilter.formatRating(context, rating), + ); + } +} diff --git a/lib/widgets/common/grid/scaling.dart b/lib/widgets/common/grid/scaling.dart index 4b62f3a9f..58aacf147 100644 --- a/lib/widgets/common/grid/scaling.dart +++ b/lib/widgets/common/grid/scaling.dart @@ -316,7 +316,6 @@ class _ScaleOverlayState extends State<_ScaleOverlay> { colors: const [ Colors.black, Colors.black54, - // Colors.amber, ], ), ) diff --git a/lib/widgets/common/thumbnail/overlay.dart b/lib/widgets/common/thumbnail/overlay.dart index cd0c9de0d..5b35a8ccf 100644 --- a/lib/widgets/common/thumbnail/overlay.dart +++ b/lib/widgets/common/thumbnail/overlay.dart @@ -25,7 +25,7 @@ class ThumbnailEntryOverlay extends StatelessWidget { else if (entry.isAnimated) const AnimatedImageIcon() else ...[ - if (entry.rating != null && context.select((t) => t.showRating)) RatingIcon(entry: entry), + if (entry.rating != 0 && context.select((t) => t.showRating)) RatingIcon(entry: entry), if (entry.isRaw && context.select((t) => t.showRaw)) const RawIcon(), if (entry.isGeotiff) const GeotiffIcon(), if (entry.is360) const SphericalImageIcon(), diff --git a/lib/widgets/search/search_delegate.dart b/lib/widgets/search/search_delegate.dart index 24927b16c..48dc7c1b6 100644 --- a/lib/widgets/search/search_delegate.dart +++ b/lib/widgets/search/search_delegate.dart @@ -4,6 +4,7 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/filters/query.dart'; +import 'package:aves/model/filters/rating.dart'; import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/filters/type.dart'; import 'package:aves/model/settings/settings.dart'; @@ -179,6 +180,11 @@ class CollectionSearchDelegate { ], ); }), + _buildFilterRow( + context: context, + title: context.l10n.searchSectionRating, + filters: [0, -1, 5, 4, 3, 2, 1].map((rating) => RatingFilter(rating)).toList(), + ), ], ); }); diff --git a/lib/widgets/viewer/info/basic_section.dart b/lib/widgets/viewer/info/basic_section.dart index b53ea7bfa..d8c414987 100644 --- a/lib/widgets/viewer/info/basic_section.dart +++ b/lib/widgets/viewer/info/basic_section.dart @@ -4,6 +4,7 @@ import 'package:aves/model/favourites.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/mime.dart'; +import 'package:aves/model/filters/rating.dart'; import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/filters/type.dart'; import 'package:aves/model/source/collection_lens.dart'; @@ -75,31 +76,12 @@ class BasicSection extends StatelessWidget { }, ), OwnerProp(entry: entry), - _buildRatingRow(), _buildChips(context), ], ); }); } - Widget _buildRatingRow() { - final rating = entry.rating; - return rating != null - ? Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - children: List.generate( - 5, - (i) => Icon( - Icons.star, - color: rating > i ? Colors.amber : Colors.grey[800], - ), - ), - ), - ) - : const SizedBox(); - } - Widget _buildChips(BuildContext context) { final tags = entry.tags.toList()..sort(compareAsciiUpperCase); final album = entry.directory; @@ -113,6 +95,7 @@ class BasicSection extends StatelessWidget { if (entry.isVideo && entry.is360) TypeFilter.sphericalVideo, if (entry.isVideo && !entry.is360) MimeFilter.video, if (album != null) AlbumFilter(album, collection?.source.getAlbumDisplayName(context, album)), + if (entry.rating != 0) RatingFilter(entry.rating), ...tags.map((tag) => TagFilter(tag)), }; return AnimatedBuilder( diff --git a/test/model/filters_test.dart b/test/model/filters_test.dart index af979fad6..b8eaf5ce5 100644 --- a/test/model/filters_test.dart +++ b/test/model/filters_test.dart @@ -6,6 +6,7 @@ import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/filters/path.dart'; import 'package:aves/model/filters/query.dart'; +import 'package:aves/model/filters/rating.dart'; import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/filters/type.dart'; import 'package:aves/services/common/services.dart'; @@ -50,6 +51,9 @@ void main() { final query = QueryFilter('some query'); expect(query, jsonRoundTrip(query)); + const rating = RatingFilter(3); + expect(rating, jsonRoundTrip(rating)); + final tag = TagFilter('some tag'); expect(tag, jsonRoundTrip(tag)); diff --git a/untranslated.json b/untranslated.json index 723499108..20b2b2e7b 100644 --- a/untranslated.json +++ b/untranslated.json @@ -1,10 +1,14 @@ { "de": [ + "filterRatingUnratedLabel", + "filterRatingRejectedLabel", "editEntryDateDialogSourceFieldLabel", "editEntryDateDialogSourceCustomDate", "editEntryDateDialogSourceTitle", "editEntryDateDialogSourceFileModifiedDate", "editEntryDateDialogTargetFieldsHeader", + "collectionSortRating", + "searchSectionRating", "settingsThumbnailShowRatingIcon" ], @@ -17,11 +21,15 @@ ], "ru": [ + "filterRatingUnratedLabel", + "filterRatingRejectedLabel", "editEntryDateDialogSourceFieldLabel", "editEntryDateDialogSourceCustomDate", "editEntryDateDialogSourceTitle", "editEntryDateDialogSourceFileModifiedDate", "editEntryDateDialogTargetFieldsHeader", + "collectionSortRating", + "searchSectionRating", "settingsThumbnailShowRatingIcon" ] } From 711b6bcbc80be6d7e5364fbb7609bd003c0ac918 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Thu, 30 Dec 2021 10:35:31 +0900 Subject: [PATCH 06/28] various fixes --- lib/l10n/app_en.arb | 2 +- lib/l10n/app_fr.arb | 1 + lib/l10n/app_ko.arb | 3 ++- lib/model/video/metadata.dart | 7 ++++--- lib/theme/icons.dart | 2 +- lib/theme/themes.dart | 4 +++- lib/widgets/search/search_delegate.dart | 2 +- lib/widgets/settings/thumbnails/thumbnails.dart | 2 +- lib/widgets/viewer/info/info_search.dart | 2 +- test/model/video/metadata_test.dart | 1 + untranslated.json | 12 ++---------- 11 files changed, 18 insertions(+), 20 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 3dd1d5446..21d0beac4 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -523,7 +523,7 @@ "settingsSectionThumbnails": "Thumbnails", "settingsThumbnailShowLocationIcon": "Show location icon", "settingsThumbnailShowMotionPhotoIcon": "Show motion photo icon", - "settingsThumbnailShowRatingIcon": "Show rating icon", + "settingsThumbnailShowRating": "Show rating", "settingsThumbnailShowRawIcon": "Show raw icon", "settingsThumbnailShowVideoDuration": "Show video duration", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 9825b9225..a704e788c 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -375,6 +375,7 @@ "settingsSectionThumbnails": "Vignettes", "settingsThumbnailShowLocationIcon": "Afficher l’icône de lieu", "settingsThumbnailShowMotionPhotoIcon": "Afficher l’icône de photo animée", + "settingsThumbnailShowRating": "Afficher la notation", "settingsThumbnailShowRawIcon": "Afficher l’icône de photo raw", "settingsThumbnailShowVideoDuration": "Afficher la durée de la vidéo", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 84b4c2c3d..89c5a9017 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -72,7 +72,7 @@ "videoActionSetSpeed": "재생 배속", "videoActionSettings": "설정", - "entryInfoActionEditDate": "날짜와 시간 수정", + "entryInfoActionEditDate": "날짜 및 시간 수정", "entryInfoActionEditTags": "태그 수정", "entryInfoActionRemoveMetadata": "메타데이터 삭제", @@ -375,6 +375,7 @@ "settingsSectionThumbnails": "섬네일", "settingsThumbnailShowLocationIcon": "위치 아이콘 표시", "settingsThumbnailShowMotionPhotoIcon": "모션 포토 아이콘 표시", + "settingsThumbnailShowRating": "별점 표시", "settingsThumbnailShowRawIcon": "Raw 아이콘 표시", "settingsThumbnailShowVideoDuration": "동영상 길이 표시", diff --git a/lib/model/video/metadata.dart b/lib/model/video/metadata.dart index be38b825d..924d45918 100644 --- a/lib/model/video/metadata.dart +++ b/lib/model/video/metadata.dart @@ -22,7 +22,7 @@ import 'package:flutter/foundation.dart'; class VideoMetadataFormatter { static final _epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); - static final _anotherDatePattern = RegExp(r'(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})'); + static final _anotherDatePattern = RegExp(r'(\d{4})[-/](\d{2})[-/](\d{2}) (\d{2}):(\d{2}):(\d{2})'); static final _durationPattern = RegExp(r'(\d+):(\d+):(\d+)(.\d+)'); static final _locationPattern = RegExp(r'([+-][.0-9]+)'); static final Map _codecNames = { @@ -112,9 +112,10 @@ class VideoMetadataFormatter { return date.millisecondsSinceEpoch; } - // `DateTime` does not recognize: + // `DateTime` does not recognize these values found in the wild: // - `UTC 2021-05-30 19:14:21` - // - `2021` + // - `2021/10/31 21:23:17` + // - `2021` (not enough to build a date) final match = _anotherDatePattern.firstMatch(dateString); if (match != null) { diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index 19830fd48..c31abc19a 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -22,7 +22,7 @@ class AIcons { static const IconData locationOff = Icons.location_off_outlined; static const IconData mainStorage = Icons.smartphone_outlined; static const IconData privacy = MdiIcons.shieldAccountOutline; - static const IconData ratingRejected = MdiIcons.starRemoveOutline; + static const IconData ratingRejected = MdiIcons.starMinusOutline; static const IconData ratingUnrated = MdiIcons.starOffOutline; static const IconData raw = Icons.raw_on_outlined; static const IconData shooting = Icons.camera_outlined; diff --git a/lib/theme/themes.dart b/lib/theme/themes.dart index 5eca4f2f9..5dd13fc3b 100644 --- a/lib/theme/themes.dart +++ b/lib/theme/themes.dart @@ -34,9 +34,11 @@ class Themes { fontFeatures: [FontFeature.enable('smcp')], ), ), - colorScheme: const ColorScheme.dark( + colorScheme: ColorScheme.dark( primary: _accentColor, secondary: _accentColor, + // surface color is used as background for the date picker header + surface: Colors.grey.shade800, onPrimary: Colors.white, onSecondary: Colors.white, ), diff --git a/lib/widgets/search/search_delegate.dart b/lib/widgets/search/search_delegate.dart index 48dc7c1b6..07247ac09 100644 --- a/lib/widgets/search/search_delegate.dart +++ b/lib/widgets/search/search_delegate.dart @@ -183,7 +183,7 @@ class CollectionSearchDelegate { _buildFilterRow( context: context, title: context.l10n.searchSectionRating, - filters: [0, -1, 5, 4, 3, 2, 1].map((rating) => RatingFilter(rating)).toList(), + filters: [0, 5, 4, 3, 2, 1, -1].map((rating) => RatingFilter(rating)).toList(), ), ], ); diff --git a/lib/widgets/settings/thumbnails/thumbnails.dart b/lib/widgets/settings/thumbnails/thumbnails.dart index c10c87717..ed8c8fb1a 100644 --- a/lib/widgets/settings/thumbnails/thumbnails.dart +++ b/lib/widgets/settings/thumbnails/thumbnails.dart @@ -83,7 +83,7 @@ class ThumbnailsSection extends StatelessWidget { onChanged: (v) => settings.showThumbnailRating = v, title: Row( children: [ - Expanded(child: Text(context.l10n.settingsThumbnailShowRatingIcon)), + Expanded(child: Text(context.l10n.settingsThumbnailShowRating)), AnimatedOpacity( opacity: opacityFor(current), duration: Durations.toggleableTransitionAnimation, diff --git a/lib/widgets/viewer/info/info_search.dart b/lib/widgets/viewer/info/info_search.dart index 30c8b6e3e..8f0bd4c2b 100644 --- a/lib/widgets/viewer/info/info_search.dart +++ b/lib/widgets/viewer/info/info_search.dart @@ -54,7 +54,7 @@ class InfoSearchDelegate extends SearchDelegate { Widget buildSuggestions(BuildContext context) { final l10n = context.l10n; final suggestions = { - l10n.viewerInfoSearchSuggestionDate: 'date or time or when -timer -uptime -exposure -timeline', + l10n.viewerInfoSearchSuggestionDate: 'date or time or when -timer -uptime -exposure -timeline -verbatim', l10n.viewerInfoSearchSuggestionDescription: 'abstract or description or comment or textual or title', l10n.viewerInfoSearchSuggestionDimensions: 'width or height or dimension or framesize or imagelength', l10n.viewerInfoSearchSuggestionResolution: 'resolution', diff --git a/test/model/video/metadata_test.dart b/test/model/video/metadata_test.dart index ac60cb941..469e0c574 100644 --- a/test/model/video/metadata_test.dart +++ b/test/model/video/metadata_test.dart @@ -7,5 +7,6 @@ void main() { expect(VideoMetadataFormatter.parseVideoDate('2011-05-08T03:46+09:00'), DateTime(2011, 5, 7, 18, 46).add(localOffset).millisecondsSinceEpoch); expect(VideoMetadataFormatter.parseVideoDate('UTC 2021-05-30 19:14:21'), DateTime(2021, 5, 30, 19, 14, 21).millisecondsSinceEpoch); + expect(VideoMetadataFormatter.parseVideoDate('2021/10/31 21:23:17'), DateTime(2021, 10, 31, 21, 23, 17).millisecondsSinceEpoch); }); } diff --git a/untranslated.json b/untranslated.json index 20b2b2e7b..f1c5f26d0 100644 --- a/untranslated.json +++ b/untranslated.json @@ -9,15 +9,7 @@ "editEntryDateDialogTargetFieldsHeader", "collectionSortRating", "searchSectionRating", - "settingsThumbnailShowRatingIcon" - ], - - "fr": [ - "settingsThumbnailShowRatingIcon" - ], - - "ko": [ - "settingsThumbnailShowRatingIcon" + "settingsThumbnailShowRating" ], "ru": [ @@ -30,6 +22,6 @@ "editEntryDateDialogTargetFieldsHeader", "collectionSortRating", "searchSectionRating", - "settingsThumbnailShowRatingIcon" + "settingsThumbnailShowRating" ] } From 8aacd5064f54590c34da4ef51aeb938acd886237 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Thu, 30 Dec 2021 11:51:52 +0900 Subject: [PATCH 07/28] debug: app query --- lib/widgets/debug/android_apps.dart | 105 ++++++++++++++++------------ 1 file changed, 59 insertions(+), 46 deletions(-) diff --git a/lib/widgets/debug/android_apps.dart b/lib/widgets/debug/android_apps.dart index c21c8e263..104721b4f 100644 --- a/lib/widgets/debug/android_apps.dart +++ b/lib/widgets/debug/android_apps.dart @@ -1,6 +1,7 @@ import 'package:aves/image_providers/app_icon_image_provider.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/utils/android_file_utils.dart'; +import 'package:aves/widgets/common/basic/query_bar.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/viewer/info/common.dart'; import 'package:collection/collection.dart'; @@ -15,6 +16,7 @@ class DebugAndroidAppSection extends StatefulWidget { class _DebugAndroidAppSectionState extends State with AutomaticKeepAliveClientMixin { late Future> _loader; + final ValueNotifier _queryNotifier = ValueNotifier(''); static const iconSize = 20.0; @@ -43,53 +45,64 @@ class _DebugAndroidAppSectionState extends State with Au final disabledTheme = enabledTheme.merge(const IconThemeData(opacity: .2)); return Column( crossAxisAlignment: CrossAxisAlignment.start, - children: packages.map((package) { - return Text.rich( - TextSpan( - children: [ - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: Image( - image: AppIconImage( - packageName: package.packageName, - size: iconSize, - ), - width: iconSize, - height: iconSize, + children: [ + QueryBar(queryNotifier: _queryNotifier), + ...packages.map((package) { + return ValueListenableBuilder( + valueListenable: _queryNotifier, + builder: (context, query, child) { + if ({package.packageName, ...package.potentialDirs}.none((v) => v.contains(query))) { + return const SizedBox(); + } + return Text.rich( + TextSpan( + children: [ + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Image( + image: AppIconImage( + packageName: package.packageName, + size: iconSize, + ), + width: iconSize, + height: iconSize, + ), + ), + TextSpan( + text: ' ${package.packageName}\n', + style: InfoRowGroup.keyStyle, + ), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: IconTheme( + data: package.categoryLauncher ? enabledTheme : disabledTheme, + child: const Icon( + Icons.launch_outlined, + size: iconSize, + ), + ), + ), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: IconTheme( + data: package.isSystem ? enabledTheme : disabledTheme, + child: const Icon( + Icons.android, + size: iconSize, + ), + ), + ), + TextSpan( + text: ' ${package.potentialDirs.join(', ')}\n', + style: InfoRowGroup.baseStyle, + ), + ], ), - ), - TextSpan( - text: ' ${package.packageName}\n', - style: InfoRowGroup.keyStyle, - ), - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: IconTheme( - data: package.categoryLauncher ? enabledTheme : disabledTheme, - child: const Icon( - Icons.launch_outlined, - size: iconSize, - ), - ), - ), - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: IconTheme( - data: package.isSystem ? enabledTheme : disabledTheme, - child: const Icon( - Icons.android, - size: iconSize, - ), - ), - ), - TextSpan( - text: ' ${package.potentialDirs.join(', ')}\n', - style: InfoRowGroup.baseStyle, - ), - ], - ), - ); - }).toList(), + ); + }, + ); + }) + ], ); }, ), From 25311c5fcb5bc8cf9c467dc7e55d5f8c51006b23 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Thu, 30 Dec 2021 14:06:01 +0900 Subject: [PATCH 08/28] #120 warn user if system file picker is disabled --- .../aves/channel/calls/DeviceHandler.kt | 12 ++++++++- .../streams/StorageAccessStreamHandler.kt | 2 +- .../thibault/aves/utils/PermissionManager.kt | 2 +- lib/l10n/app_en.arb | 2 ++ lib/model/device.dart | 5 +--- lib/services/device_service.dart | 14 +++++++++- .../action_mixins/permission_aware.dart | 27 ++++++++++++++++--- lib/widgets/debug/android_apps.dart | 2 +- untranslated.json | 14 ++++++++++ 9 files changed, 68 insertions(+), 12 deletions(-) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DeviceHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DeviceHandler.kt index e9c9fbc26..be8ee35ee 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DeviceHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DeviceHandler.kt @@ -1,6 +1,7 @@ package deckers.thibault.aves.channel.calls import android.content.Context +import android.content.Intent import android.content.res.Resources import android.os.Build import androidx.core.content.pm.ShortcutManagerCompat @@ -18,6 +19,7 @@ class DeviceHandler(private val context: Context) : MethodCallHandler { "getDefaultTimeZone" -> safe(call, result, ::getDefaultTimeZone) "getLocales" -> safe(call, result, ::getLocales) "getPerformanceClass" -> safe(call, result, ::getPerformanceClass) + "isSystemFilePickerEnabled" -> safe(call, result, ::isSystemFilePickerEnabled) else -> result.notImplemented() } } @@ -34,7 +36,6 @@ class DeviceHandler(private val context: Context) : MethodCallHandler { // but using hybrid composition would make it usable on API 19 too, // cf https://github.com/flutter/flutter/issues/23728 "canRenderGoogleMaps" to (sdkInt >= Build.VERSION_CODES.KITKAT_WATCH), - "hasFilePicker" to (sdkInt >= Build.VERSION_CODES.KITKAT), "showPinShortcutFeedback" to (sdkInt >= Build.VERSION_CODES.O), "supportEdgeToEdgeUIMode" to (sdkInt >= Build.VERSION_CODES.Q), ) @@ -82,6 +83,15 @@ class DeviceHandler(private val context: Context) : MethodCallHandler { result.success(Build.VERSION.SDK_INT) } + private fun isSystemFilePickerEnabled(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { + val enabled = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).resolveActivity(context.packageManager) != null + } else { + false + } + result.success(enabled) + } + companion object { const val CHANNEL = "deckers.thibault/aves/device" } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt index 8970a02d9..6d134fae4 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt @@ -170,7 +170,7 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any? MainActivity.pendingStorageAccessResultHandlers[MainActivity.OPEN_FILE_REQUEST] = PendingStorageAccessResultHandler(null, ::onGranted, ::onDenied) activity.startActivityForResult(intent, MainActivity.OPEN_FILE_REQUEST) } else { - MainActivity.notifyError("failed to resolve activity for intent=$intent") + MainActivity.notifyError("failed to resolve activity for intent=$intent extras=${intent.extras}") onDenied() } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt index e77a1648e..02eeaf485 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt @@ -55,7 +55,7 @@ object PermissionManager { MainActivity.pendingStorageAccessResultHandlers[MainActivity.DOCUMENT_TREE_ACCESS_REQUEST] = PendingStorageAccessResultHandler(path, onGranted, onDenied) activity.startActivityForResult(intent, MainActivity.DOCUMENT_TREE_ACCESS_REQUEST) } else { - MainActivity.notifyError("failed to resolve activity for intent=$intent") + MainActivity.notifyError("failed to resolve activity for intent=$intent extras=${intent.extras}") onDenied() } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 21d0beac4..f5354e8e1 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -204,6 +204,8 @@ } } }, + "missingSystemFilePickerDialogTitle": "Missing System File Picker", + "missingSystemFilePickerDialogMessage": "The system file picker is missing or disabled. Please enable it and try again.", "unsupportedTypeDialogTitle": "Unsupported Types", "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}.}}", diff --git a/lib/model/device.dart b/lib/model/device.dart index 7a8ab076b..e26583f61 100644 --- a/lib/model/device.dart +++ b/lib/model/device.dart @@ -6,7 +6,7 @@ final Device device = Device._private(); class Device { late final String _userAgent; late final bool _canGrantDirectoryAccess, _canPinShortcut, _canPrint, _canRenderFlagEmojis, _canRenderGoogleMaps; - late final bool _hasFilePicker, _showPinShortcutFeedback, _supportEdgeToEdgeUIMode; + late final bool _showPinShortcutFeedback, _supportEdgeToEdgeUIMode; String get userAgent => _userAgent; @@ -20,8 +20,6 @@ class Device { bool get canRenderGoogleMaps => _canRenderGoogleMaps; - bool get hasFilePicker => _hasFilePicker; - bool get showPinShortcutFeedback => _showPinShortcutFeedback; bool get supportEdgeToEdgeUIMode => _supportEdgeToEdgeUIMode; @@ -38,7 +36,6 @@ class Device { _canPrint = capabilities['canPrint'] ?? false; _canRenderFlagEmojis = capabilities['canRenderFlagEmojis'] ?? false; _canRenderGoogleMaps = capabilities['canRenderGoogleMaps'] ?? false; - _hasFilePicker = capabilities['hasFilePicker'] ?? false; _showPinShortcutFeedback = capabilities['showPinShortcutFeedback'] ?? false; _supportEdgeToEdgeUIMode = capabilities['supportEdgeToEdgeUIMode'] ?? false; } diff --git a/lib/services/device_service.dart b/lib/services/device_service.dart index 88cc22522..3360204f2 100644 --- a/lib/services/device_service.dart +++ b/lib/services/device_service.dart @@ -11,6 +11,8 @@ abstract class DeviceService { Future> getLocales(); Future getPerformanceClass(); + + Future isSystemFilePickerEnabled(); } class PlatformDeviceService implements DeviceService { @@ -60,7 +62,6 @@ class PlatformDeviceService implements DeviceService { @override Future getPerformanceClass() async { try { - await platform.invokeMethod('getPerformanceClass'); final result = await platform.invokeMethod('getPerformanceClass'); if (result != null) return result as int; } on PlatformException catch (e, stack) { @@ -68,4 +69,15 @@ class PlatformDeviceService implements DeviceService { } return 0; } + + @override + Future isSystemFilePickerEnabled() async { + try { + final result = await platform.invokeMethod('isSystemFilePickerEnabled'); + if (result != null) return result as bool; + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); + } + return false; + } } diff --git a/lib/widgets/common/action_mixins/permission_aware.dart b/lib/widgets/common/action_mixins/permission_aware.dart index ab41e0717..329c325f0 100644 --- a/lib/widgets/common/action_mixins/permission_aware.dart +++ b/lib/widgets/common/action_mixins/permission_aware.dart @@ -47,11 +47,12 @@ mixin PermissionAwareMixin { final confirmed = await showDialog( context: context, builder: (context) { - final directory = dir.relativeDir.isEmpty ? context.l10n.rootDirectoryDescription : context.l10n.otherDirectoryDescription(dir.relativeDir); + final l10n = context.l10n; + final directory = dir.relativeDir.isEmpty ? l10n.rootDirectoryDescription : l10n.otherDirectoryDescription(dir.relativeDir); final volume = dir.getVolumeDescription(context); return AvesDialog( - title: context.l10n.storageAccessDialogTitle, - content: Text(context.l10n.storageAccessDialogMessage(directory, volume)), + title: l10n.storageAccessDialogTitle, + content: Text(l10n.storageAccessDialogMessage(directory, volume)), actions: [ TextButton( onPressed: () => Navigator.pop(context), @@ -68,6 +69,26 @@ mixin PermissionAwareMixin { // abort if the user cancels in Flutter if (confirmed == null || !confirmed) return false; + if (!await deviceService.isSystemFilePickerEnabled()) { + await showDialog( + context: context, + builder: (context) { + final l10n = context.l10n; + return AvesDialog( + title: l10n.missingSystemFilePickerDialogTitle, + content: Text(l10n.missingSystemFilePickerDialogMessage), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(MaterialLocalizations.of(context).okButtonLabel), + ), + ], + ); + }, + ); + return false; + } + final granted = await storageService.requestDirectoryAccess(dir.volumePath); if (!granted) { // abort if the user denies access from the native dialog diff --git a/lib/widgets/debug/android_apps.dart b/lib/widgets/debug/android_apps.dart index 104721b4f..25a89ef64 100644 --- a/lib/widgets/debug/android_apps.dart +++ b/lib/widgets/debug/android_apps.dart @@ -51,7 +51,7 @@ class _DebugAndroidAppSectionState extends State with Au return ValueListenableBuilder( valueListenable: _queryNotifier, builder: (context, query, child) { - if ({package.packageName, ...package.potentialDirs}.none((v) => v.contains(query))) { + if ({package.packageName, ...package.potentialDirs}.none((v) => v.toLowerCase().contains(query.toLowerCase()))) { return const SizedBox(); } return Text.rich( diff --git a/untranslated.json b/untranslated.json index f1c5f26d0..a0db63d02 100644 --- a/untranslated.json +++ b/untranslated.json @@ -2,6 +2,8 @@ "de": [ "filterRatingUnratedLabel", "filterRatingRejectedLabel", + "missingSystemFilePickerDialogTitle", + "missingSystemFilePickerDialogMessage", "editEntryDateDialogSourceFieldLabel", "editEntryDateDialogSourceCustomDate", "editEntryDateDialogSourceTitle", @@ -12,9 +14,21 @@ "settingsThumbnailShowRating" ], + "fr": [ + "missingSystemFilePickerDialogTitle", + "missingSystemFilePickerDialogMessage" + ], + + "ko": [ + "missingSystemFilePickerDialogTitle", + "missingSystemFilePickerDialogMessage" + ], + "ru": [ "filterRatingUnratedLabel", "filterRatingRejectedLabel", + "missingSystemFilePickerDialogTitle", + "missingSystemFilePickerDialogMessage", "editEntryDateDialogSourceFieldLabel", "editEntryDateDialogSourceCustomDate", "editEntryDateDialogSourceTitle", From b5e4fecf2fd6b5a8221d457f0b4d5ba694433c22 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Thu, 30 Dec 2021 15:11:39 +0900 Subject: [PATCH 09/28] fixed sliver app bar title opacity --- lib/widgets/collection/app_bar.dart | 7 ++++-- lib/widgets/common/sliver_app_bar_title.dart | 23 ++++++++++++++++++++ lib/widgets/filter_grids/album_pick.dart | 9 +++++--- lib/widgets/filter_grids/common/app_bar.dart | 7 ++++-- lib/widgets/viewer/info/info_app_bar.dart | 9 +++++--- 5 files changed, 45 insertions(+), 10 deletions(-) create mode 100644 lib/widgets/common/sliver_app_bar_title.dart diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index 491819f81..1d2e33d68 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -20,6 +20,7 @@ import 'package:aves/widgets/common/app_bar_subtitle.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/common/sliver_app_bar_title.dart'; import 'package:aves/widgets/dialogs/tile_view_dialog.dart'; import 'package:aves/widgets/search/search_delegate.dart'; import 'package:flutter/material.dart'; @@ -116,7 +117,9 @@ class _CollectionAppBarState extends State with SingleTickerPr builder: (context, queryEnabled, child) { return SliverAppBar( leading: appMode.hasDrawer ? _buildAppBarLeading(isSelecting) : null, - title: _buildAppBarTitle(isSelecting), + title: SliverAppBarTitleWrapper( + child: _buildAppBarTitle(isSelecting), + ), actions: _buildActions( isSelecting: isSelecting, selectedItemCount: selectedItemCount, @@ -177,7 +180,7 @@ class _CollectionAppBarState extends State with SingleTickerPr ); } - Widget? _buildAppBarTitle(bool isSelecting) { + Widget _buildAppBarTitle(bool isSelecting) { final l10n = context.l10n; if (isSelecting) { diff --git a/lib/widgets/common/sliver_app_bar_title.dart b/lib/widgets/common/sliver_app_bar_title.dart new file mode 100644 index 000000000..0ed370ba2 --- /dev/null +++ b/lib/widgets/common/sliver_app_bar_title.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +// as of Flutter v2.8.1, fading opacity in `SliverAppBar` +// is not applied to title when `appBarTheme.titleTextStyle` is defined, +// so this wrapper manually applies opacity to the default text style +class SliverAppBarTitleWrapper extends StatelessWidget { + final Widget child; + + const SliverAppBarTitleWrapper({ + Key? key, + required this.child, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final toolbarOpacity = context.dependOnInheritedWidgetOfExactType()!.toolbarOpacity; + final baseColor = (DefaultTextStyle.of(context).style.color ?? Theme.of(context).primaryTextTheme.headline6!.color!); + return DefaultTextStyle.merge( + style: TextStyle(color: baseColor.withOpacity(toolbarOpacity)), + child: child, + ); + } +} diff --git a/lib/widgets/filter_grids/album_pick.dart b/lib/widgets/filter_grids/album_pick.dart index dfff41e2c..63755740b 100644 --- a/lib/widgets/filter_grids/album_pick.dart +++ b/lib/widgets/filter_grids/album_pick.dart @@ -16,6 +16,7 @@ import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/common/identity/empty.dart'; import 'package:aves/widgets/common/providers/selection_provider.dart'; +import 'package:aves/widgets/common/sliver_app_bar_title.dart'; import 'package:aves/widgets/dialogs/filter_editors/create_album_dialog.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/filter_grids/common/action_delegates/album_set.dart'; @@ -141,9 +142,11 @@ class _AlbumPickAppBar extends StatelessWidget { return SliverAppBar( leading: const BackButton(), - title: SourceStateAwareAppBarTitle( - title: Text(title()), - source: source, + title: SliverAppBarTitleWrapper( + child: SourceStateAwareAppBarTitle( + title: Text(title()), + source: source, + ), ), bottom: _AlbumQueryBar( queryNotifier: queryNotifier, diff --git a/lib/widgets/filter_grids/common/app_bar.dart b/lib/widgets/filter_grids/common/app_bar.dart index ec443c7df..d5e3c4659 100644 --- a/lib/widgets/filter_grids/common/app_bar.dart +++ b/lib/widgets/filter_grids/common/app_bar.dart @@ -8,6 +8,7 @@ import 'package:aves/widgets/common/app_bar_subtitle.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/common/sliver_app_bar_title.dart'; import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart'; import 'package:aves/widgets/search/search_delegate.dart'; import 'package:flutter/material.dart'; @@ -74,7 +75,9 @@ class _FilterGridAppBarState extends State extends State>, int>( selector: (context, selection) => selection.selectedItems.length, diff --git a/lib/widgets/viewer/info/info_app_bar.dart b/lib/widgets/viewer/info/info_app_bar.dart index 611a1b7f5..0b4185886 100644 --- a/lib/widgets/viewer/info/info_app_bar.dart +++ b/lib/widgets/viewer/info/info_app_bar.dart @@ -5,6 +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/common/sliver_app_bar_title.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'; @@ -37,9 +38,11 @@ class InfoAppBar extends StatelessWidget { onPressed: onBackPressed, tooltip: context.l10n.viewerInfoBackToViewerTooltip, ), - title: InteractiveAppBarTitle( - onTap: () => _goToSearch(context), - child: Text(context.l10n.viewerInfoPageTitle), + title: SliverAppBarTitleWrapper( + child: InteractiveAppBarTitle( + onTap: () => _goToSearch(context), + child: Text(context.l10n.viewerInfoPageTitle), + ), ), actions: [ IconButton( From 30d875f1cf1e9988434b2eecf67c94e6cf731dfe Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Thu, 30 Dec 2021 18:19:53 +0900 Subject: [PATCH 10/28] info: changed date edit dialog --- lib/l10n/app_de.arb | 2 +- lib/l10n/app_en.arb | 9 +- lib/l10n/app_fr.arb | 9 +- lib/l10n/app_ko.arb | 7 +- lib/l10n/app_ru.arb | 2 +- lib/model/entry.dart | 90 +++++------ lib/model/metadata/date_modifier.dart | 26 +++- lib/model/metadata/enums.dart | 22 ++- lib/utils/time_utils.dart | 4 +- .../entry_editors/edit_entry_date_dialog.dart | 147 ++++++++---------- lib/widgets/search/search_delegate.dart | 2 +- untranslated.json | 10 +- 12 files changed, 159 insertions(+), 171 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 1ab2eea74..20d207b42 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -178,7 +178,7 @@ "renameEntryDialogLabel": "Neuer Name", "editEntryDateDialogTitle": "Datum & Uhrzeit", - "editEntryDateDialogSet": "Festlegen", + "editEntryDateDialogExtractFromTitle": "Auszug aus dem Titel", "editEntryDateDialogShift": "Verschieben", "editEntryDateDialogClear": "Aufräumen", "editEntryDateDialogHours": "Stunden", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index f5354e8e1..aec69c717 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -275,13 +275,12 @@ "renameEntryDialogLabel": "New name", "editEntryDateDialogTitle": "Date & Time", - "editEntryDateDialogSet": "Set", + "editEntryDateDialogSetCustom": "Set custom date", + "editEntryDateDialogCopyField": "Set from other date", + "editEntryDateDialogExtractFromTitle": "Extract from title", "editEntryDateDialogShift": "Shift", "editEntryDateDialogClear": "Clear", - "editEntryDateDialogSourceFieldLabel": "Value:", - "editEntryDateDialogSourceCustomDate": "custom date", - "editEntryDateDialogSourceTitle": "extracted from title", - "editEntryDateDialogSourceFileModifiedDate": "file modified date", + "editEntryDateDialogSourceFileModifiedDate": "File modified date", "editEntryDateDialogTargetFieldsHeader": "Fields to modify", "editEntryDateDialogHours": "Hours", "editEntryDateDialogMinutes": "Minutes", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index a704e788c..2971c6bec 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -180,13 +180,12 @@ "renameEntryDialogLabel": "Nouveau nom", "editEntryDateDialogTitle": "Date & Heure", - "editEntryDateDialogSet": "Régler", + "editEntryDateDialogSetCustom": "Régler une date personnalisée", + "editEntryDateDialogCopyField": "Copier d'une autre date", + "editEntryDateDialogExtractFromTitle": "Extraire du titre", "editEntryDateDialogShift": "Décaler", "editEntryDateDialogClear": "Effacer", - "editEntryDateDialogSourceFieldLabel": "Valeur :", - "editEntryDateDialogSourceCustomDate": "date personnalisée", - "editEntryDateDialogSourceTitle": "extraite du titre", - "editEntryDateDialogSourceFileModifiedDate": "date de modification du fichier", + "editEntryDateDialogSourceFileModifiedDate": "Date de modification du fichier", "editEntryDateDialogTargetFieldsHeader": "Champs à modifier", "editEntryDateDialogHours": "Heures", "editEntryDateDialogMinutes": "Minutes", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 89c5a9017..c5cd70c88 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -180,12 +180,11 @@ "renameEntryDialogLabel": "이름", "editEntryDateDialogTitle": "날짜 및 시간", - "editEntryDateDialogSet": "편집", + "editEntryDateDialogSetCustom": "지정 날짜로 편집", + "editEntryDateDialogCopyField": "다른 날짜에서 지정", + "editEntryDateDialogExtractFromTitle": "제목에서 추출", "editEntryDateDialogShift": "시간 이동", "editEntryDateDialogClear": "삭제", - "editEntryDateDialogSourceFieldLabel": "값:", - "editEntryDateDialogSourceCustomDate": "지정 날짜", - "editEntryDateDialogSourceTitle": "제목에서 추출", "editEntryDateDialogSourceFileModifiedDate": "파일 수정한 날짜", "editEntryDateDialogTargetFieldsHeader": "수정할 필드", "editEntryDateDialogHours": "시간", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index f85ede8ec..e851d2fe2 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -178,7 +178,7 @@ "renameEntryDialogLabel": "Новое название", "editEntryDateDialogTitle": "Дата и время", - "editEntryDateDialogSet": "Задать", + "editEntryDateDialogExtractFromTitle": "Извлечь из названия", "editEntryDateDialogShift": "Сдвиг", "editEntryDateDialogClear": "Очистить", "editEntryDateDialogHours": "Часов", diff --git a/lib/model/entry.dart b/lib/model/entry.dart index 0153f4590..9d2e83afe 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -675,55 +675,42 @@ class AvesEntry { } Future> editDate(DateModifier modifier) async { - final action = modifier.action; - if (action == DateEditAction.set) { - final source = modifier.setSource; - if (source == null) { - await reportService.recordError('edit date with action=$action but source is null', null); - return {}; - } - - switch (source) { - case DateSetSource.title: - final _title = bestTitle; - if (_title == null) return {}; - final date = parseUnknownDateFormat(_title); - if (date == null) { - await reportService.recordError('failed to parse date from title=$_title', null); - return {}; + switch (modifier.action) { + case DateEditAction.copyField: + DateTime? date; + final source = modifier.copyFieldSource; + if (source != null) { + switch (source) { + case DateFieldSource.fileModifiedDate: + try { + date = path != null ? await File(path!).lastModified() : null; + } on FileSystemException catch (_) {} + break; + default: + date = await metadataFetchService.getDate(this, source.toMetadataField()!); + break; } - modifier = DateModifier(DateEditAction.set, modifier.fields, setDateTime: date); - break; - case DateSetSource.fileModifiedDate: - final _path = path; - if (_path == null) { - await reportService.recordError('edit date with action=$action, source=$source but entry has no path, uri=$uri', null); - return {}; - } - try { - final fileModifiedDate = await File(_path).lastModified(); - modifier = DateModifier(DateEditAction.set, modifier.fields, setDateTime: fileModifiedDate); - } on FileSystemException catch (error, stack) { - await reportService.recordError(error, stack); - return {}; - } - break; - case DateSetSource.custom: - break; - default: - final field = source.toMetadataField(); - if (field == null) { - await reportService.recordError('failed to get field for action=$action, source=$source, uri=$uri', null); - return {}; - } - final fieldDate = await metadataFetchService.getDate(this, field); - if (fieldDate == null) { - await reportService.recordError('failed to get date for field=$field, source=$source, uri=$uri', null); - return {}; - } - modifier = DateModifier(DateEditAction.set, modifier.fields, setDateTime: fieldDate); - break; - } + } + if (date != null) { + modifier = DateModifier.setCustom(modifier.fields, date); + } else { + await reportService.recordError('failed to get date for modifier=$modifier, uri=$uri', null); + return {}; + } + break; + case DateEditAction.extractFromTitle: + final date = parseUnknownDateFormat(bestTitle); + if (date != null) { + modifier = DateModifier.setCustom(modifier.fields, date); + } else { + await reportService.recordError('failed to get date for modifier=$modifier, uri=$uri', null); + return {}; + } + break; + case DateEditAction.setCustom: + case DateEditAction.shift: + case DateEditAction.clear: + break; } final newFields = await metadataEditService.editDate(this, modifier); return newFields.isEmpty @@ -746,10 +733,9 @@ class AvesEntry { final metadataDate = catalogMetadata?.dateMillis; if (metadataDate != null && metadataDate > 0) return {}; - return await editDate(const DateModifier( - DateEditAction.set, - {MetadataField.exifDateOriginal}, - setSource: DateSetSource.fileModifiedDate, + return await editDate(DateModifier.copyField( + const {MetadataField.exifDateOriginal}, + DateFieldSource.fileModifiedDate, )); } diff --git a/lib/model/metadata/date_modifier.dart b/lib/model/metadata/date_modifier.dart index 2924306eb..176d403e7 100644 --- a/lib/model/metadata/date_modifier.dart +++ b/lib/model/metadata/date_modifier.dart @@ -13,15 +13,35 @@ class DateModifier { final DateEditAction action; final Set fields; - final DateSetSource? setSource; final DateTime? setDateTime; + final DateFieldSource? copyFieldSource; final int? shiftMinutes; - const DateModifier( + const DateModifier._private( this.action, this.fields, { - this.setSource, this.setDateTime, + this.copyFieldSource, this.shiftMinutes, }); + + factory DateModifier.setCustom(Set fields, DateTime dateTime) { + return DateModifier._private(DateEditAction.setCustom, fields, setDateTime: dateTime); + } + + factory DateModifier.copyField(Set fields, DateFieldSource copyFieldSource) { + return DateModifier._private(DateEditAction.copyField, fields, copyFieldSource: copyFieldSource); + } + + factory DateModifier.extractFromTitle(Set fields) { + return DateModifier._private(DateEditAction.extractFromTitle, fields); + } + + factory DateModifier.shift(Set fields, int shiftMinutes) { + return DateModifier._private(DateEditAction.shift, fields, shiftMinutes: shiftMinutes); + } + + factory DateModifier.clear(Set fields) { + return DateModifier._private(DateEditAction.clear, fields); + } } diff --git a/lib/model/metadata/enums.dart b/lib/model/metadata/enums.dart index dc148cd49..d5592e9dc 100644 --- a/lib/model/metadata/enums.dart +++ b/lib/model/metadata/enums.dart @@ -6,14 +6,14 @@ enum MetadataField { } enum DateEditAction { - set, + setCustom, + copyField, + extractFromTitle, shift, clear, } -enum DateSetSource { - custom, - title, +enum DateFieldSource { fileModifiedDate, exifDate, exifDateOriginal, @@ -105,20 +105,18 @@ extension ExtraMetadataField on MetadataField { } } -extension ExtraDateSetSource on DateSetSource { +extension ExtraDateFieldSource on DateFieldSource { MetadataField? toMetadataField() { switch (this) { - case DateSetSource.custom: - case DateSetSource.title: - case DateSetSource.fileModifiedDate: + case DateFieldSource.fileModifiedDate: return null; - case DateSetSource.exifDate: + case DateFieldSource.exifDate: return MetadataField.exifDate; - case DateSetSource.exifDateOriginal: + case DateFieldSource.exifDateOriginal: return MetadataField.exifDateOriginal; - case DateSetSource.exifDateDigitized: + case DateFieldSource.exifDateDigitized: return MetadataField.exifDateDigitized; - case DateSetSource.exifGpsDate: + case DateFieldSource.exifGpsDate: return MetadataField.exifGpsDate; } } diff --git a/lib/utils/time_utils.dart b/lib/utils/time_utils.dart index cf36f57c6..6980430ba 100644 --- a/lib/utils/time_utils.dart +++ b/lib/utils/time_utils.dart @@ -18,7 +18,9 @@ final _unixStampMillisPattern = RegExp(r'\d{13}'); final _unixStampSecPattern = RegExp(r'\d{10}'); final _plainPattern = RegExp(r'(\d{8})([_-\s](\d{6})([_-\s](\d{3}))?)?'); -DateTime? parseUnknownDateFormat(String s) { +DateTime? parseUnknownDateFormat(String? s) { + if (s == null) return null; + var match = _unixStampMillisPattern.firstMatch(s); if (match != null) { final stampString = match.group(0); diff --git a/lib/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart b/lib/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart index 9341213d8..9fb584ac3 100644 --- a/lib/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart @@ -24,8 +24,8 @@ class EditEntryDateDialog extends StatefulWidget { } class _EditEntryDateDialogState extends State { - DateEditAction _action = DateEditAction.set; - DateSetSource _setSource = DateSetSource.custom; + DateEditAction _action = DateEditAction.setCustom; + DateFieldSource _copyFieldSource = DateFieldSource.fileModifiedDate; late DateTime _setDateTime; late ValueNotifier _shiftHour, _shiftMinute; late ValueNotifier _shiftSign; @@ -96,7 +96,8 @@ class _EditEntryDateDialogState extends State { key: ValueKey(_action), mainAxisSize: MainAxisSize.min, children: [ - if (_action == DateEditAction.set) ..._buildSetContent(context), + if (_action == DateEditAction.setCustom) _buildSetCustomContent(context), + if (_action == DateEditAction.copyField) _buildCopyFieldContent(context), if (_action == DateEditAction.shift) _buildShiftContent(context), ], ), @@ -128,67 +129,52 @@ class _EditEntryDateDialogState extends State { ), ); - List _buildSetContent(BuildContext context) { + Widget _buildSetCustomContent(BuildContext context) { final l10n = context.l10n; final locale = l10n.localeName; final use24hour = context.select((v) => v.alwaysUse24HourFormat); - return [ - Padding( - padding: const EdgeInsets.only(left: 16, right: 16), - child: Row( - children: [ - Text(l10n.editEntryDateDialogSourceFieldLabel), - const SizedBox(width: 8), - Expanded( - child: DropdownButton( - items: DateSetSource.values - .map((v) => DropdownMenuItem( - value: v, - child: Text(_setSourceText(context, v)), - )) - .toList(), - selectedItemBuilder: (context) => DateSetSource.values - .map((v) => DropdownMenuItem( - value: v, - child: Text( - _setSourceText(context, v), - softWrap: false, - overflow: TextOverflow.fade, - ), - )) - .toList(), - value: _setSource, - onChanged: (v) => setState(() => _setSource = v!), - isExpanded: true, - dropdownColor: dropdownColor, - ), - ), - ], - ), + return Padding( + padding: const EdgeInsets.only(left: 16, top: 4, right: 12), + child: Row( + children: [ + Expanded(child: Text(formatDateTime(_setDateTime, locale, use24hour))), + IconButton( + icon: const Icon(AIcons.edit), + onPressed: _editDate, + tooltip: l10n.changeTooltip, + ), + ], ), - AnimatedSwitcher( - duration: context.read().formTransition, - switchInCurve: Curves.easeInOutCubic, - switchOutCurve: Curves.easeInOutCubic, - transitionBuilder: _formTransitionBuilder, - child: _setSource == DateSetSource.custom - ? Padding( - padding: const EdgeInsets.only(left: 16, right: 12), - child: Row( - children: [ - Expanded(child: Text(formatDateTime(_setDateTime, locale, use24hour))), - IconButton( - icon: const Icon(AIcons.edit), - onPressed: _editDate, - tooltip: l10n.changeTooltip, - ), - ], - ), - ) - : const SizedBox(), + ); + } + + Widget _buildCopyFieldContent(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: DropdownButton( + items: DateFieldSource.values + .map((v) => DropdownMenuItem( + value: v, + child: Text(_setSourceText(context, v)), + )) + .toList(), + selectedItemBuilder: (context) => DateFieldSource.values + .map((v) => DropdownMenuItem( + value: v, + child: Text( + _setSourceText(context, v), + softWrap: false, + overflow: TextOverflow.fade, + ), + )) + .toList(), + value: _copyFieldSource, + onChanged: (v) => setState(() => _copyFieldSource = v!), + isExpanded: true, + dropdownColor: dropdownColor, ), - ]; + ); } Widget _buildShiftContent(BuildContext context) { @@ -283,8 +269,12 @@ class _EditEntryDateDialogState extends State { String _actionText(BuildContext context, DateEditAction action) { final l10n = context.l10n; switch (action) { - case DateEditAction.set: - return l10n.editEntryDateDialogSet; + case DateEditAction.setCustom: + return l10n.editEntryDateDialogSetCustom; + case DateEditAction.copyField: + return l10n.editEntryDateDialogCopyField; + case DateEditAction.extractFromTitle: + return l10n.editEntryDateDialogExtractFromTitle; case DateEditAction.shift: return l10n.editEntryDateDialogShift; case DateEditAction.clear: @@ -292,22 +282,18 @@ class _EditEntryDateDialogState extends State { } } - String _setSourceText(BuildContext context, DateSetSource source) { + String _setSourceText(BuildContext context, DateFieldSource source) { final l10n = context.l10n; switch (source) { - case DateSetSource.custom: - return l10n.editEntryDateDialogSourceCustomDate; - case DateSetSource.title: - return l10n.editEntryDateDialogSourceTitle; - case DateSetSource.fileModifiedDate: + case DateFieldSource.fileModifiedDate: return l10n.editEntryDateDialogSourceFileModifiedDate; - case DateSetSource.exifDate: + case DateFieldSource.exifDate: return 'Exif date'; - case DateSetSource.exifDateOriginal: + case DateFieldSource.exifDateOriginal: return 'Exif original date'; - case DateSetSource.exifDateDigitized: + case DateFieldSource.exifDateDigitized: return 'Exif digitized date'; - case DateSetSource.exifGpsDate: + case DateFieldSource.exifGpsDate: return 'Exif GPS date'; } } @@ -350,20 +336,21 @@ class _EditEntryDateDialogState extends State { )); } - void _submit(BuildContext context) { - late DateModifier modifier; + DateModifier _getModifier() { switch (_action) { - case DateEditAction.set: - modifier = DateModifier(_action, _fields, setSource: _setSource, setDateTime: _setDateTime); - break; + case DateEditAction.setCustom: + return DateModifier.setCustom(_fields, _setDateTime); + case DateEditAction.copyField: + return DateModifier.copyField(_fields, _copyFieldSource); + case DateEditAction.extractFromTitle: + return DateModifier.extractFromTitle(_fields); case DateEditAction.shift: final shiftTotalMinutes = (_shiftHour.value * 60 + _shiftMinute.value) * (_shiftSign.value == '+' ? 1 : -1); - modifier = DateModifier(_action, _fields, shiftMinutes: shiftTotalMinutes); - break; + return DateModifier.shift(_fields, shiftTotalMinutes); case DateEditAction.clear: - modifier = DateModifier(_action, _fields); - break; + return DateModifier.clear(_fields); } - Navigator.pop(context, modifier); } + + void _submit(BuildContext context) => Navigator.pop(context, _getModifier()); } diff --git a/lib/widgets/search/search_delegate.dart b/lib/widgets/search/search_delegate.dart index 07247ac09..5d224993f 100644 --- a/lib/widgets/search/search_delegate.dart +++ b/lib/widgets/search/search_delegate.dart @@ -183,7 +183,7 @@ class CollectionSearchDelegate { _buildFilterRow( context: context, title: context.l10n.searchSectionRating, - filters: [0, 5, 4, 3, 2, 1, -1].map((rating) => RatingFilter(rating)).toList(), + filters: [0, 5, 4, 3, 2, 1, -1].map((rating) => RatingFilter(rating)).where((f) => containQuery(f.getLabel(context))).toList(), ), ], ); diff --git a/untranslated.json b/untranslated.json index a0db63d02..bc3e1d959 100644 --- a/untranslated.json +++ b/untranslated.json @@ -4,9 +4,8 @@ "filterRatingRejectedLabel", "missingSystemFilePickerDialogTitle", "missingSystemFilePickerDialogMessage", - "editEntryDateDialogSourceFieldLabel", - "editEntryDateDialogSourceCustomDate", - "editEntryDateDialogSourceTitle", + "editEntryDateDialogSetCustom", + "editEntryDateDialogCopyField", "editEntryDateDialogSourceFileModifiedDate", "editEntryDateDialogTargetFieldsHeader", "collectionSortRating", @@ -29,9 +28,8 @@ "filterRatingRejectedLabel", "missingSystemFilePickerDialogTitle", "missingSystemFilePickerDialogMessage", - "editEntryDateDialogSourceFieldLabel", - "editEntryDateDialogSourceCustomDate", - "editEntryDateDialogSourceTitle", + "editEntryDateDialogSetCustom", + "editEntryDateDialogCopyField", "editEntryDateDialogSourceFileModifiedDate", "editEntryDateDialogTargetFieldsHeader", "collectionSortRating", From acdb7afa6d424924d7088dffce7f4b471cdd64ac Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Fri, 31 Dec 2021 11:41:14 +0900 Subject: [PATCH 11/28] info: fixed PNG exif/iptc raw profile extraction --- .../channel/calls/MetadataFetchHandler.kt | 2 +- .../aves/metadata/MetadataExtractorHelper.kt | 63 ++++++++++++------- 2 files changed, 42 insertions(+), 23 deletions(-) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt index aced1a04a..53a4f3d7c 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt @@ -207,7 +207,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { val dirs = extractPngProfile(key, valueString) if (dirs?.any() == true) { dirs.forEach { profileDir -> - val profileDirName = profileDir.name + val profileDirName = "${dir.name}/${profileDir.name}" val profileDirMap = metadataMap[profileDirName] ?: HashMap() metadataMap[profileDirName] = profileDirMap profileDirMap.putAll(profileDir.tags.map { Pair(it.tagName, it.description) }) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt index f0d73d1e3..1b518da6d 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt @@ -1,25 +1,34 @@ package deckers.thibault.aves.metadata +import android.util.Log +import com.drew.lang.ByteArrayReader import com.drew.lang.Rational import com.drew.lang.SequentialByteArrayReader import com.drew.metadata.Directory import com.drew.metadata.exif.ExifIFD0Directory +import com.drew.metadata.exif.ExifReader import com.drew.metadata.iptc.IptcReader import com.drew.metadata.png.PngDirectory +import deckers.thibault.aves.utils.LogUtils import java.text.SimpleDateFormat import java.util.* object MetadataExtractorHelper { + private val LOG_TAG = LogUtils.createTag() + const val PNG_ITXT_DIR_NAME = "PNG-iTXt" private const val PNG_TEXT_DIR_NAME = "PNG-tEXt" const val PNG_TIME_DIR_NAME = "PNG-tIME" private const val PNG_ZTXT_DIR_NAME = "PNG-zTXt" + private const val PNG_RAW_PROFILE_EXIF = "Raw profile type exif" + private const val PNG_RAW_PROFILE_IPTC = "Raw profile type iptc" val PNG_LAST_MODIFICATION_TIME_FORMAT = SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.ROOT) // Pattern to extract profile name, length, and text data // of raw profiles (EXIF, IPTC, etc.) in PNG `zTXt` chunks // e.g. "iptc [...] 114 [...] 3842494d040400[...]" + // e.g. "exif [...] 134 [...] 4578696600004949[...]" private val PNG_RAW_PROFILE_PATTERN = Regex("^\\n(.*?)\\n\\s*(\\d+)\\n(.*)", RegexOption.DOT_MATCHES_ALL) // extensions @@ -77,22 +86,29 @@ object MetadataExtractorHelper { fun Directory.isPngTextDir(): Boolean = this is PngDirectory && setOf(PNG_ITXT_DIR_NAME, PNG_TEXT_DIR_NAME, PNG_ZTXT_DIR_NAME).contains(this.name) fun extractPngProfile(key: String, valueString: String): Iterable? { - when (key) { - "Raw profile type iptc" -> { - val match = PNG_RAW_PROFILE_PATTERN.matchEntire(valueString) - if (match != null) { - val dataString = match.groupValues[3] - val hexString = dataString.replace(Regex("[\\r\\n]"), "") - val dataBytes = hexStringToByteArray(hexString) - if (dataBytes != null) { - val start = dataBytes.indexOf(Metadata.IPTC_MARKER_BYTE) - if (start != -1) { - val segmentBytes = dataBytes.copyOfRange(fromIndex = start, toIndex = dataBytes.size) - val metadata = com.drew.metadata.Metadata() - IptcReader().extract(SequentialByteArrayReader(segmentBytes), metadata, segmentBytes.size.toLong()) - return metadata.directories + if (key == PNG_RAW_PROFILE_EXIF || key == PNG_RAW_PROFILE_IPTC) { + val match = PNG_RAW_PROFILE_PATTERN.matchEntire(valueString) + if (match != null) { + val dataString = match.groupValues[3] + val hexString = dataString.replace(Regex("[\\r\\n]"), "") + val dataBytes = hexString.decodeHex() + if (dataBytes != null) { + val metadata = com.drew.metadata.Metadata() + when (key) { + PNG_RAW_PROFILE_EXIF -> { + if (ExifReader.startsWithJpegExifPreamble(dataBytes)) { + ExifReader().extract(ByteArrayReader(dataBytes), metadata, ExifReader.JPEG_SEGMENT_PREAMBLE.length) + } + } + PNG_RAW_PROFILE_IPTC -> { + val start = dataBytes.indexOf(Metadata.IPTC_MARKER_BYTE) + if (start != -1) { + val segmentBytes = dataBytes.copyOfRange(fromIndex = start, toIndex = dataBytes.size) + IptcReader().extract(SequentialByteArrayReader(segmentBytes), metadata, segmentBytes.size.toLong()) + } } } + return metadata.directories } } } @@ -101,15 +117,18 @@ object MetadataExtractorHelper { // convenience methods - private fun hexStringToByteArray(hexString: String): ByteArray? { - if (hexString.length % 2 != 0) return null + private fun String.decodeHex(): ByteArray? { + if (length % 2 != 0) return null - val dataBytes = ByteArray(hexString.length / 2) - var i = 0 - while (i < hexString.length) { - dataBytes[i / 2] = hexString.substring(i, i + 2).toByte(16) - i += 2 + try { + val byteIterator = chunkedSequence(2) + .map { it.toInt(16).toByte() } + .iterator() + + return ByteArray(length / 2) { byteIterator.next() } + } catch (e: NumberFormatException) { + Log.w(LOG_TAG, "failed to decode hex string=$this", e) } - return dataBytes + return null } } \ No newline at end of file From e5fe3e980fd813e8b99562b84db7d25155a8918f Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Fri, 31 Dec 2021 12:35:23 +0900 Subject: [PATCH 12/28] info: improved exif tag display --- .../channel/calls/MetadataFetchHandler.kt | 42 +++++++++++-------- .../metadata/{TiffTags.kt => ExifTags.kt} | 10 ++++- .../aves/metadata/MetadataExtractorHelper.kt | 11 ++--- 3 files changed, 40 insertions(+), 23 deletions(-) rename android/app/src/main/kotlin/deckers/thibault/aves/metadata/{TiffTags.kt => ExifTags.kt} (94%) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt index 53a4f3d7c..d0fcdd67c 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt @@ -19,7 +19,10 @@ import com.drew.lang.KeyValuePair import com.drew.lang.Rational import com.drew.metadata.Tag import com.drew.metadata.avi.AviDirectory -import com.drew.metadata.exif.* +import com.drew.metadata.exif.ExifDirectoryBase +import com.drew.metadata.exif.ExifIFD0Directory +import com.drew.metadata.exif.ExifSubIFDDirectory +import com.drew.metadata.exif.GpsDirectory import com.drew.metadata.file.FileTypeDirectory import com.drew.metadata.gif.GifAnimationDirectory import com.drew.metadata.iptc.IptcDirectory @@ -160,25 +163,16 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { // tags val tags = dir.tags - if (mimeType == MimeTypes.TIFF && (dir is ExifIFD0Directory || dir is ExifThumbnailDirectory)) { - fun tagMapper(it: Tag): Pair { - val name = if (it.hasTagName()) { - it.tagName - } else { - TiffTags.getTagName(it.tagType) ?: it.tagName - } - return Pair(name, it.description) - } - - if (dir is ExifIFD0Directory && dir.isGeoTiff()) { + if (dir is ExifDirectoryBase) { + if (dir.isGeoTiff()) { // split GeoTIFF tags in their own directory - val byGeoTiff = tags.groupBy { TiffTags.isGeoTiffTag(it.tagType) } + val byGeoTiff = tags.groupBy { ExifTags.isGeoTiffTag(it.tagType) } metadataMap["GeoTIFF"] = HashMap().apply { - byGeoTiff[true]?.map { tagMapper(it) }?.let { putAll(it) } + byGeoTiff[true]?.map { exifTagMapper(it) }?.let { putAll(it) } } - byGeoTiff[false]?.map { tagMapper(it) }?.let { dirMap.putAll(it) } + byGeoTiff[false]?.map { exifTagMapper(it) }?.let { dirMap.putAll(it) } } else { - dirMap.putAll(tags.map { tagMapper(it) }) + dirMap.putAll(tags.map { exifTagMapper(it) }) } } else if (dir.isPngTextDir()) { metadataMap.remove(thisDirName) @@ -210,7 +204,12 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { val profileDirName = "${dir.name}/${profileDir.name}" val profileDirMap = metadataMap[profileDirName] ?: HashMap() metadataMap[profileDirName] = profileDirMap - profileDirMap.putAll(profileDir.tags.map { Pair(it.tagName, it.description) }) + val profileTags = profileDir.tags + if (profileDir is ExifDirectoryBase) { + profileDirMap.putAll(profileTags.map { exifTagMapper(it) }) + } else { + profileDirMap.putAll(profileTags.map { Pair(it.tagName, it.description) }) + } } null } else { @@ -974,6 +973,15 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { omitXmpMetaElement = false // e.g. ... } + private fun exifTagMapper(it: Tag): Pair { + val name = if (it.hasTagName()) { + it.tagName + } else { + ExifTags.getTagName(it.tagType) ?: it.tagName + } + return Pair(name, it.description) + } + // catalog metadata private const val KEY_MIME_TYPE = "mimeType" private const val KEY_DATE_MILLIS = "dateMillis" diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/TiffTags.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifTags.kt similarity index 94% rename from android/app/src/main/kotlin/deckers/thibault/aves/metadata/TiffTags.kt rename to android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifTags.kt index 2f8a9c23c..4dbe77450 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/TiffTags.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifTags.kt @@ -1,6 +1,7 @@ package deckers.thibault.aves.metadata -object TiffTags { +// Exif tags missing from `metadata-extractor` +object ExifTags { // XPosition // Tag = 286 (011E.H) private const val TAG_X_POSITION = 0x011e @@ -32,6 +33,12 @@ object TiffTags { // SAMPLEFORMAT_COMPLEXIEEEFP 6 // complex ieee floating private const val TAG_SAMPLE_FORMAT = 0x0153 + + // Rating tag used by Windows, value in percent + // Tag = 18249 (4749.H) + // Type = SHORT + private const val TAG_RATING_PERCENT = 0x4749 + /* SGI tags 32995-32999 @@ -125,6 +132,7 @@ object TiffTags { TAG_COLOR_MAP to "Color Map", TAG_EXTRA_SAMPLES to "Extra Samples", TAG_SAMPLE_FORMAT to "Sample Format", + TAG_RATING_PERCENT to "Rating Percent", // SGI TAG_MATTEING to "Matteing", // GeoTIFF diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt index 1b518da6d..84d5959f2 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt @@ -5,6 +5,7 @@ import com.drew.lang.ByteArrayReader import com.drew.lang.Rational import com.drew.lang.SequentialByteArrayReader import com.drew.metadata.Directory +import com.drew.metadata.exif.ExifDirectoryBase import com.drew.metadata.exif.ExifIFD0Directory import com.drew.metadata.exif.ExifReader import com.drew.metadata.iptc.IptcReader @@ -68,14 +69,14 @@ object MetadataExtractorHelper { - If the ModelTransformationTag is included in an IFD, then a ModelPixelScaleTag SHALL NOT be included - If the ModelPixelScaleTag is included in an IFD, then a ModelTiepointTag SHALL also be included. */ - fun ExifIFD0Directory.isGeoTiff(): Boolean { - if (!this.containsTag(TiffTags.TAG_GEO_KEY_DIRECTORY)) return false + fun ExifDirectoryBase.isGeoTiff(): Boolean { + if (!this.containsTag(ExifTags.TAG_GEO_KEY_DIRECTORY)) return false - val modelTiepoint = this.containsTag(TiffTags.TAG_MODEL_TIEPOINT) - val modelTransformation = this.containsTag(TiffTags.TAG_MODEL_TRANSFORMATION) + val modelTiepoint = this.containsTag(ExifTags.TAG_MODEL_TIEPOINT) + val modelTransformation = this.containsTag(ExifTags.TAG_MODEL_TRANSFORMATION) if (!modelTiepoint && !modelTransformation) return false - val modelPixelScale = this.containsTag(TiffTags.TAG_MODEL_PIXEL_SCALE) + val modelPixelScale = this.containsTag(ExifTags.TAG_MODEL_PIXEL_SCALE) if ((modelTransformation && modelPixelScale) || (modelPixelScale && !modelTiepoint)) return false return true From f3581562d46e77e0d185bb4de248833acc8882fc Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Mon, 3 Jan 2022 17:34:04 +0900 Subject: [PATCH 13/28] #143 rating: edition --- .../aves/channel/calls/MetadataEditHandler.kt | 49 +-- .../channel/calls/MetadataFetchHandler.kt | 51 +-- .../deckers/thibault/aves/metadata/XMP.kt | 2 - .../aves/model/provider/ImageProvider.kt | 76 ++-- lib/l10n/app_de.arb | 3 +- lib/l10n/app_en.arb | 6 +- lib/l10n/app_fr.arb | 3 +- lib/l10n/app_ko.arb | 3 +- lib/l10n/app_ru.arb | 3 +- lib/model/actions/entry_info_actions.dart | 6 + lib/model/actions/entry_set_actions.dart | 5 + lib/model/entry.dart | 16 +- lib/model/entry_metadata_edition.dart | 122 ++++++ lib/model/entry_xmp_iptc.dart | 242 ----------- lib/model/metadata/date_modifier.dart | 4 +- lib/model/metadata/enums.dart | 4 +- lib/ref/xmp.dart | 52 --- .../metadata/metadata_edit_service.dart | 29 +- .../metadata/metadata_fetch_service.dart | 2 +- lib/services/metadata/xmp.dart | 40 ++ lib/theme/icons.dart | 3 +- lib/utils/xmp_utils.dart | 273 ++++++++++++ lib/widgets/collection/app_bar.dart | 2 + .../collection/entry_set_action_delegate.dart | 20 +- .../common/action_mixins/entry_editor.dart | 13 + lib/widgets/common/thumbnail/overlay.dart | 2 +- .../entry_editors/edit_entry_date_dialog.dart | 8 +- .../edit_entry_rating_dialog.dart | 136 ++++++ .../navigation/drawer_tab_albums.dart | 2 +- .../settings/privacy/hidden_items.dart | 2 +- .../action/entry_info_action_delegate.dart | 15 +- .../viewer/info/metadata/xmp_namespaces.dart | 52 ++- .../viewer/info/metadata/xmp_tile.dart | 2 +- test/utils/xmp_utils_test.dart | 390 ++++++++++++++++++ untranslated.json | 12 +- 35 files changed, 1179 insertions(+), 471 deletions(-) create mode 100644 lib/model/entry_metadata_edition.dart delete mode 100644 lib/model/entry_xmp_iptc.dart delete mode 100644 lib/ref/xmp.dart create mode 100644 lib/services/metadata/xmp.dart create mode 100644 lib/utils/xmp_utils.dart create mode 100644 lib/widgets/dialogs/entry_editors/edit_entry_rating_dialog.dart create mode 100644 test/utils/xmp_utils_test.dart diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataEditHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataEditHandler.kt index 9040083d5..adc3449f5 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataEditHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataEditHandler.kt @@ -20,8 +20,7 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler { "rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) } "flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) } "editDate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::editDate) } - "setIptc" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::setIptc) } - "setXmp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::setXmp) } + "editMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::editMetadata) } "removeTypes" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::removeTypes) } else -> result.notImplemented() } @@ -99,12 +98,11 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler { }) } - private fun setIptc(call: MethodCall, result: MethodChannel.Result) { - val iptc = call.argument>("iptc") + private fun editMetadata(call: MethodCall, result: MethodChannel.Result) { + val metadata = call.argument("metadata") val entryMap = call.argument("entry") - val postEditScan = call.argument("postEditScan") - if (entryMap == null || postEditScan == null) { - result.error("setIptc-args", "failed because of missing arguments", null) + if (entryMap == null || metadata == null) { + result.error("editMetadata-args", "failed because of missing arguments", null) return } @@ -112,48 +110,19 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler { val path = entryMap["path"] as String? val mimeType = entryMap["mimeType"] as String? if (uri == null || path == null || mimeType == null) { - result.error("setIptc-args", "failed because entry fields are missing", null) + result.error("editMetadata-args", "failed because entry fields are missing", null) return } val provider = getProvider(uri) if (provider == null) { - result.error("setIptc-provider", "failed to find provider for uri=$uri", null) + result.error("editMetadata-provider", "failed to find provider for uri=$uri", null) return } - provider.setIptc(activity, path, uri, mimeType, postEditScan, iptc = iptc, callback = object : ImageOpCallback { + provider.editMetadata(activity, path, uri, mimeType, metadata, callback = object : ImageOpCallback { override fun onSuccess(fields: FieldMap) = result.success(fields) - override fun onFailure(throwable: Throwable) = result.error("setIptc-failure", "failed to set IPTC for mimeType=$mimeType uri=$uri", throwable.message) - }) - } - - private fun setXmp(call: MethodCall, result: MethodChannel.Result) { - val xmp = call.argument("xmp") - val extendedXmp = call.argument("extendedXmp") - val entryMap = call.argument("entry") - if (entryMap == null) { - result.error("setXmp-args", "failed because of missing arguments", null) - return - } - - val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) } - val path = entryMap["path"] as String? - val mimeType = entryMap["mimeType"] as String? - if (uri == null || path == null || mimeType == null) { - result.error("setXmp-args", "failed because entry fields are missing", null) - return - } - - val provider = getProvider(uri) - if (provider == null) { - result.error("setXmp-provider", "failed to find provider for uri=$uri", null) - return - } - - provider.setXmp(activity, path, uri, mimeType, coreXmp = xmp, extendedXmp = extendedXmp, callback = object : ImageOpCallback { - override fun onSuccess(fields: FieldMap) = result.success(fields) - override fun onFailure(throwable: Throwable) = result.error("setXmp-failure", "failed to set XMP for mimeType=$mimeType uri=$uri", throwable.message) + override fun onFailure(throwable: Throwable) = result.error("editMetadata-failure", "failed to edit metadata for mimeType=$mimeType uri=$uri", throwable.message) }) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt index d0fcdd67c..b461e2b9f 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt @@ -118,8 +118,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { try { Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> val metadata = ImageMetadataReader.readMetadata(input) - foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java) - foundXmp = metadata.containsDirectoryOfType(XmpDirectory::class.java) + foundExif = metadata.directories.any { it is ExifDirectoryBase && it.tagCount > 0 } + foundXmp = metadata.directories.any { it is XmpDirectory && it.tagCount > 0 } val uuidDirCount = HashMap() val dirByName = metadata.directories.filter { it.tagCount > 0 @@ -358,26 +358,22 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { return dirMap } - // legend: ME=MetadataExtractor, EI=ExifInterface, MMR=MediaMetadataRetriever // set `KEY_DATE_MILLIS` from these fields (by precedence): - // - ME / Exif / DATETIME_ORIGINAL - // - ME / Exif / DATETIME - // - EI / Exif / DATETIME_ORIGINAL - // - EI / Exif / DATETIME - // - ME / XMP / xmp:CreateDate - // - ME / XMP / photoshop:DateCreated - // - ME / PNG / TIME / LAST_MODIFICATION_TIME - // - MMR / METADATA_KEY_DATE + // - Exif / DATETIME_ORIGINAL + // - Exif / DATETIME + // - XMP / xmp:CreateDate + // - XMP / photoshop:DateCreated + // - PNG / TIME / LAST_MODIFICATION_TIME + // - Video / METADATA_KEY_DATE // set `KEY_XMP_TITLE_DESCRIPTION` from these fields (by precedence): - // - ME / XMP / dc:title - // - ME / XMP / dc:description + // - XMP / dc:title + // - XMP / dc:description // set `KEY_XMP_SUBJECTS` from these fields (by precedence): - // - ME / XMP / dc:subject - // - ME / IPTC / keywords + // - XMP / dc:subject + // - IPTC / keywords // set `KEY_RATING` from these fields (by precedence): - // - ME / XMP / xmp:Rating - // - ME / XMP / MicrosoftPhoto:Rating - // - ME / XMP / acdsee:rating + // - XMP / xmp:Rating + // - XMP / MicrosoftPhoto:Rating private fun getCatalogMetadata(call: MethodCall, result: MethodChannel.Result) { val mimeType = call.argument("mimeType") val uri = call.argument("uri")?.let { Uri.parse(it) } @@ -412,7 +408,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { try { Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> val metadata = ImageMetadataReader.readMetadata(input) - foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java) + foundExif = metadata.directories.any { it is ExifDirectoryBase && it.tagCount > 0 } // File type for (dir in metadata.getDirectoriesOfType(FileTypeDirectory::class.java)) { @@ -437,13 +433,13 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { // EXIF for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) { - dir.getSafeDateMillis(ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL) { metadataMap[KEY_DATE_MILLIS] = it } + dir.getSafeDateMillis(ExifDirectoryBase.TAG_DATETIME_ORIGINAL) { metadataMap[KEY_DATE_MILLIS] = it } } for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) { if (!metadataMap.containsKey(KEY_DATE_MILLIS)) { - dir.getSafeDateMillis(ExifIFD0Directory.TAG_DATETIME) { metadataMap[KEY_DATE_MILLIS] = it } + dir.getSafeDateMillis(ExifDirectoryBase.TAG_DATETIME) { metadataMap[KEY_DATE_MILLIS] = it } } - dir.getSafeInt(ExifIFD0Directory.TAG_ORIENTATION) { + dir.getSafeInt(ExifDirectoryBase.TAG_ORIENTATION) { val orientation = it if (isFlippedForExifCode(orientation)) flags = flags or MASK_IS_FLIPPED metadataMap[KEY_ROTATION_DEGREES] = getRotationDegreesForExifCode(orientation) @@ -486,9 +482,6 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { val standardRating = (percentRating / 25f).roundToInt() + 1 metadataMap[KEY_RATING] = standardRating } - if (!metadataMap.containsKey(KEY_RATING)) { - xmpMeta.getSafeInt(XMP.ACDSEE_SCHEMA_NS, XMP.ACDSEE_RATING_PROP_NAME) { metadataMap[KEY_RATING] = it } - } } // identification of panorama (aka photo sphere) @@ -676,10 +669,10 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { val metadata = ImageMetadataReader.readMetadata(input) for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) { foundExif = true - dir.getSafeRational(ExifSubIFDDirectory.TAG_FNUMBER) { metadataMap[KEY_APERTURE] = it.numerator.toDouble() / it.denominator } - dir.getSafeRational(ExifSubIFDDirectory.TAG_EXPOSURE_TIME, saveExposureTime) - dir.getSafeRational(ExifSubIFDDirectory.TAG_FOCAL_LENGTH) { metadataMap[KEY_FOCAL_LENGTH] = it.numerator.toDouble() / it.denominator } - dir.getSafeInt(ExifSubIFDDirectory.TAG_ISO_EQUIVALENT) { metadataMap[KEY_ISO] = it } + dir.getSafeRational(ExifDirectoryBase.TAG_FNUMBER) { metadataMap[KEY_APERTURE] = it.numerator.toDouble() / it.denominator } + dir.getSafeRational(ExifDirectoryBase.TAG_EXPOSURE_TIME, saveExposureTime) + dir.getSafeRational(ExifDirectoryBase.TAG_FOCAL_LENGTH) { metadataMap[KEY_FOCAL_LENGTH] = it.numerator.toDouble() / it.denominator } + dir.getSafeInt(ExifDirectoryBase.TAG_ISO_EQUIVALENT) { metadataMap[KEY_ISO] = it } } } } catch (e: Exception) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt index 97f099fe4..2819915e8 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt @@ -14,7 +14,6 @@ object XMP { // standard namespaces // cf com.adobe.internal.xmp.XMPConst - const val ACDSEE_SCHEMA_NS = "http://ns.acdsee.com/iptc/1.0/" const val DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/" const val MICROSOFTPHOTO_SCHEMA_NS = "http://ns.microsoft.com/photo/1.0/" const val PHOTOSHOP_SCHEMA_NS = "http://ns.adobe.com/photoshop/1.0/" @@ -29,7 +28,6 @@ object XMP { const val CONTAINER_SCHEMA_NS = "http://ns.google.com/photos/1.0/container/" private const val CONTAINER_ITEM_SCHEMA_NS = "http://ns.google.com/photos/1.0/container/item/" - const val ACDSEE_RATING_PROP_NAME = "acdsee:rating" const val DC_DESCRIPTION_PROP_NAME = "dc:description" const val DC_SUBJECT_PROP_NAME = "dc:subject" const val DC_TITLE_PROP_NAME = "dc:title" diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt index 1e04ca4ac..9ef5a9112 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt @@ -800,63 +800,47 @@ abstract class ImageProvider { } } - fun setIptc( + fun editMetadata( context: Context, path: String, uri: Uri, mimeType: String, - postEditScan: Boolean, + modifier: FieldMap, callback: ImageOpCallback, - iptc: List? = null, ) { - val newFields = HashMap() + if (modifier.containsKey("iptc")) { + val iptc = (modifier["iptc"] as List<*>?)?.filterIsInstance() + if (!editIptc( + context = context, + path = path, + uri = uri, + mimeType = mimeType, + callback = callback, + iptc = iptc, + ) + ) return + } - val success = editIptc( - context = context, - path = path, - uri = uri, - mimeType = mimeType, - callback = callback, - iptc = iptc, - ) - - if (success) { - if (postEditScan) { - scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback) - } else { - callback.onSuccess(HashMap()) + if (modifier.containsKey("xmp")) { + val xmp = modifier["xmp"] as Map<*, *>? + if (xmp != null) { + val coreXmp = xmp["xmp"] as String? + val extendedXmp = xmp["extendedXmp"] as String? + if (!editXmp( + context = context, + path = path, + uri = uri, + mimeType = mimeType, + callback = callback, + coreXmp = coreXmp, + extendedXmp = extendedXmp, + ) + ) return } - } else { - callback.onFailure(Exception("failed to set IPTC")) } - } - fun setXmp( - context: Context, - path: String, - uri: Uri, - mimeType: String, - callback: ImageOpCallback, - coreXmp: String? = null, - extendedXmp: String? = null, - ) { val newFields = HashMap() - - val success = editXmp( - context = context, - path = path, - uri = uri, - mimeType = mimeType, - callback = callback, - coreXmp = coreXmp, - extendedXmp = extendedXmp, - ) - - if (success) { - scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback) - } else { - callback.onFailure(Exception("failed to set XMP")) - } + scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback) } fun removeMetadataTypes( diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 20d207b42..edebff219 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -22,7 +22,7 @@ "nextTooltip": "Nächste", "showTooltip": "Anzeigen", "hideTooltip": "Ausblenden", - "removeTooltip": "Entfernen", + "actionRemove": "Entfernen", "resetButtonTooltip": "Zurücksetzen", "doubleBackExitMessage": "Tippen Sie zum Verlassen erneut auf „Zurück“.", @@ -180,7 +180,6 @@ "editEntryDateDialogTitle": "Datum & Uhrzeit", "editEntryDateDialogExtractFromTitle": "Auszug aus dem Titel", "editEntryDateDialogShift": "Verschieben", - "editEntryDateDialogClear": "Aufräumen", "editEntryDateDialogHours": "Stunden", "editEntryDateDialogMinutes": "Minuten", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index aec69c717..b90b87835 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -37,7 +37,7 @@ "nextTooltip": "Next", "showTooltip": "Show", "hideTooltip": "Hide", - "removeTooltip": "Remove", + "actionRemove": "Remove", "resetButtonTooltip": "Reset", "doubleBackExitMessage": "Tap “back” again to exit.", @@ -88,6 +88,7 @@ "videoActionSettings": "Settings", "entryInfoActionEditDate": "Edit date & time", + "entryInfoActionEditRating": "Edit rating", "entryInfoActionEditTags": "Edit tags", "entryInfoActionRemoveMetadata": "Remove metadata", @@ -279,12 +280,13 @@ "editEntryDateDialogCopyField": "Set from other date", "editEntryDateDialogExtractFromTitle": "Extract from title", "editEntryDateDialogShift": "Shift", - "editEntryDateDialogClear": "Clear", "editEntryDateDialogSourceFileModifiedDate": "File modified date", "editEntryDateDialogTargetFieldsHeader": "Fields to modify", "editEntryDateDialogHours": "Hours", "editEntryDateDialogMinutes": "Minutes", + "editEntryRatingDialogTitle": "Rating", + "removeEntryMetadataDialogTitle": "Metadata Removal", "removeEntryMetadataDialogMore": "More", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 2971c6bec..87b92dd51 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -22,7 +22,7 @@ "nextTooltip": "Suivant", "showTooltip": "Afficher", "hideTooltip": "Masquer", - "removeTooltip": "Supprimer", + "actionRemove": "Supprimer", "resetButtonTooltip": "Réinitialiser", "doubleBackExitMessage": "Pressez «\u00A0retour\u00A0» à nouveau pour quitter.", @@ -184,7 +184,6 @@ "editEntryDateDialogCopyField": "Copier d'une autre date", "editEntryDateDialogExtractFromTitle": "Extraire du titre", "editEntryDateDialogShift": "Décaler", - "editEntryDateDialogClear": "Effacer", "editEntryDateDialogSourceFileModifiedDate": "Date de modification du fichier", "editEntryDateDialogTargetFieldsHeader": "Champs à modifier", "editEntryDateDialogHours": "Heures", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index c5cd70c88..56955ad05 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -22,7 +22,7 @@ "nextTooltip": "다음", "showTooltip": "보기", "hideTooltip": "숨기기", - "removeTooltip": "제거", + "actionRemove": "제거", "resetButtonTooltip": "복원", "doubleBackExitMessage": "종료하려면 한번 더 누르세요.", @@ -184,7 +184,6 @@ "editEntryDateDialogCopyField": "다른 날짜에서 지정", "editEntryDateDialogExtractFromTitle": "제목에서 추출", "editEntryDateDialogShift": "시간 이동", - "editEntryDateDialogClear": "삭제", "editEntryDateDialogSourceFileModifiedDate": "파일 수정한 날짜", "editEntryDateDialogTargetFieldsHeader": "수정할 필드", "editEntryDateDialogHours": "시간", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index e851d2fe2..9d9e3809f 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -22,7 +22,7 @@ "nextTooltip": "Следующий", "showTooltip": "Показать", "hideTooltip": "Скрыть", - "removeTooltip": "Удалить", + "actionRemove": "Удалить", "resetButtonTooltip": "Сбросить", "doubleBackExitMessage": "Нажмите «назад» еще раз, чтобы выйти.", @@ -180,7 +180,6 @@ "editEntryDateDialogTitle": "Дата и время", "editEntryDateDialogExtractFromTitle": "Извлечь из названия", "editEntryDateDialogShift": "Сдвиг", - "editEntryDateDialogClear": "Очистить", "editEntryDateDialogHours": "Часов", "editEntryDateDialogMinutes": "Минут", diff --git a/lib/model/actions/entry_info_actions.dart b/lib/model/actions/entry_info_actions.dart index 901133187..94ff10857 100644 --- a/lib/model/actions/entry_info_actions.dart +++ b/lib/model/actions/entry_info_actions.dart @@ -5,6 +5,7 @@ import 'package:flutter/widgets.dart'; enum EntryInfoAction { // general editDate, + editRating, editTags, removeMetadata, // motion photo @@ -14,6 +15,7 @@ enum EntryInfoAction { class EntryInfoActions { static const all = [ EntryInfoAction.editDate, + EntryInfoAction.editRating, EntryInfoAction.editTags, EntryInfoAction.removeMetadata, EntryInfoAction.viewMotionPhotoVideo, @@ -26,6 +28,8 @@ extension ExtraEntryInfoAction on EntryInfoAction { // general case EntryInfoAction.editDate: return context.l10n.entryInfoActionEditDate; + case EntryInfoAction.editRating: + return context.l10n.entryInfoActionEditRating; case EntryInfoAction.editTags: return context.l10n.entryInfoActionEditTags; case EntryInfoAction.removeMetadata: @@ -45,6 +49,8 @@ extension ExtraEntryInfoAction on EntryInfoAction { // general case EntryInfoAction.editDate: return AIcons.date; + case EntryInfoAction.editRating: + return AIcons.rating; case EntryInfoAction.editTags: return AIcons.addTag; case EntryInfoAction.removeMetadata: diff --git a/lib/model/actions/entry_set_actions.dart b/lib/model/actions/entry_set_actions.dart index 2d97dabca..6d8ae339c 100644 --- a/lib/model/actions/entry_set_actions.dart +++ b/lib/model/actions/entry_set_actions.dart @@ -25,6 +25,7 @@ enum EntrySetAction { rotateCW, flip, editDate, + editRating, editTags, removeMetadata, } @@ -101,6 +102,8 @@ extension ExtraEntrySetAction on EntrySetAction { return context.l10n.entryActionFlip; case EntrySetAction.editDate: return context.l10n.entryInfoActionEditDate; + case EntrySetAction.editRating: + return context.l10n.entryInfoActionEditRating; case EntrySetAction.editTags: return context.l10n.entryInfoActionEditTags; case EntrySetAction.removeMetadata: @@ -155,6 +158,8 @@ extension ExtraEntrySetAction on EntrySetAction { return AIcons.flip; case EntrySetAction.editDate: return AIcons.date; + case EntrySetAction.editRating: + return AIcons.rating; case EntrySetAction.editTags: return AIcons.addTag; case EntrySetAction.removeMetadata: diff --git a/lib/model/entry.dart b/lib/model/entry.dart index 9d2e83afe..d7650828d 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -239,6 +239,8 @@ class AvesEntry { bool get canEditDate => canEdit && canEditExif; + bool get canEditRating => canEdit && canEditXmp; + bool get canEditTags => canEdit && canEditXmp; bool get canRotateAndFlip => canEdit && canEditExif; @@ -709,7 +711,7 @@ class AvesEntry { break; case DateEditAction.setCustom: case DateEditAction.shift: - case DateEditAction.clear: + case DateEditAction.remove: break; } final newFields = await metadataEditService.editDate(this, modifier); @@ -733,10 +735,14 @@ class AvesEntry { final metadataDate = catalogMetadata?.dateMillis; if (metadataDate != null && metadataDate > 0) return {}; - return await editDate(DateModifier.copyField( - const {MetadataField.exifDateOriginal}, - DateFieldSource.fileModifiedDate, - )); + if (canEditExif) { + return await editDate(DateModifier.copyField( + const {MetadataField.exifDateOriginal}, + DateFieldSource.fileModifiedDate, + )); + } + // TODO TLAD [metadata] set XMP / xmp:CreateDate + return {}; } Future> removeMetadata(Set types) async { diff --git a/lib/model/entry_metadata_edition.dart b/lib/model/entry_metadata_edition.dart new file mode 100644 index 000000000..e9428a7b8 --- /dev/null +++ b/lib/model/entry_metadata_edition.dart @@ -0,0 +1,122 @@ +import 'dart:convert'; + +import 'package:aves/model/entry.dart'; +import 'package:aves/model/metadata/enums.dart'; +import 'package:aves/ref/iptc.dart'; +import 'package:aves/services/common/services.dart'; +import 'package:aves/services/metadata/xmp.dart'; +import 'package:aves/utils/xmp_utils.dart'; +import 'package:flutter/foundation.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:xml/xml.dart'; + +extension ExtraAvesEntryMetadataEdition on AvesEntry { + // write: + // - IPTC / keywords, if IPTC exists + // - XMP / dc:subject + Future> editTags(Set tags) async { + final Map metadata = {}; + + final dataTypes = await setMetadataDateIfMissing(); + + if (canEditIptc) { + final iptc = await metadataFetchService.getIptc(this); + if (iptc != null) { + editTagsIptc(iptc, tags); + metadata[MetadataType.iptc] = iptc; + } + } + + if (canEditXmp) { + metadata[MetadataType.xmp] = await _editXmp((descriptions) => editTagsXmp(descriptions, tags)); + } + + final newFields = await metadataEditService.editMetadata(this, metadata); + if (newFields.isNotEmpty) { + dataTypes.add(EntryDataType.catalog); + } + return dataTypes; + } + + // write: + // - XMP / xmp:Rating + // update: + // - XMP / MicrosoftPhoto:Rating + // ignore (Windows tags, not part of Exif 2.32 spec): + // - Exif / Rating + // - Exif / RatingPercent + Future> editRating(int? rating) async { + final Map metadata = {}; + + final dataTypes = await setMetadataDateIfMissing(); + + if (canEditXmp) { + metadata[MetadataType.xmp] = await _editXmp((descriptions) => editRatingXmp(descriptions, rating)); + } + + final newFields = await metadataEditService.editMetadata(this, metadata); + if (newFields.isNotEmpty) { + dataTypes.add(EntryDataType.catalog); + } + return dataTypes; + } + + @visibleForTesting + static void editTagsIptc(List> iptc, Set tags) { + iptc.removeWhere((v) => v['record'] == IPTC.applicationRecord && v['tag'] == IPTC.keywordsTag); + iptc.add({ + 'record': IPTC.applicationRecord, + 'tag': IPTC.keywordsTag, + 'values': tags.map((v) => utf8.encode(v)).toList(), + }); + } + + @visibleForTesting + static void editTagsXmp(List descriptions, Set tags) { + XMP.setStringBag( + descriptions, + XMP.dcSubject, + tags, + namespace: Namespaces.dc, + strat: XmpEditStrategy.always, + ); + } + + @visibleForTesting + static void editRatingXmp(List descriptions, int? rating) { + XMP.setAttribute( + descriptions, + XMP.xmpRating, + (rating ?? 0) == 0 ? null : '$rating', + namespace: Namespaces.xmp, + strat: XmpEditStrategy.always, + ); + XMP.setAttribute( + descriptions, + XMP.msPhotoRating, + XMP.toMsPhotoRating(rating), + namespace: Namespaces.microsoftPhoto, + strat: XmpEditStrategy.updateIfPresent, + ); + } + + // convenience + + Future> _editXmp(void Function(List descriptions) apply) async { + final xmp = await metadataFetchService.getXmp(this); + final xmpString = xmp?.xmpString; + final extendedXmpString = xmp?.extendedXmpString; + + final editedXmpString = await XMP.edit( + xmpString, + () => PackageInfo.fromPlatform().then((v) => 'Aves v${v.version}'), + apply, + ); + + final editedXmp = AvesXmp(xmpString: editedXmpString, extendedXmpString: extendedXmpString); + return { + 'xmp': editedXmp.xmpString, + 'extendedXmp': editedXmp.extendedXmpString, + }; + } +} diff --git a/lib/model/entry_xmp_iptc.dart b/lib/model/entry_xmp_iptc.dart deleted file mode 100644 index b61c80cd7..000000000 --- a/lib/model/entry_xmp_iptc.dart +++ /dev/null @@ -1,242 +0,0 @@ -import 'dart:convert'; - -import 'package:aves/model/entry.dart'; -import 'package:aves/ref/iptc.dart'; -import 'package:aves/services/common/services.dart'; -import 'package:collection/collection.dart'; -import 'package:equatable/equatable.dart'; -import 'package:flutter/foundation.dart'; -import 'package:intl/intl.dart'; -import 'package:package_info_plus/package_info_plus.dart'; -import 'package:xml/xml.dart'; - -extension ExtraAvesEntryXmpIptc on AvesEntry { - static const dcNamespace = 'http://purl.org/dc/elements/1.1/'; - static const rdfNamespace = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'; - static const xNamespace = 'adobe:ns:meta/'; - static const xmpNamespace = 'http://ns.adobe.com/xap/1.0/'; - static const xmpNoteNamespace = 'http://ns.adobe.com/xmp/note/'; - - static const xmlnsPrefix = 'xmlns'; - - static final nsDefaultPrefixes = { - dcNamespace: 'dc', - rdfNamespace: 'rdf', - xNamespace: 'x', - xmpNamespace: 'xmp', - xmpNoteNamespace: 'xmpNote', - }; - - // elements - static const xXmpmeta = 'xmpmeta'; - static const rdfRoot = 'RDF'; - static const rdfDescription = 'Description'; - static const dcSubject = 'subject'; - - // attributes - static const xXmptk = 'xmptk'; - static const rdfAbout = 'about'; - static const xmpMetadataDate = 'MetadataDate'; - static const xmpModifyDate = 'ModifyDate'; - static const xmpNoteHasExtendedXMP = 'HasExtendedXMP'; - - 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; - - XmlDocument? xmpDoc; - if (xmp != null) { - final xmpString = xmp.xmpString; - if (xmpString != null) { - xmpDoc = XmlDocument.parse(xmpString); - } - } - if (xmpDoc == null) { - final toolkit = 'Aves v${(await PackageInfo.fromPlatform()).version}'; - final builder = XmlBuilder(); - builder.namespace(xNamespace, prefixOf(xNamespace)); - builder.element(xXmpmeta, namespace: xNamespace, namespaces: { - xNamespace: prefixOf(xNamespace), - }, attributes: { - '${prefixOf(xNamespace)}:$xXmptk': toolkit, - }); - xmpDoc = builder.buildDocument(); - } - - final root = xmpDoc.rootElement; - XmlNode? rdf = root.getElement(rdfRoot, namespace: rdfNamespace); - if (rdf == null) { - final builder = XmlBuilder(); - builder.namespace(rdfNamespace, prefixOf(rdfNamespace)); - builder.element(rdfRoot, namespace: rdfNamespace, namespaces: { - rdfNamespace: prefixOf(rdfNamespace), - }); - // get element because doc fragment cannot be used to edit - root.children.add(builder.buildFragment()); - rdf = root.getElement(rdfRoot, namespace: rdfNamespace)!; - } - - XmlNode? description = rdf.getElement(rdfDescription, namespace: rdfNamespace); - if (description == null) { - final builder = XmlBuilder(); - builder.namespace(rdfNamespace, prefixOf(rdfNamespace)); - builder.element(rdfDescription, namespace: rdfNamespace, attributes: { - '${prefixOf(rdfNamespace)}:$rdfAbout': '', - }); - rdf.children.add(builder.buildFragment()); - // get element because doc fragment cannot be used to edit - description = rdf.getElement(rdfDescription, namespace: rdfNamespace)!; - } - _setNamespaces(description, { - dcNamespace: prefixOf(dcNamespace), - xmpNamespace: prefixOf(xmpNamespace), - }); - - _setStringBag(description, dcSubject, tags, namespace: dcNamespace); - - if (_isMeaningfulXmp(rdf)) { - final modifyDate = DateTime.now(); - description.setAttribute('${prefixOf(xmpNamespace)}:$xmpMetadataDate', _toXmpDate(modifyDate)); - description.setAttribute('${prefixOf(xmpNamespace)}:$xmpModifyDate', _toXmpDate(modifyDate)); - } else { - // clear XMP if there are no attributes or elements worth preserving - xmpDoc = null; - } - - final editedXmp = AvesXmp( - xmpString: xmpDoc?.toXmlString(), - extendedXmpString: extendedXmpString, - ); - - if (canEditIptc) { - final iptc = await metadataFetchService.getIptc(this); - if (iptc != null) { - await _setIptcKeywords(iptc, tags); - } - } - - final newFields = await metadataEditService.setXmp(this, editedXmp); - if (newFields.isNotEmpty) { - dataTypes.add(EntryDataType.catalog); - } - return dataTypes; - } - - Future _setIptcKeywords(List> iptc, Set tags) async { - iptc.removeWhere((v) => v['record'] == IPTC.applicationRecord && v['tag'] == IPTC.keywordsTag); - iptc.add({ - 'record': IPTC.applicationRecord, - 'tag': IPTC.keywordsTag, - 'values': tags.map((v) => utf8.encode(v)).toList(), - }); - await metadataEditService.setIptc(this, iptc, postEditScan: false); - } - - int _meaningfulChildrenCount(XmlNode node) => node.children.where((v) => v.nodeType != XmlNodeType.TEXT || v.text.trim().isNotEmpty).length; - - bool _isMeaningfulXmp(XmlNode rdf) { - if (_meaningfulChildrenCount(rdf) > 1) return true; - - final description = rdf.getElement(rdfDescription, namespace: rdfNamespace); - if (description == null) return true; - - if (_meaningfulChildrenCount(description) > 0) return true; - - final hasMeaningfulAttributes = description.attributes.any((v) { - switch (v.name.local) { - case rdfAbout: - return v.value.isNotEmpty; - case xmpMetadataDate: - case xmpModifyDate: - return false; - default: - switch (v.name.prefix) { - case xmlnsPrefix: - return false; - default: - // if the attribute got defined with the prefix as part of the name, - // the prefix is not recognized as such, so we check the full name - return !v.name.qualified.startsWith(xmlnsPrefix); - } - } - }); - return hasMeaningfulAttributes; - } - - // return time zone designator, formatted as `Z` or `+hh:mm` or `-hh:mm` - // as of intl v0.17.0, formatting time zone offset is not implemented - String _xmpTimeZoneDesignator(DateTime date) { - final offsetMinutes = date.timeZoneOffset.inMinutes; - final abs = offsetMinutes.abs(); - final h = abs ~/ Duration.minutesPerHour; - final m = abs % Duration.minutesPerHour; - return '${offsetMinutes.isNegative ? '-' : '+'}${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}'; - } - - String _toXmpDate(DateTime date) => '${DateFormat('yyyy-MM-ddTHH:mm:ss').format(date)}${_xmpTimeZoneDesignator(date)}'; - - void _setNamespaces(XmlNode node, Map namespaces) => namespaces.forEach((uri, prefix) => node.setAttribute('$xmlnsPrefix:$prefix', uri)); - - void _setStringBag(XmlNode node, String name, Set values, {required String namespace}) { - // remove existing - node.findElements(name, namespace: namespace).toSet().forEach(node.children.remove); - - if (values.isNotEmpty) { - // add new bag - final rootBuilder = XmlBuilder(); - rootBuilder.namespace(namespace, prefixOf(namespace)); - rootBuilder.element(name, namespace: namespace); - node.children.add(rootBuilder.buildFragment()); - - final bagBuilder = XmlBuilder(); - bagBuilder.namespace(rdfNamespace, prefixOf(rdfNamespace)); - bagBuilder.element('Bag', namespace: rdfNamespace, nest: () { - values.forEach((v) { - bagBuilder.element('li', namespace: rdfNamespace, nest: v); - }); - }); - node.children.last.children.add(bagBuilder.buildFragment()); - } - } -} - -@immutable -class AvesXmp extends Equatable { - final String? xmpString; - final String? extendedXmpString; - - @override - List get props => [xmpString, extendedXmpString]; - - const AvesXmp({ - required this.xmpString, - this.extendedXmpString, - }); - - static AvesXmp? fromList(List xmpStrings) { - switch (xmpStrings.length) { - case 0: - return null; - case 1: - return AvesXmp(xmpString: xmpStrings.single); - default: - final byExtending = groupBy(xmpStrings, (v) => v.contains(':HasExtendedXMP=')); - final extending = byExtending[true] ?? []; - final extension = byExtending[false] ?? []; - if (extending.length == 1 && extension.length == 1) { - return AvesXmp( - xmpString: extending.single, - extendedXmpString: extension.single, - ); - } - - // take the first XMP and ignore the rest when the file is weirdly constructed - debugPrint('warning: entry has ${xmpStrings.length} XMP directories, xmpStrings=$xmpStrings'); - return AvesXmp(xmpString: xmpStrings.firstOrNull); - } - } -} diff --git a/lib/model/metadata/date_modifier.dart b/lib/model/metadata/date_modifier.dart index 176d403e7..96ef5ffc0 100644 --- a/lib/model/metadata/date_modifier.dart +++ b/lib/model/metadata/date_modifier.dart @@ -41,7 +41,7 @@ class DateModifier { return DateModifier._private(DateEditAction.shift, fields, shiftMinutes: shiftMinutes); } - factory DateModifier.clear(Set fields) { - return DateModifier._private(DateEditAction.clear, fields); + factory DateModifier.remove(Set fields) { + return DateModifier._private(DateEditAction.remove, fields); } } diff --git a/lib/model/metadata/enums.dart b/lib/model/metadata/enums.dart index d5592e9dc..768a87d16 100644 --- a/lib/model/metadata/enums.dart +++ b/lib/model/metadata/enums.dart @@ -10,7 +10,7 @@ enum DateEditAction { copyField, extractFromTitle, shift, - clear, + remove, } enum DateFieldSource { @@ -65,7 +65,7 @@ class MetadataTypes { } extension ExtraMetadataType on MetadataType { - // match `ExifInterface` directory names + // match `metadata-extractor` directory names String getText() { switch (this) { case MetadataType.comment: diff --git a/lib/ref/xmp.dart b/lib/ref/xmp.dart deleted file mode 100644 index 53c6f1c30..000000000 --- a/lib/ref/xmp.dart +++ /dev/null @@ -1,52 +0,0 @@ -class XMP { - static const propNamespaceSeparator = ':'; - static const structFieldSeparator = '/'; - - // cf https://exiftool.org/TagNames/XMP.html - static const Map namespaces = { - 'acdsee': 'ACDSee', - 'adsml-at': 'AdsML', - 'aux': 'Exif Aux', - 'avm': 'Astronomy Visualization', - 'Camera': 'Camera', - 'cc': 'Creative Commons', - 'crd': 'Camera Raw Defaults', - 'creatorAtom': 'After Effects', - 'crs': 'Camera Raw Settings', - 'dc': 'Dublin Core', - 'drone-dji': 'DJI Drone', - 'dwc': 'Darwin Core', - 'exif': 'Exif', - 'exifEX': 'Exif Ex', - 'GettyImagesGIFT': 'Getty Images', - 'GAudio': 'Google Audio', - 'GDepth': 'Google Depth', - 'GImage': 'Google Image', - 'GIMP': 'GIMP', - 'GCamera': 'Google Camera', - 'GCreations': 'Google Creations', - 'GFocus': 'Google Focus', - 'GPano': 'Google Panorama', - 'illustrator': 'Illustrator', - 'Iptc4xmpCore': 'IPTC Core', - 'Iptc4xmpExt': 'IPTC Extension', - 'lr': 'Lightroom', - 'MicrosoftPhoto': 'Microsoft Photo', - 'mwg-rs': 'Regions', - 'panorama': 'Panorama', - 'PanoStudioXMP': 'PanoramaStudio', - 'pdf': 'PDF', - 'pdfx': 'PDF/X', - 'photomechanic': 'Photo Mechanic', - 'photoshop': 'Photoshop', - 'plus': 'PLUS', - 'pmtm': 'Photomatix', - 'tiff': 'TIFF', - 'xmp': 'Basic', - 'xmpBJ': 'Basic Job Ticket', - 'xmpDM': 'Dynamic Media', - 'xmpMM': 'Media Management', - 'xmpRights': 'Rights Management', - 'xmpTPg': 'Paged-Text', - }; -} diff --git a/lib/services/metadata/metadata_edit_service.dart b/lib/services/metadata/metadata_edit_service.dart index 2635c83a0..b9489a691 100644 --- a/lib/services/metadata/metadata_edit_service.dart +++ b/lib/services/metadata/metadata_edit_service.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:aves/model/entry.dart'; -import 'package:aves/model/entry_xmp_iptc.dart'; import 'package:aves/model/metadata/date_modifier.dart'; import 'package:aves/model/metadata/enums.dart'; import 'package:aves/services/common/services.dart'; @@ -14,9 +13,7 @@ abstract class MetadataEditService { Future> editDate(AvesEntry entry, DateModifier modifier); - Future> setIptc(AvesEntry entry, List>? iptc, {required bool postEditScan}); - - Future> setXmp(AvesEntry entry, AvesXmp? xmp); + Future> editMetadata(AvesEntry entry, Map modifier); Future> removeTypes(AvesEntry entry, Set types); } @@ -91,29 +88,11 @@ class PlatformMetadataEditService implements MetadataEditService { } @override - Future> setIptc(AvesEntry entry, List>? iptc, {required bool postEditScan}) async { + Future> editMetadata(AvesEntry entry, Map metadata) async { try { - final result = await platform.invokeMethod('setIptc', { + final result = await platform.invokeMethod('editMetadata', { 'entry': _toPlatformEntryMap(entry), - 'iptc': iptc, - 'postEditScan': postEditScan, - }); - if (result != null) return (result as Map).cast(); - } on PlatformException catch (e, stack) { - if (!entry.isMissingAtPath) { - await reportService.recordError(e, stack); - } - } - return {}; - } - - @override - Future> setXmp(AvesEntry entry, AvesXmp? xmp) async { - try { - final result = await platform.invokeMethod('setXmp', { - 'entry': _toPlatformEntryMap(entry), - 'xmp': xmp?.xmpString, - 'extendedXmp': xmp?.extendedXmpString, + 'metadata': metadata.map((type, value) => MapEntry(_toPlatformMetadataType(type), value)), }); if (result != null) return (result as Map).cast(); } on PlatformException catch (e, stack) { diff --git a/lib/services/metadata/metadata_fetch_service.dart b/lib/services/metadata/metadata_fetch_service.dart index 3bf7e42e8..5a665af3d 100644 --- a/lib/services/metadata/metadata_fetch_service.dart +++ b/lib/services/metadata/metadata_fetch_service.dart @@ -1,5 +1,4 @@ import 'package:aves/model/entry.dart'; -import 'package:aves/model/entry_xmp_iptc.dart'; import 'package:aves/model/metadata/catalog.dart'; import 'package:aves/model/metadata/enums.dart'; import 'package:aves/model/metadata/overlay.dart'; @@ -7,6 +6,7 @@ import 'package:aves/model/multipage.dart'; import 'package:aves/model/panorama.dart'; import 'package:aves/services/common/service_policy.dart'; import 'package:aves/services/common/services.dart'; +import 'package:aves/services/metadata/xmp.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; diff --git a/lib/services/metadata/xmp.dart b/lib/services/metadata/xmp.dart new file mode 100644 index 000000000..a6be54191 --- /dev/null +++ b/lib/services/metadata/xmp.dart @@ -0,0 +1,40 @@ +import 'package:collection/collection.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; + +@immutable +class AvesXmp extends Equatable { + final String? xmpString; + final String? extendedXmpString; + + @override + List get props => [xmpString, extendedXmpString]; + + const AvesXmp({ + required this.xmpString, + this.extendedXmpString, + }); + + static AvesXmp? fromList(List xmpStrings) { + switch (xmpStrings.length) { + case 0: + return null; + case 1: + return AvesXmp(xmpString: xmpStrings.single); + default: + final byExtending = groupBy(xmpStrings, (v) => v.contains(':HasExtendedXMP=')); + final extending = byExtending[true] ?? []; + final extension = byExtending[false] ?? []; + if (extending.length == 1 && extension.length == 1) { + return AvesXmp( + xmpString: extending.single, + extendedXmpString: extension.single, + ); + } + + // take the first XMP and ignore the rest when the file is weirdly constructed + debugPrint('warning: entry has ${xmpStrings.length} XMP directories, xmpStrings=$xmpStrings'); + return AvesXmp(xmpString: xmpStrings.firstOrNull); + } + } +} diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index c31abc19a..2299830f7 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -22,6 +22,8 @@ class AIcons { static const IconData locationOff = Icons.location_off_outlined; static const IconData mainStorage = Icons.smartphone_outlined; static const IconData privacy = MdiIcons.shieldAccountOutline; + static const IconData rating = Icons.star_border_outlined; + static const IconData ratingFull = Icons.star; static const IconData ratingRejected = MdiIcons.starMinusOutline; static const IconData ratingUnrated = MdiIcons.starOffOutline; static const IconData raw = Icons.raw_on_outlined; @@ -113,7 +115,6 @@ class AIcons { static const IconData geo = Icons.language_outlined; static const IconData motionPhoto = Icons.motion_photos_on_outlined; static const IconData multiPage = Icons.burst_mode_outlined; - static const IconData rating = Icons.star_border_outlined; static const IconData threeSixty = Icons.threesixty_outlined; static const IconData videoThumb = Icons.play_circle_outline; static const IconData selected = Icons.check_circle_outline; diff --git a/lib/utils/xmp_utils.dart b/lib/utils/xmp_utils.dart new file mode 100644 index 000000000..798bb8771 --- /dev/null +++ b/lib/utils/xmp_utils.dart @@ -0,0 +1,273 @@ +import 'package:intl/intl.dart'; +import 'package:xml/xml.dart'; + +class Namespaces { + static const dc = 'http://purl.org/dc/elements/1.1/'; + static const microsoftPhoto = 'http://ns.microsoft.com/photo/1.0/'; + static const rdf = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'; + static const x = 'adobe:ns:meta/'; + static const xmp = 'http://ns.adobe.com/xap/1.0/'; + static const xmpNote = 'http://ns.adobe.com/xmp/note/'; + + static final defaultPrefixes = { + dc: 'dc', + microsoftPhoto: 'MicrosoftPhoto', + rdf: 'rdf', + x: 'x', + xmp: 'xmp', + xmpNote: 'xmpNote', + }; +} + +class XMP { + static const xmlnsPrefix = 'xmlns'; + static const propNamespaceSeparator = ':'; + static const structFieldSeparator = '/'; + + static String prefixOf(String ns) => Namespaces.defaultPrefixes[ns] ?? ''; + + // elements + static const xXmpmeta = 'xmpmeta'; + static const rdfRoot = 'RDF'; + static const rdfDescription = 'Description'; + static const dcSubject = 'subject'; + static const msPhotoRating = 'Rating'; + static const xmpRating = 'Rating'; + + // attributes + static const xXmptk = 'xmptk'; + static const rdfAbout = 'about'; + static const xmpMetadataDate = 'MetadataDate'; + static const xmpModifyDate = 'ModifyDate'; + static const xmpNoteHasExtendedXMP = 'HasExtendedXMP'; + + // for `rdf:Description` node only + static bool _hasMeaningfulChildren(XmlNode node) => node.children.any((v) => v.nodeType != XmlNodeType.TEXT || v.text.trim().isNotEmpty); + + // for `rdf:Description` node only + static bool _hasMeaningfulAttributes(XmlNode description) { + final hasMeaningfulAttributes = description.attributes.any((v) { + switch (v.name.local) { + case rdfAbout: + case xmpMetadataDate: + case xmpModifyDate: + return false; + default: + switch (v.name.prefix) { + case xmlnsPrefix: + return false; + default: + // if the attribute got defined with the prefix as part of the name, + // the prefix is not recognized as such, so we check the full name + return !v.name.qualified.startsWith(xmlnsPrefix); + } + } + }); + return hasMeaningfulAttributes; + } + + // return time zone designator, formatted as `Z` or `+hh:mm` or `-hh:mm` + // as of intl v0.17.0, formatting time zone offset is not implemented + static String _xmpTimeZoneDesignator(DateTime date) { + final offsetMinutes = date.timeZoneOffset.inMinutes; + final abs = offsetMinutes.abs(); + final h = abs ~/ Duration.minutesPerHour; + final m = abs % Duration.minutesPerHour; + return '${offsetMinutes.isNegative ? '-' : '+'}${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}'; + } + + static String toXmpDate(DateTime date) => '${DateFormat('yyyy-MM-ddTHH:mm:ss').format(date)}${_xmpTimeZoneDesignator(date)}'; + + static void _addNamespaces(XmlNode node, Map namespaces) => namespaces.forEach((uri, prefix) => node.setAttribute('$xmlnsPrefix:$prefix', uri)); + + // remove elements and attributes + static bool _removeElements(List nodes, String name, String namespace) { + var removed = false; + nodes.forEach((node) { + final elements = node.findElements(name, namespace: namespace).toSet(); + if (elements.isNotEmpty) { + elements.forEach(node.children.remove); + removed = true; + } + + if (node.getAttributeNode(name, namespace: namespace) != null) { + node.removeAttribute(name, namespace: namespace); + removed = true; + } + }); + return removed; + } + + // remove attribute/element from all nodes, and set attribute with new value, if any, in the first node + static void setAttribute( + List nodes, + String name, + String? value, { + required String namespace, + required XmpEditStrategy strat, + }) { + final removed = _removeElements(nodes, name, namespace); + + if (value == null) return; + + if (strat == XmpEditStrategy.always || (strat == XmpEditStrategy.updateIfPresent && removed)) { + final node = nodes.first; + _addNamespaces(node, {namespace: prefixOf(namespace)}); + + // use qualified name, otherwise the namespace prefix is not added + final qualifiedName = '${prefixOf(namespace)}$propNamespaceSeparator$name'; + node.setAttribute(qualifiedName, value); + } + } + + // remove attribute/element from all nodes, and create element with new value, if any, in the first node + static void setElement( + List nodes, + String name, + String? value, { + required String namespace, + required XmpEditStrategy strat, + }) { + final removed = _removeElements(nodes, name, namespace); + + if (value == null) return; + + if (strat == XmpEditStrategy.always || (strat == XmpEditStrategy.updateIfPresent && removed)) { + final node = nodes.first; + _addNamespaces(node, {namespace: prefixOf(namespace)}); + + final builder = XmlBuilder(); + builder.namespace(namespace, prefixOf(namespace)); + builder.element(name, namespace: namespace, nest: () { + builder.text(value); + }); + node.children.add(builder.buildFragment()); + } + } + + // remove bag from all nodes, and create bag with new values, if any, in the first node + static void setStringBag( + List nodes, + String name, + Set values, { + required String namespace, + required XmpEditStrategy strat, + }) { + // remove existing + final removed = _removeElements(nodes, name, namespace); + + if (values.isEmpty) return; + + if (strat == XmpEditStrategy.always || (strat == XmpEditStrategy.updateIfPresent && removed)) { + final node = nodes.first; + _addNamespaces(node, {namespace: prefixOf(namespace)}); + + // add new bag + final rootBuilder = XmlBuilder(); + rootBuilder.namespace(namespace, prefixOf(namespace)); + rootBuilder.element(name, namespace: namespace); + node.children.add(rootBuilder.buildFragment()); + + final bagBuilder = XmlBuilder(); + bagBuilder.namespace(Namespaces.rdf, prefixOf(Namespaces.rdf)); + bagBuilder.element('Bag', namespace: Namespaces.rdf, nest: () { + values.forEach((v) { + bagBuilder.element('li', namespace: Namespaces.rdf, nest: v); + }); + }); + node.children.last.children.add(bagBuilder.buildFragment()); + } + } + + static Future edit( + String? xmpString, + Future Function() toolkit, + void Function(List descriptions) apply, { + DateTime? modifyDate, + }) async { + XmlDocument? xmpDoc; + if (xmpString != null) { + xmpDoc = XmlDocument.parse(xmpString); + } + if (xmpDoc == null) { + final builder = XmlBuilder(); + builder.namespace(Namespaces.x, prefixOf(Namespaces.x)); + builder.element(xXmpmeta, namespace: Namespaces.x, namespaces: { + Namespaces.x: prefixOf(Namespaces.x), + }, attributes: { + '${prefixOf(Namespaces.x)}$propNamespaceSeparator$xXmptk': await toolkit(), + }); + xmpDoc = builder.buildDocument(); + } + + final root = xmpDoc.rootElement; + XmlNode? rdf = root.getElement(rdfRoot, namespace: Namespaces.rdf); + if (rdf == null) { + final builder = XmlBuilder(); + builder.namespace(Namespaces.rdf, prefixOf(Namespaces.rdf)); + builder.element(rdfRoot, namespace: Namespaces.rdf, namespaces: { + Namespaces.rdf: prefixOf(Namespaces.rdf), + }); + // get element because doc fragment cannot be used to edit + root.children.add(builder.buildFragment()); + rdf = root.getElement(rdfRoot, namespace: Namespaces.rdf)!; + } + + // content can be split in multiple `rdf:Description` elements + List descriptions = rdf.children.where((node) { + return node is XmlElement && node.name.local == rdfDescription && node.name.namespaceUri == Namespaces.rdf; + }).toList(); + + if (descriptions.isEmpty) { + final builder = XmlBuilder(); + builder.namespace(Namespaces.rdf, prefixOf(Namespaces.rdf)); + builder.element(rdfDescription, namespace: Namespaces.rdf, attributes: { + '${prefixOf(Namespaces.rdf)}$propNamespaceSeparator$rdfAbout': '', + }); + rdf.children.add(builder.buildFragment()); + // get element because doc fragment cannot be used to edit + descriptions.add(rdf.getElement(rdfDescription, namespace: Namespaces.rdf)!); + } + apply(descriptions); + + // clean description nodes with no children + descriptions.where((v) => !_hasMeaningfulChildren(v)).forEach((v) => v.children.clear()); + + // remove superfluous description nodes + rdf.children.removeWhere((v) => !_hasMeaningfulChildren(v) && !_hasMeaningfulAttributes(v)); + + if (rdf.children.isNotEmpty) { + _addNamespaces(descriptions.first, {Namespaces.xmp: prefixOf(Namespaces.xmp)}); + final xmpDate = toXmpDate(modifyDate ?? DateTime.now()); + setAttribute(descriptions, xmpMetadataDate, xmpDate, namespace: Namespaces.xmp, strat: XmpEditStrategy.always); + setAttribute(descriptions, xmpModifyDate, xmpDate, namespace: Namespaces.xmp, strat: XmpEditStrategy.always); + } else { + // clear XMP if there are no attributes or elements worth preserving + xmpDoc = null; + } + + return xmpDoc?.toXmlString(); + } + + static String? toMsPhotoRating(int? rating) { + if (rating == null) return null; + switch (rating) { + case 5: + return '99'; + case 4: + return '75'; + case 3: + return '50'; + case 2: + return '25'; + case 1: + return '1'; + case 0: + return null; + case -1: + return '-1'; + } + } +} + +enum XmpEditStrategy { always, updateIfPresent } diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index 1d2e33d68..1bdaedae4 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -259,6 +259,7 @@ class _CollectionAppBarState extends State with SingleTickerPr _buildRotateAndFlipMenuItems(context, canApply: canApply), ...[ EntrySetAction.editDate, + EntrySetAction.editRating, EntrySetAction.editTags, EntrySetAction.removeMetadata, ].map((action) => _toMenuItem(action, enabled: canApply(action))), @@ -427,6 +428,7 @@ class _CollectionAppBarState extends State with SingleTickerPr case EntrySetAction.rotateCW: case EntrySetAction.flip: case EntrySetAction.editDate: + case EntrySetAction.editRating: case EntrySetAction.editTags: case EntrySetAction.removeMetadata: _actionDelegate.onActionSelected(context, action); diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index 36f36a0bb..ab8211a1b 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -6,7 +6,7 @@ import 'package:aves/model/actions/entry_set_actions.dart'; import 'package:aves/model/actions/move_type.dart'; import 'package:aves/model/device.dart'; import 'package:aves/model/entry.dart'; -import 'package:aves/model/entry_xmp_iptc.dart'; +import 'package:aves/model/entry_metadata_edition.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/highlight.dart'; @@ -77,6 +77,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa case EntrySetAction.rotateCW: case EntrySetAction.flip: case EntrySetAction.editDate: + case EntrySetAction.editRating: case EntrySetAction.editTags: case EntrySetAction.removeMetadata: return appMode == AppMode.main && isSelecting; @@ -118,6 +119,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa case EntrySetAction.rotateCW: case EntrySetAction.flip: case EntrySetAction.editDate: + case EntrySetAction.editRating: case EntrySetAction.editTags: case EntrySetAction.removeMetadata: return hasSelection; @@ -177,6 +179,9 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa case EntrySetAction.editDate: _editDate(context); break; + case EntrySetAction.editRating: + _editRating(context); + break; case EntrySetAction.editTags: _editTags(context); break; @@ -513,6 +518,19 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa await _edit(context, selection, todoItems, (entry) => entry.editDate(modifier)); } + Future _editRating(BuildContext context) async { + final selection = context.read>(); + final selectedItems = _getExpandedSelectedItems(selection); + + final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canEditRating); + if (todoItems == null || todoItems.isEmpty) return; + + final rating = await selectRating(context, todoItems); + if (rating == null) return; + + await _edit(context, selection, todoItems, (entry) => entry.editRating(rating)); + } + Future _editTags(BuildContext context) async { final selection = context.read>(); final selectedItems = _getExpandedSelectedItems(selection); diff --git a/lib/widgets/common/action_mixins/entry_editor.dart b/lib/widgets/common/action_mixins/entry_editor.dart index 5b383cbc7..9b84e7d32 100644 --- a/lib/widgets/common/action_mixins/entry_editor.dart +++ b/lib/widgets/common/action_mixins/entry_editor.dart @@ -5,6 +5,7 @@ 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/entry_editors/edit_entry_date_dialog.dart'; +import 'package:aves/widgets/dialogs/entry_editors/edit_entry_rating_dialog.dart'; import 'package:aves/widgets/dialogs/entry_editors/edit_entry_tags_dialog.dart'; import 'package:aves/widgets/dialogs/entry_editors/remove_entry_metadata_dialog.dart'; import 'package:flutter/material.dart'; @@ -22,6 +23,18 @@ mixin EntryEditorMixin { return modifier; } + Future selectRating(BuildContext context, Set entries) async { + if (entries.isEmpty) return null; + + final rating = await showDialog( + context: context, + builder: (context) => EditEntryRatingDialog( + entry: entries.first, + ), + ); + return rating; + } + Future>?> selectTags(BuildContext context, Set entries) async { if (entries.isEmpty) return null; diff --git a/lib/widgets/common/thumbnail/overlay.dart b/lib/widgets/common/thumbnail/overlay.dart index 5b35a8ccf..795a7e814 100644 --- a/lib/widgets/common/thumbnail/overlay.dart +++ b/lib/widgets/common/thumbnail/overlay.dart @@ -20,12 +20,12 @@ class ThumbnailEntryOverlay extends StatelessWidget { Widget build(BuildContext context) { final children = [ if (entry.hasGps && context.select((t) => t.showLocation)) const GpsIcon(), + if (entry.rating != 0 && context.select((t) => t.showRating)) RatingIcon(entry: entry), if (entry.isVideo) VideoIcon(entry: entry) else if (entry.isAnimated) const AnimatedImageIcon() else ...[ - if (entry.rating != 0 && context.select((t) => t.showRating)) RatingIcon(entry: entry), if (entry.isRaw && context.select((t) => t.showRaw)) const RawIcon(), if (entry.isGeotiff) const GeotiffIcon(), if (entry.is360) const SphericalImageIcon(), diff --git a/lib/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart b/lib/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart index 9fb584ac3..5b3b280ab 100644 --- a/lib/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart @@ -277,8 +277,8 @@ class _EditEntryDateDialogState extends State { return l10n.editEntryDateDialogExtractFromTitle; case DateEditAction.shift: return l10n.editEntryDateDialogShift; - case DateEditAction.clear: - return l10n.editEntryDateDialogClear; + case DateEditAction.remove: + return l10n.actionRemove; } } @@ -347,8 +347,8 @@ class _EditEntryDateDialogState extends State { case DateEditAction.shift: final shiftTotalMinutes = (_shiftHour.value * 60 + _shiftMinute.value) * (_shiftSign.value == '+' ? 1 : -1); return DateModifier.shift(_fields, shiftTotalMinutes); - case DateEditAction.clear: - return DateModifier.clear(_fields); + case DateEditAction.remove: + return DateModifier.remove(_fields); } } diff --git a/lib/widgets/dialogs/entry_editors/edit_entry_rating_dialog.dart b/lib/widgets/dialogs/entry_editors/edit_entry_rating_dialog.dart new file mode 100644 index 000000000..5ab85c56f --- /dev/null +++ b/lib/widgets/dialogs/entry_editors/edit_entry_rating_dialog.dart @@ -0,0 +1,136 @@ +import 'package:aves/model/entry.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:aves/widgets/dialogs/aves_dialog.dart'; +import 'package:flutter/material.dart'; + +class EditEntryRatingDialog extends StatefulWidget { + final AvesEntry entry; + + const EditEntryRatingDialog({ + Key? key, + required this.entry, + }) : super(key: key); + + @override + _EditEntryRatingDialogState createState() => _EditEntryRatingDialogState(); +} + +class _EditEntryRatingDialogState extends State { + late _RatingAction _action; + late int _rating; + + @override + void initState() { + super.initState(); + final entryRating = widget.entry.rating; + switch (entryRating) { + case -1: + _action = _RatingAction.rejected; + _rating = 0; + break; + case 0: + _action = _RatingAction.unrated; + _rating = 0; + break; + default: + _action = _RatingAction.set; + _rating = entryRating; + } + } + + @override + Widget build(BuildContext context) { + return MediaQueryDataProvider( + child: TooltipTheme( + data: TooltipTheme.of(context).copyWith( + preferBelow: false, + ), + child: Builder(builder: (context) { + final l10n = context.l10n; + + return AvesDialog( + title: l10n.editEntryRatingDialogTitle, + scrollableContent: [ + RadioListTile<_RatingAction>( + value: _RatingAction.set, + groupValue: _action, + onChanged: (v) => setState(() => _action = v!), + title: Wrap( + children: [ + ...List.generate(5, (i) { + final thisRating = i + 1; + return GestureDetector( + onTap: () => setState(() { + _action = _RatingAction.set; + _rating = thisRating; + }), + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.all(4), + child: Icon( + _rating < thisRating ? AIcons.rating : AIcons.ratingFull, + color: _rating < thisRating ? Colors.grey : Colors.amber, + ), + ), + ); + }) + ], + ), + ), + RadioListTile<_RatingAction>( + value: _RatingAction.rejected, + groupValue: _action, + onChanged: (v) => setState(() { + _action = v!; + _rating = 0; + }), + title: Text(l10n.filterRatingRejectedLabel), + ), + RadioListTile<_RatingAction>( + value: _RatingAction.unrated, + groupValue: _action, + onChanged: (v) => setState(() { + _action = v!; + _rating = 0; + }), + title: Text(l10n.filterRatingUnratedLabel), + ), + ], + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), + ), + TextButton( + onPressed: isValid ? () => _submit(context) : null, + child: Text(l10n.applyButtonLabel), + ), + ], + ); + }), + ), + ); + } + + bool get isValid => !(_action == _RatingAction.set && _rating <= 0); + + void _submit(BuildContext context) { + late int entryRating; + switch (_action) { + case _RatingAction.set: + entryRating = _rating; + break; + case _RatingAction.rejected: + entryRating = -1; + break; + case _RatingAction.unrated: + entryRating = 0; + break; + } + Navigator.pop(context, entryRating); + } +} + +enum _RatingAction { set, rejected, unrated } diff --git a/lib/widgets/settings/navigation/drawer_tab_albums.dart b/lib/widgets/settings/navigation/drawer_tab_albums.dart index b7d42d4f8..a0dbad668 100644 --- a/lib/widgets/settings/navigation/drawer_tab_albums.dart +++ b/lib/widgets/settings/navigation/drawer_tab_albums.dart @@ -43,7 +43,7 @@ class _DrawerAlbumTabState extends State { onPressed: () { setState(() => widget.items.remove(album)); }, - tooltip: context.l10n.removeTooltip, + tooltip: context.l10n.actionRemove, ), ); }, diff --git a/lib/widgets/settings/privacy/hidden_items.dart b/lib/widgets/settings/privacy/hidden_items.dart index 48e5a6d19..0c1b0f17b 100644 --- a/lib/widgets/settings/privacy/hidden_items.dart +++ b/lib/widgets/settings/privacy/hidden_items.dart @@ -154,7 +154,7 @@ class _HiddenPaths extends StatelessWidget { onPressed: () { context.read().changeFilterVisibility({pathFilter}, true); }, - tooltip: context.l10n.removeTooltip, + tooltip: context.l10n.actionRemove, ), )), ], diff --git a/lib/widgets/viewer/action/entry_info_action_delegate.dart b/lib/widgets/viewer/action/entry_info_action_delegate.dart index c5ea64f74..1c20cba58 100644 --- a/lib/widgets/viewer/action/entry_info_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_info_action_delegate.dart @@ -3,7 +3,7 @@ import 'dart:async'; 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/entry_metadata_edition.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'; @@ -25,6 +25,7 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi switch (action) { // general case EntryInfoAction.editDate: + case EntryInfoAction.editRating: case EntryInfoAction.editTags: case EntryInfoAction.removeMetadata: return true; @@ -39,6 +40,8 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi // general case EntryInfoAction.editDate: return entry.canEditDate; + case EntryInfoAction.editRating: + return entry.canEditRating; case EntryInfoAction.editTags: return entry.canEditTags; case EntryInfoAction.removeMetadata: @@ -56,6 +59,9 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi case EntryInfoAction.editDate: await _editDate(context); break; + case EntryInfoAction.editRating: + await _editRating(context); + break; case EntryInfoAction.editTags: await _editTags(context); break; @@ -77,6 +83,13 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi await edit(context, () => entry.editDate(modifier)); } + Future _editRating(BuildContext context) async { + final rating = await selectRating(context, {entry}); + if (rating == null) return; + + await edit(context, () => entry.editRating(rating)); + } + Future _editTags(BuildContext context) async { final newTagsByEntry = await selectTags(context, {entry}); if (newTagsByEntry == null) return; diff --git a/lib/widgets/viewer/info/metadata/xmp_namespaces.dart b/lib/widgets/viewer/info/metadata/xmp_namespaces.dart index 317ec7e5d..d4154c64e 100644 --- a/lib/widgets/viewer/info/metadata/xmp_namespaces.dart +++ b/lib/widgets/viewer/info/metadata/xmp_namespaces.dart @@ -1,7 +1,7 @@ import 'package:aves/ref/brand_colors.dart'; -import 'package:aves/ref/xmp.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/utils/string_utils.dart'; +import 'package:aves/utils/xmp_utils.dart'; import 'package:aves/widgets/common/identity/highlight_title.dart'; import 'package:aves/widgets/viewer/info/common.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_ns/crs.dart'; @@ -66,7 +66,55 @@ class XmpNamespace extends Equatable { } } - String get displayTitle => XMP.namespaces[namespace] ?? namespace; + // cf https://exiftool.org/TagNames/XMP.html + static const Map nsTitles = { + 'acdsee': 'ACDSee', + 'adsml-at': 'AdsML', + 'aux': 'Exif Aux', + 'avm': 'Astronomy Visualization', + 'Camera': 'Camera', + 'cc': 'Creative Commons', + 'crd': 'Camera Raw Defaults', + 'creatorAtom': 'After Effects', + 'crs': 'Camera Raw Settings', + 'dc': 'Dublin Core', + 'drone-dji': 'DJI Drone', + 'dwc': 'Darwin Core', + 'exif': 'Exif', + 'exifEX': 'Exif Ex', + 'GettyImagesGIFT': 'Getty Images', + 'GAudio': 'Google Audio', + 'GDepth': 'Google Depth', + 'GImage': 'Google Image', + 'GIMP': 'GIMP', + 'GCamera': 'Google Camera', + 'GCreations': 'Google Creations', + 'GFocus': 'Google Focus', + 'GPano': 'Google Panorama', + 'illustrator': 'Illustrator', + 'Iptc4xmpCore': 'IPTC Core', + 'Iptc4xmpExt': 'IPTC Extension', + 'lr': 'Lightroom', + 'MicrosoftPhoto': 'Microsoft Photo', + 'mwg-rs': 'Regions', + 'panorama': 'Panorama', + 'PanoStudioXMP': 'PanoramaStudio', + 'pdf': 'PDF', + 'pdfx': 'PDF/X', + 'photomechanic': 'Photo Mechanic', + 'photoshop': 'Photoshop', + 'plus': 'PLUS', + 'pmtm': 'Photomatix', + 'tiff': 'TIFF', + 'xmp': 'Basic', + 'xmpBJ': 'Basic Job Ticket', + 'xmpDM': 'Dynamic Media', + 'xmpMM': 'Media Management', + 'xmpRights': 'Rights Management', + 'xmpTPg': 'Paged-Text', + }; + + String get displayTitle => nsTitles[namespace] ?? namespace; Map get buildProps => rawProps; diff --git a/lib/widgets/viewer/info/metadata/xmp_tile.dart b/lib/widgets/viewer/info/metadata/xmp_tile.dart index 7c738e002..01dd5a393 100644 --- a/lib/widgets/viewer/info/metadata/xmp_tile.dart +++ b/lib/widgets/viewer/info/metadata/xmp_tile.dart @@ -1,8 +1,8 @@ import 'dart:collection'; import 'package:aves/model/entry.dart'; -import 'package:aves/ref/xmp.dart'; import 'package:aves/utils/color_utils.dart'; +import 'package:aves/utils/xmp_utils.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart'; import 'package:collection/collection.dart'; diff --git a/test/utils/xmp_utils_test.dart b/test/utils/xmp_utils_test.dart new file mode 100644 index 000000000..24dcc403f --- /dev/null +++ b/test/utils/xmp_utils_test.dart @@ -0,0 +1,390 @@ +import 'package:aves/model/entry_metadata_edition.dart'; +import 'package:aves/utils/xmp_utils.dart'; +import 'package:test/test.dart'; +import 'package:xml/xml.dart'; + +void main() { + const toolkit = 'test-toolkit'; + + String? _toExpect(String? xmpString) => xmpString != null + ? XmlDocument.parse(xmpString).toXmlString( + pretty: true, + sortAttributes: (a, b) => a.name.qualified.compareTo(b.name.qualified), + ) + : null; + + const inMultiDescriptionRatings = ''' + + + + 5 + + + 99 + + + +'''; + const inRatingAttribute = ''' + + + + + +'''; + const inRatingElement = ''' + + + + 5 + + + +'''; + const inSubjects = ''' + + + + + + the king + + + + + +'''; + const inSubjectsCreator = ''' + + + + + + c + + + + + a + b + + + + + +'''; + + test('Set tags without existing XMP', () async { + final modifyDate = DateTime.now(); + final xmpDate = XMP.toXmpDate(modifyDate); + + expect( + _toExpect(await XMP.edit( + null, + () async => toolkit, + (descriptions) => ExtraAvesEntryMetadataEdition.editTagsXmp(descriptions, {'one', 'two'}), + modifyDate: modifyDate, + )), + _toExpect(''' + + + + + + one + two + + + + + +''')); + }); + + test('Set tags to XMP with ratings (multiple descriptions)', () async { + final modifyDate = DateTime.now(); + final xmpDate = XMP.toXmpDate(modifyDate); + + expect( + _toExpect(await XMP.edit( + inMultiDescriptionRatings, + () async => toolkit, + (descriptions) => ExtraAvesEntryMetadataEdition.editTagsXmp(descriptions, {'one', 'two'}), + modifyDate: modifyDate, + )), + _toExpect(''' + + + + 5 + + + one + two + + + + + 99 + + + +''')); + }); + + test('Set tags to XMP with subjects only', () async { + final modifyDate = DateTime.now(); + final xmpDate = XMP.toXmpDate(modifyDate); + + expect( + _toExpect(await XMP.edit( + inSubjects, + () async => toolkit, + (descriptions) => ExtraAvesEntryMetadataEdition.editTagsXmp(descriptions, {'one', 'two'}), + modifyDate: modifyDate, + )), + _toExpect(''' + + + + + + one + two + + + + + +''')); + }); + + test('Remove tags from XMP with subjects only', () async { + expect( + _toExpect(await XMP.edit( + inSubjects, + () async => toolkit, + (descriptions) => ExtraAvesEntryMetadataEdition.editTagsXmp(descriptions, {}), + )), + _toExpect(null)); + }); + + test('Remove tags from XMP with subjects and creator', () async { + final modifyDate = DateTime.now(); + final xmpDate = XMP.toXmpDate(modifyDate); + + expect( + _toExpect(await XMP.edit( + inSubjectsCreator, + () async => toolkit, + (descriptions) => ExtraAvesEntryMetadataEdition.editTagsXmp(descriptions, {}), + modifyDate: modifyDate, + )), + _toExpect(''' + + + + + + c + + + + + +''')); + }); + + test('Set rating without existing XMP', () async { + final modifyDate = DateTime.now(); + final xmpDate = XMP.toXmpDate(modifyDate); + + expect( + _toExpect(await XMP.edit( + null, + () async => toolkit, + (descriptions) => ExtraAvesEntryMetadataEdition.editRatingXmp(descriptions, 3), + modifyDate: modifyDate, + )), + _toExpect(''' + + + + + +''')); + }); + + test('Set rating to XMP with ratings (multiple descriptions)', () async { + final modifyDate = DateTime.now(); + final xmpDate = XMP.toXmpDate(modifyDate); + + expect( + _toExpect(await XMP.edit( + inMultiDescriptionRatings, + () async => toolkit, + (descriptions) => ExtraAvesEntryMetadataEdition.editRatingXmp(descriptions, 3), + modifyDate: modifyDate, + )), + _toExpect(''' + + + + + +''')); + }); + + test('Set rating to XMP with rating attribute', () async { + final modifyDate = DateTime.now(); + final xmpDate = XMP.toXmpDate(modifyDate); + + expect( + _toExpect(await XMP.edit( + inRatingAttribute, + () async => toolkit, + (descriptions) => ExtraAvesEntryMetadataEdition.editRatingXmp(descriptions, 3), + modifyDate: modifyDate, + )), + _toExpect(''' + + + + + +''')); + }); + + test('Set rating to XMP with rating element', () async { + final modifyDate = DateTime.now(); + final xmpDate = XMP.toXmpDate(modifyDate); + + expect( + _toExpect(await XMP.edit( + inRatingElement, + () async => toolkit, + (descriptions) => ExtraAvesEntryMetadataEdition.editRatingXmp(descriptions, 3), + modifyDate: modifyDate, + )), + _toExpect(''' + + + + + +''')); + }); + + test('Set rating to XMP with subjects only', () async { + final modifyDate = DateTime.now(); + final xmpDate = XMP.toXmpDate(modifyDate); + + expect( + _toExpect(await XMP.edit( + inSubjects, + () async => toolkit, + (descriptions) => ExtraAvesEntryMetadataEdition.editRatingXmp(descriptions, 3), + modifyDate: modifyDate, + )), + _toExpect(''' + + + + + + the king + + + + + +''')); + }); + + test('Remove rating from XMP with subjects only', () async { + final modifyDate = DateTime.now(); + final xmpDate = XMP.toXmpDate(modifyDate); + + expect( + _toExpect(await XMP.edit( + inSubjects, + () async => toolkit, + (descriptions) => ExtraAvesEntryMetadataEdition.editRatingXmp(descriptions, null), + modifyDate: modifyDate, + )), + _toExpect(''' + + + + + + the king + + + + + +''')); + }); + + test('Remove rating from XMP with ratings (multiple descriptions)', () async { + final modifyDate = DateTime.now(); + + expect( + _toExpect(await XMP.edit( + inMultiDescriptionRatings, + () async => toolkit, + (descriptions) => ExtraAvesEntryMetadataEdition.editRatingXmp(descriptions, null), + modifyDate: modifyDate, + )), + _toExpect(null)); + }); +} diff --git a/untranslated.json b/untranslated.json index bc3e1d959..e9cb2d571 100644 --- a/untranslated.json +++ b/untranslated.json @@ -1,5 +1,6 @@ { "de": [ + "entryInfoActionEditRating", "filterRatingUnratedLabel", "filterRatingRejectedLabel", "missingSystemFilePickerDialogTitle", @@ -8,22 +9,28 @@ "editEntryDateDialogCopyField", "editEntryDateDialogSourceFileModifiedDate", "editEntryDateDialogTargetFieldsHeader", + "editEntryRatingDialogTitle", "collectionSortRating", "searchSectionRating", "settingsThumbnailShowRating" ], "fr": [ + "entryInfoActionEditRating", "missingSystemFilePickerDialogTitle", - "missingSystemFilePickerDialogMessage" + "missingSystemFilePickerDialogMessage", + "editEntryRatingDialogTitle" ], "ko": [ + "entryInfoActionEditRating", "missingSystemFilePickerDialogTitle", - "missingSystemFilePickerDialogMessage" + "missingSystemFilePickerDialogMessage", + "editEntryRatingDialogTitle" ], "ru": [ + "entryInfoActionEditRating", "filterRatingUnratedLabel", "filterRatingRejectedLabel", "missingSystemFilePickerDialogTitle", @@ -32,6 +39,7 @@ "editEntryDateDialogCopyField", "editEntryDateDialogSourceFileModifiedDate", "editEntryDateDialogTargetFieldsHeader", + "editEntryRatingDialogTitle", "collectionSortRating", "searchSectionRating", "settingsThumbnailShowRating" From a3e18d3b3a42d2a5fc348076dcf036cfb97ea7a0 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Tue, 4 Jan 2022 11:37:49 +0900 Subject: [PATCH 14/28] #146 editing ratings/tags automatically sets a metadata date via XMP for GIF --- lib/model/entry.dart | 115 +------------ lib/model/entry_metadata_edition.dart | 162 +++++++++++++++++- lib/utils/xmp_utils.dart | 1 + .../viewer/action/entry_action_delegate.dart | 1 + 4 files changed, 164 insertions(+), 115 deletions(-) diff --git a/lib/model/entry.dart b/lib/model/entry.dart index d7650828d..1f419782e 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -7,8 +7,6 @@ import 'package:aves/model/entry_cache.dart'; import 'package:aves/model/favourites.dart'; import 'package:aves/model/metadata/address.dart'; import 'package:aves/model/metadata/catalog.dart'; -import 'package:aves/model/metadata/date_modifier.dart'; -import 'package:aves/model/metadata/enums.dart'; import 'package:aves/model/multipage.dart'; import 'package:aves/model/video/metadata.dart'; import 'package:aves/ref/mime_types.dart'; @@ -18,7 +16,6 @@ import 'package:aves/services/geocoding_service.dart'; import 'package:aves/services/metadata/svg_metadata_service.dart'; import 'package:aves/theme/format.dart'; import 'package:aves/utils/change_notifier.dart'; -import 'package:aves/utils/time_utils.dart'; import 'package:collection/collection.dart'; import 'package:country_code/country_code.dart'; import 'package:flutter/foundation.dart'; @@ -481,14 +478,14 @@ class AvesEntry { 'width': size.width.ceil(), 'height': size.height.ceil(), }; - await _applyNewFields(fields, persist: persist); + await applyNewFields(fields, persist: persist); } catalogMetadata = CatalogMetadata(contentId: contentId); } else { if (isVideo && (!isSized || durationMillis == 0)) { // exotic video that is not sized during loading final fields = await VideoMetadataFormatter.getLoadingMetadata(this); - await _applyNewFields(fields, persist: persist); + await applyNewFields(fields, persist: persist); } catalogMetadata = await metadataFetchService.getCatalogMetadata(this, background: background); @@ -585,7 +582,7 @@ class AvesEntry { }.whereNotNull().where((v) => v.isNotEmpty).join(', '); } - Future _applyNewFields(Map newFields, {required bool persist}) async { + Future applyNewFields(Map newFields, {required bool persist}) async { final oldDateModifiedSecs = this.dateModifiedSecs; final oldRotationDegrees = this.rotationDegrees; final oldIsFlipped = this.isFlipped; @@ -646,116 +643,12 @@ class AvesEntry { final updatedEntry = await mediaFileService.getEntry(uri, mimeType); if (updatedEntry != null) { - await _applyNewFields(updatedEntry.toMap(), persist: persist); + await applyNewFields(updatedEntry.toMap(), persist: persist); } await catalog(background: background, force: dataTypes.contains(EntryDataType.catalog), persist: persist); await locate(background: background, force: dataTypes.contains(EntryDataType.address), geocoderLocale: geocoderLocale); } - Future> _changeOrientation(Future> Function() apply) async { - final dataTypes = await setMetadataDateIfMissing(); - - 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> rotate({required bool clockwise}) { - return _changeOrientation(() => metadataEditService.rotate(this, clockwise: clockwise)); - } - - Future> flip() { - return _changeOrientation(() => metadataEditService.flip(this)); - } - - Future> editDate(DateModifier modifier) async { - switch (modifier.action) { - case DateEditAction.copyField: - DateTime? date; - final source = modifier.copyFieldSource; - if (source != null) { - switch (source) { - case DateFieldSource.fileModifiedDate: - try { - date = path != null ? await File(path!).lastModified() : null; - } on FileSystemException catch (_) {} - break; - default: - date = await metadataFetchService.getDate(this, source.toMetadataField()!); - break; - } - } - if (date != null) { - modifier = DateModifier.setCustom(modifier.fields, date); - } else { - await reportService.recordError('failed to get date for modifier=$modifier, uri=$uri', null); - return {}; - } - break; - case DateEditAction.extractFromTitle: - final date = parseUnknownDateFormat(bestTitle); - if (date != null) { - modifier = DateModifier.setCustom(modifier.fields, date); - } else { - await reportService.recordError('failed to get date for modifier=$modifier, uri=$uri', null); - return {}; - } - break; - case DateEditAction.setCustom: - case DateEditAction.shift: - case DateEditAction.remove: - break; - } - final newFields = await metadataEditService.editDate(this, modifier); - return newFields.isEmpty - ? {} - : { - EntryDataType.basic, - EntryDataType.catalog, - }; - } - - // 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 {}; - - if (canEditExif) { - return await editDate(DateModifier.copyField( - const {MetadataField.exifDateOriginal}, - DateFieldSource.fileModifiedDate, - )); - } - // TODO TLAD [metadata] set XMP / xmp:CreateDate - return {}; - } - - Future> removeMetadata(Set types) async { - final newFields = await metadataEditService.removeTypes(this, types); - return newFields.isEmpty - ? {} - : { - EntryDataType.basic, - EntryDataType.catalog, - EntryDataType.address, - }; - } - Future delete() { final completer = Completer(); mediaFileService.delete(entries: {this}).listen( diff --git a/lib/model/entry_metadata_edition.dart b/lib/model/entry_metadata_edition.dart index e9428a7b8..a729ec41c 100644 --- a/lib/model/entry_metadata_edition.dart +++ b/lib/model/entry_metadata_edition.dart @@ -1,23 +1,118 @@ import 'dart:convert'; +import 'dart:io'; 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/iptc.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/services/metadata/xmp.dart'; +import 'package:aves/utils/time_utils.dart'; import 'package:aves/utils/xmp_utils.dart'; import 'package:flutter/foundation.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:xml/xml.dart'; extension ExtraAvesEntryMetadataEdition on AvesEntry { + Future> editDate(DateModifier modifier) async { + switch (modifier.action) { + case DateEditAction.copyField: + DateTime? date; + final source = modifier.copyFieldSource; + if (source != null) { + switch (source) { + case DateFieldSource.fileModifiedDate: + try { + date = path != null ? await File(path!).lastModified() : null; + } on FileSystemException catch (_) {} + break; + default: + date = await metadataFetchService.getDate(this, source.toMetadataField()!); + break; + } + } + if (date != null) { + modifier = DateModifier.setCustom(modifier.fields, date); + } else { + await reportService.recordError('failed to get date for modifier=$modifier, uri=$uri', null); + return {}; + } + break; + case DateEditAction.extractFromTitle: + final date = parseUnknownDateFormat(bestTitle); + if (date != null) { + modifier = DateModifier.setCustom(modifier.fields, date); + } else { + await reportService.recordError('failed to get date for modifier=$modifier, uri=$uri', null); + return {}; + } + break; + case DateEditAction.setCustom: + case DateEditAction.shift: + case DateEditAction.remove: + break; + } + final newFields = await metadataEditService.editDate(this, modifier); + return newFields.isEmpty + ? {} + : { + EntryDataType.basic, + EntryDataType.catalog, + }; + } + + Future> _changeOrientation(Future> Function() apply) async { + final Set dataTypes = {}; + + // when editing a file that has no metadata date, + // we will set one, using the file modified date, if any + var missingDate = await _getMissingMetadataDate(); + if (missingDate != null && canEditExif) { + dataTypes.addAll(await editDate(DateModifier.setCustom( + const {MetadataField.exifDateOriginal}, + missingDate, + ))); + missingDate = null; + } + + 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> rotate({required bool clockwise}) { + return _changeOrientation(() => metadataEditService.rotate(this, clockwise: clockwise)); + } + + Future> flip() { + return _changeOrientation(() => metadataEditService.flip(this)); + } + // write: // - IPTC / keywords, if IPTC exists // - XMP / dc:subject Future> editTags(Set tags) async { + final Set dataTypes = {}; final Map metadata = {}; - final dataTypes = await setMetadataDateIfMissing(); + // when editing a file that has no metadata date, + // we will set one, using the file modified date, if any + var missingDate = await _getMissingMetadataDate(); + if (missingDate != null && canEditExif) { + dataTypes.addAll(await editDate(DateModifier.setCustom( + const {MetadataField.exifDateOriginal}, + missingDate, + ))); + missingDate = null; + } if (canEditIptc) { final iptc = await metadataFetchService.getIptc(this); @@ -28,7 +123,13 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { } if (canEditXmp) { - metadata[MetadataType.xmp] = await _editXmp((descriptions) => editTagsXmp(descriptions, tags)); + metadata[MetadataType.xmp] = await _editXmp((descriptions) { + if (missingDate != null) { + editDateXmp(descriptions, missingDate!); + missingDate = null; + } + editTagsXmp(descriptions, tags); + }); } final newFields = await metadataEditService.editMetadata(this, metadata); @@ -46,12 +147,28 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { // - Exif / Rating // - Exif / RatingPercent Future> editRating(int? rating) async { + final Set dataTypes = {}; final Map metadata = {}; - final dataTypes = await setMetadataDateIfMissing(); + // when editing a file that has no metadata date, + // we will set one, using the file modified date, if any + var missingDate = await _getMissingMetadataDate(); + if (missingDate != null && canEditExif) { + dataTypes.addAll(await editDate(DateModifier.setCustom( + const {MetadataField.exifDateOriginal}, + missingDate, + ))); + missingDate = null; + } if (canEditXmp) { - metadata[MetadataType.xmp] = await _editXmp((descriptions) => editRatingXmp(descriptions, rating)); + metadata[MetadataType.xmp] = await _editXmp((descriptions) { + if (missingDate != null) { + editDateXmp(descriptions, missingDate!); + missingDate = null; + } + editRatingXmp(descriptions, rating); + }); } final newFields = await metadataEditService.editMetadata(this, metadata); @@ -61,6 +178,28 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { return dataTypes; } + Future> removeMetadata(Set types) async { + final newFields = await metadataEditService.removeTypes(this, types); + return newFields.isEmpty + ? {} + : { + EntryDataType.basic, + EntryDataType.catalog, + EntryDataType.address, + }; + } + + @visibleForTesting + static void editDateXmp(List descriptions, DateTime date) { + XMP.setAttribute( + descriptions, + XMP.xmpCreateDate, + XMP.toXmpDate(date), + namespace: Namespaces.xmp, + strat: XmpEditStrategy.always, + ); + } + @visibleForTesting static void editTagsIptc(List> iptc, Set tags) { iptc.removeWhere((v) => v['record'] == IPTC.applicationRecord && v['tag'] == IPTC.keywordsTag); @@ -102,6 +241,21 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { // convenience + Future _getMissingMetadataDate() async { + if (path == null) return null; + + // 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 null; + + try { + return await File(path!).lastModified(); + } on FileSystemException catch (_) {} + } + Future> _editXmp(void Function(List descriptions) apply) async { final xmp = await metadataFetchService.getXmp(this); final xmpString = xmp?.xmpString; diff --git a/lib/utils/xmp_utils.dart b/lib/utils/xmp_utils.dart index 798bb8771..c7fbe3173 100644 --- a/lib/utils/xmp_utils.dart +++ b/lib/utils/xmp_utils.dart @@ -37,6 +37,7 @@ class XMP { // attributes static const xXmptk = 'xmptk'; static const rdfAbout = 'about'; + static const xmpCreateDate = 'CreateDate'; static const xmpMetadataDate = 'MetadataDate'; static const xmpModifyDate = 'ModifyDate'; static const xmpNoteHasExtendedXMP = 'HasExtendedXMP'; diff --git a/lib/widgets/viewer/action/entry_action_delegate.dart b/lib/widgets/viewer/action/entry_action_delegate.dart index cec470c8e..3c6cff31f 100644 --- a/lib/widgets/viewer/action/entry_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_action_delegate.dart @@ -6,6 +6,7 @@ import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/actions/move_type.dart'; import 'package:aves/model/device.dart'; import 'package:aves/model/entry.dart'; +import 'package:aves/model/entry_metadata_edition.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/highlight.dart'; import 'package:aves/model/source/collection_lens.dart'; From b94097bda75978556d5c4ed6c9f6cdef16b0e4f5 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Tue, 4 Jan 2022 18:33:02 +0900 Subject: [PATCH 15/28] info: edit date for GIF --- lib/l10n/app_en.arb | 2 +- lib/model/entry.dart | 2 +- lib/model/entry_metadata_edition.dart | 197 ++++++++++-------- lib/model/metadata/date_modifier.dart | 1 + lib/model/metadata/enums.dart | 17 +- .../metadata/metadata_edit_service.dart | 7 +- lib/utils/xmp_utils.dart | 15 ++ .../entry_editors/edit_entry_date_dialog.dart | 23 +- test/utils/xmp_utils_test.dart | 15 ++ 9 files changed, 180 insertions(+), 99 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b90b87835..b34ff0cf5 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -277,7 +277,7 @@ "editEntryDateDialogTitle": "Date & Time", "editEntryDateDialogSetCustom": "Set custom date", - "editEntryDateDialogCopyField": "Set from other date", + "editEntryDateDialogCopyField": "Copy from other date", "editEntryDateDialogExtractFromTitle": "Extract from title", "editEntryDateDialogShift": "Shift", "editEntryDateDialogSourceFileModifiedDate": "File modified date", diff --git a/lib/model/entry.dart b/lib/model/entry.dart index 1f419782e..2e071117e 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -234,7 +234,7 @@ class AvesEntry { bool get canEdit => path != null; - bool get canEditDate => canEdit && canEditExif; + bool get canEditDate => canEdit && (canEditExif || canEditXmp); bool get canEditRating => canEdit && canEditXmp; diff --git a/lib/model/entry_metadata_edition.dart b/lib/model/entry_metadata_edition.dart index a729ec41c..a69300c39 100644 --- a/lib/model/entry_metadata_edition.dart +++ b/lib/model/entry_metadata_edition.dart @@ -14,66 +14,69 @@ import 'package:package_info_plus/package_info_plus.dart'; import 'package:xml/xml.dart'; extension ExtraAvesEntryMetadataEdition on AvesEntry { - Future> editDate(DateModifier modifier) async { - switch (modifier.action) { - case DateEditAction.copyField: - DateTime? date; - final source = modifier.copyFieldSource; - if (source != null) { - switch (source) { - case DateFieldSource.fileModifiedDate: - try { - date = path != null ? await File(path!).lastModified() : null; - } on FileSystemException catch (_) {} + Future> editDate(DateModifier userModifier) async { + final Set dataTypes = {}; + + final appliedModifier = await _applyDateModifierToEntry(userModifier); + if (appliedModifier == null) { + await reportService.recordError('failed to get date for modifier=$userModifier, uri=$uri', null); + return {}; + } + + if (canEditExif && appliedModifier.fields.any((v) => v.type == MetadataType.exif)) { + final newFields = await metadataEditService.editExifDate(this, appliedModifier); + if (newFields.isNotEmpty) { + dataTypes.addAll({ + EntryDataType.basic, + EntryDataType.catalog, + }); + } + } + + if (canEditXmp && appliedModifier.fields.any((v) => v.type == MetadataType.xmp)) { + final metadata = { + MetadataType.xmp: await _editXmp((descriptions) { + switch (appliedModifier.action) { + case DateEditAction.setCustom: + case DateEditAction.copyField: + case DateEditAction.extractFromTitle: + editCreateDateXmp(descriptions, appliedModifier.setDateTime); break; - default: - date = await metadataFetchService.getDate(this, source.toMetadataField()!); + case DateEditAction.shift: + final xmpDate = XMP.getString(descriptions, XMP.xmpCreateDate, namespace: Namespaces.xmp); + if (xmpDate != null) { + final date = DateTime.tryParse(xmpDate); + if (date != null) { + // TODO TLAD [date] DateTime.tryParse converts to UTC time, losing the time zone offset + final shiftedDate = date.add(Duration(minutes: appliedModifier.shiftMinutes!)); + editCreateDateXmp(descriptions, shiftedDate); + } else { + reportService.recordError('failed to parse XMP date=$xmpDate', null); + } + } + break; + case DateEditAction.remove: + editCreateDateXmp(descriptions, null); break; } - } - if (date != null) { - modifier = DateModifier.setCustom(modifier.fields, date); - } else { - await reportService.recordError('failed to get date for modifier=$modifier, uri=$uri', null); - return {}; - } - break; - case DateEditAction.extractFromTitle: - final date = parseUnknownDateFormat(bestTitle); - if (date != null) { - modifier = DateModifier.setCustom(modifier.fields, date); - } else { - await reportService.recordError('failed to get date for modifier=$modifier, uri=$uri', null); - return {}; - } - break; - case DateEditAction.setCustom: - case DateEditAction.shift: - case DateEditAction.remove: - break; + }), + }; + final newFields = await metadataEditService.editMetadata(this, metadata); + if (newFields.isNotEmpty) { + dataTypes.addAll({ + EntryDataType.basic, + EntryDataType.catalog, + }); + } } - final newFields = await metadataEditService.editDate(this, modifier); - return newFields.isEmpty - ? {} - : { - EntryDataType.basic, - EntryDataType.catalog, - }; + + return dataTypes; } Future> _changeOrientation(Future> Function() apply) async { final Set dataTypes = {}; - // when editing a file that has no metadata date, - // we will set one, using the file modified date, if any - var missingDate = await _getMissingMetadataDate(); - if (missingDate != null && canEditExif) { - dataTypes.addAll(await editDate(DateModifier.setCustom( - const {MetadataField.exifDateOriginal}, - missingDate, - ))); - missingDate = null; - } + await _missingDateCheckAndExifEdit(dataTypes); final newFields = await apply(); // applying fields is only useful for a smoother visual change, @@ -103,16 +106,7 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { final Set dataTypes = {}; final Map metadata = {}; - // when editing a file that has no metadata date, - // we will set one, using the file modified date, if any - var missingDate = await _getMissingMetadataDate(); - if (missingDate != null && canEditExif) { - dataTypes.addAll(await editDate(DateModifier.setCustom( - const {MetadataField.exifDateOriginal}, - missingDate, - ))); - missingDate = null; - } + final missingDate = await _missingDateCheckAndExifEdit(dataTypes); if (canEditIptc) { final iptc = await metadataFetchService.getIptc(this); @@ -125,8 +119,7 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { if (canEditXmp) { metadata[MetadataType.xmp] = await _editXmp((descriptions) { if (missingDate != null) { - editDateXmp(descriptions, missingDate!); - missingDate = null; + editCreateDateXmp(descriptions, missingDate); } editTagsXmp(descriptions, tags); }); @@ -150,22 +143,12 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { final Set dataTypes = {}; final Map metadata = {}; - // when editing a file that has no metadata date, - // we will set one, using the file modified date, if any - var missingDate = await _getMissingMetadataDate(); - if (missingDate != null && canEditExif) { - dataTypes.addAll(await editDate(DateModifier.setCustom( - const {MetadataField.exifDateOriginal}, - missingDate, - ))); - missingDate = null; - } + final missingDate = await _missingDateCheckAndExifEdit(dataTypes); if (canEditXmp) { metadata[MetadataType.xmp] = await _editXmp((descriptions) { if (missingDate != null) { - editDateXmp(descriptions, missingDate!); - missingDate = null; + editCreateDateXmp(descriptions, missingDate); } editRatingXmp(descriptions, rating); }); @@ -190,11 +173,11 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { } @visibleForTesting - static void editDateXmp(List descriptions, DateTime date) { + static void editCreateDateXmp(List descriptions, DateTime? date) { XMP.setAttribute( descriptions, XMP.xmpCreateDate, - XMP.toXmpDate(date), + date != null ? XMP.toXmpDate(date) : null, namespace: Namespaces.xmp, strat: XmpEditStrategy.always, ); @@ -241,19 +224,69 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { // convenience - Future _getMissingMetadataDate() async { + // This method checks whether the item already has a metadata date, + // and adds a date (the file modified date) via Exif if possible. + // It returns a date if the caller needs to add it via other metadata types (e.g. XMP). + Future _missingDateCheckAndExifEdit(Set dataTypes) async { if (path == null) return null; // 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 null; + final dateMillis = catalogMetadata?.dateMillis; + if (dateMillis != null && dateMillis > 0) return null; + late DateTime date; try { - return await File(path!).lastModified(); - } on FileSystemException catch (_) {} + date = await File(path!).lastModified(); + } on FileSystemException catch (_) { + return null; + } + + if (canEditExif) { + final newFields = await metadataEditService.editExifDate(this, DateModifier.setCustom(const {MetadataField.exifDateOriginal}, date)); + if (newFields.isNotEmpty) { + dataTypes.addAll({ + EntryDataType.basic, + EntryDataType.catalog, + }); + return null; + } + } + + return date; + } + + Future _applyDateModifierToEntry(DateModifier modifier) async { + Set mainMetadataDate() => {canEditExif ? MetadataField.exifDateOriginal : MetadataField.xmpCreateDate}; + + switch (modifier.action) { + case DateEditAction.copyField: + DateTime? date; + final source = modifier.copyFieldSource; + if (source != null) { + switch (source) { + case DateFieldSource.fileModifiedDate: + try { + date = path != null ? await File(path!).lastModified() : null; + } on FileSystemException catch (_) {} + break; + default: + date = await metadataFetchService.getDate(this, source.toMetadataField()!); + break; + } + } + return date != null ? DateModifier.setCustom(mainMetadataDate(), date) : null; + case DateEditAction.extractFromTitle: + final date = parseUnknownDateFormat(bestTitle); + return date != null ? DateModifier.setCustom(mainMetadataDate(), date) : null; + case DateEditAction.setCustom: + return DateModifier.setCustom(mainMetadataDate(), modifier.setDateTime!); + case DateEditAction.shift: + case DateEditAction.remove: + return modifier; + } } Future> _editXmp(void Function(List descriptions) apply) async { diff --git a/lib/model/metadata/date_modifier.dart b/lib/model/metadata/date_modifier.dart index 96ef5ffc0..86c312f62 100644 --- a/lib/model/metadata/date_modifier.dart +++ b/lib/model/metadata/date_modifier.dart @@ -9,6 +9,7 @@ class DateModifier { MetadataField.exifDateOriginal, MetadataField.exifDateDigitized, MetadataField.exifGpsDate, + MetadataField.xmpCreateDate, ]; final DateEditAction action; diff --git a/lib/model/metadata/enums.dart b/lib/model/metadata/enums.dart index 768a87d16..530dc932b 100644 --- a/lib/model/metadata/enums.dart +++ b/lib/model/metadata/enums.dart @@ -3,6 +3,7 @@ enum MetadataField { exifDateOriginal, exifDateDigitized, exifGpsDate, + xmpCreateDate, } enum DateEditAction { @@ -91,7 +92,19 @@ extension ExtraMetadataType on MetadataType { } extension ExtraMetadataField on MetadataField { - String toExifInterfaceTag() { + MetadataType get type { + switch (this) { + case MetadataField.exifDate: + case MetadataField.exifDateOriginal: + case MetadataField.exifDateDigitized: + case MetadataField.exifGpsDate: + return MetadataType.exif; + case MetadataField.xmpCreateDate: + return MetadataType.xmp; + } + } + + String? toExifInterfaceTag() { switch (this) { case MetadataField.exifDate: return 'DateTime'; @@ -101,6 +114,8 @@ extension ExtraMetadataField on MetadataField { return 'DateTimeDigitized'; case MetadataField.exifGpsDate: return 'GPSDateStamp'; + case MetadataField.xmpCreateDate: + return null; } } } diff --git a/lib/services/metadata/metadata_edit_service.dart b/lib/services/metadata/metadata_edit_service.dart index b9489a691..164b5d7e9 100644 --- a/lib/services/metadata/metadata_edit_service.dart +++ b/lib/services/metadata/metadata_edit_service.dart @@ -4,6 +4,7 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/metadata/date_modifier.dart'; import 'package:aves/model/metadata/enums.dart'; import 'package:aves/services/common/services.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/services.dart'; abstract class MetadataEditService { @@ -11,7 +12,7 @@ abstract class MetadataEditService { Future> flip(AvesEntry entry); - Future> editDate(AvesEntry entry, DateModifier modifier); + Future> editExifDate(AvesEntry entry, DateModifier modifier); Future> editMetadata(AvesEntry entry, Map modifier); @@ -70,13 +71,13 @@ class PlatformMetadataEditService implements MetadataEditService { } @override - Future> editDate(AvesEntry entry, DateModifier modifier) async { + Future> editExifDate(AvesEntry entry, DateModifier modifier) async { try { final result = await platform.invokeMethod('editDate', { 'entry': _toPlatformEntryMap(entry), 'dateMillis': modifier.setDateTime?.millisecondsSinceEpoch, 'shiftMinutes': modifier.shiftMinutes, - 'fields': modifier.fields.map((v) => v.toExifInterfaceTag()).toList(), + 'fields': modifier.fields.where((v) => v.type == MetadataType.exif).map((v) => v.toExifInterfaceTag()).whereNotNull().toList(), }); if (result != null) return (result as Map).cast(); } on PlatformException catch (e, stack) { diff --git a/lib/utils/xmp_utils.dart b/lib/utils/xmp_utils.dart index c7fbe3173..d897e476a 100644 --- a/lib/utils/xmp_utils.dart +++ b/lib/utils/xmp_utils.dart @@ -79,6 +79,21 @@ class XMP { static String toXmpDate(DateTime date) => '${DateFormat('yyyy-MM-ddTHH:mm:ss').format(date)}${_xmpTimeZoneDesignator(date)}'; + static String? getString( + List nodes, + String name, { + required String namespace, + }) { + for (final node in nodes) { + final attribute = node.getAttribute(name, namespace: namespace); + if (attribute != null) return attribute; + + final element = node.getElement(name, namespace: namespace); + if (element != null) return element.innerText; + } + return null; + } + static void _addNamespaces(XmlNode node, Map namespaces) => namespaces.forEach((uri, prefix) => node.setAttribute('$xmlnsPrefix:$prefix', uri)); // remove elements and attributes diff --git a/lib/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart b/lib/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart index 5b3b280ab..306b2f572 100644 --- a/lib/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart @@ -30,11 +30,7 @@ class _EditEntryDateDialogState extends State { late ValueNotifier _shiftHour, _shiftMinute; late ValueNotifier _shiftSign; bool _showOptions = false; - final Set _fields = { - MetadataField.exifDate, - MetadataField.exifDateDigitized, - MetadataField.exifDateOriginal, - }; + final Set _fields = {...DateModifier.writableDateFields}; // use a different shade to avoid having the same background // on the dialog (using the theme `dialogBackgroundColor`) @@ -99,10 +95,10 @@ class _EditEntryDateDialogState extends State { if (_action == DateEditAction.setCustom) _buildSetCustomContent(context), if (_action == DateEditAction.copyField) _buildCopyFieldContent(context), if (_action == DateEditAction.shift) _buildShiftContent(context), + (_action == DateEditAction.shift || _action == DateEditAction.remove)? _buildDestinationFields(context): const SizedBox(height: 8), ], ), ), - _buildDestinationFields(context), ], actions: [ TextButton( @@ -135,7 +131,7 @@ class _EditEntryDateDialogState extends State { final use24hour = context.select((v) => v.alwaysUse24HourFormat); return Padding( - padding: const EdgeInsets.only(left: 16, top: 4, right: 12), + padding: const EdgeInsets.only(left: 16, right: 8), child: Row( children: [ Expanded(child: Text(formatDateTime(_setDateTime, locale, use24hour))), @@ -151,7 +147,7 @@ class _EditEntryDateDialogState extends State { Widget _buildCopyFieldContent(BuildContext context) { return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.only(left: 16, top: 0, right: 16), child: DropdownButton( items: DateFieldSource.values .map((v) => DropdownMenuItem( @@ -308,6 +304,8 @@ class _EditEntryDateDialogState extends State { return 'Exif digitized date'; case MetadataField.exifGpsDate: return 'Exif GPS date'; + case MetadataField.xmpCreateDate: + return 'XMP xmp:CreateDate'; } } @@ -337,13 +335,16 @@ class _EditEntryDateDialogState extends State { } DateModifier _getModifier() { + // fields to modify are only set for the `shift` and `remove` actions, + // as the effective fields for the other actions will depend on + // whether each item supports Exif edition switch (_action) { case DateEditAction.setCustom: - return DateModifier.setCustom(_fields, _setDateTime); + return DateModifier.setCustom(const {}, _setDateTime); case DateEditAction.copyField: - return DateModifier.copyField(_fields, _copyFieldSource); + return DateModifier.copyField(const {}, _copyFieldSource); case DateEditAction.extractFromTitle: - return DateModifier.extractFromTitle(_fields); + return DateModifier.extractFromTitle(const {}); case DateEditAction.shift: final shiftTotalMinutes = (_shiftHour.value * 60 + _shiftMinute.value) * (_shiftSign.value == '+' ? 1 : -1); return DateModifier.shift(_fields, shiftTotalMinutes); diff --git a/test/utils/xmp_utils_test.dart b/test/utils/xmp_utils_test.dart index 24dcc403f..a81497a6b 100644 --- a/test/utils/xmp_utils_test.dart +++ b/test/utils/xmp_utils_test.dart @@ -13,6 +13,15 @@ void main() { ) : null; + List _getDescriptions(String xmpString) { + final xmpDoc = XmlDocument.parse(xmpString); + final root = xmpDoc.rootElement; + final rdf = root.getElement(XMP.rdfRoot, namespace: Namespaces.rdf); + return rdf!.children.where((node) { + return node is XmlElement && node.name.local == XMP.rdfDescription && node.name.namespaceUri == Namespaces.rdf; + }).toList(); + } + const inMultiDescriptionRatings = ''' @@ -79,6 +88,12 @@ void main() { '''; + test('Get string', () async { + expect(XMP.getString(_getDescriptions(inRatingAttribute), XMP.xmpRating, namespace: Namespaces.xmp), '5'); + expect(XMP.getString(_getDescriptions(inRatingElement), XMP.xmpRating, namespace: Namespaces.xmp), '5'); + expect(XMP.getString(_getDescriptions(inSubjects), XMP.xmpRating, namespace: Namespaces.xmp), null); + }); + test('Set tags without existing XMP', () async { final modifyDate = DateTime.now(); final xmpDate = XMP.toXmpDate(modifyDate); From 1a17f9546c25e7dae48761b395c5032adfd09be1 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Wed, 5 Jan 2022 10:44:43 +0900 Subject: [PATCH 16/28] stats: added ratings --- lib/widgets/stats/filter_table.dart | 22 ++++++++++++++-------- lib/widgets/stats/stats_page.dart | 26 +++++++++++++++++++------- 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/lib/widgets/stats/filter_table.dart b/lib/widgets/stats/filter_table.dart index 2d5754440..325b518c0 100644 --- a/lib/widgets/stats/filter_table.dart +++ b/lib/widgets/stats/filter_table.dart @@ -2,15 +2,16 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/utils/color_utils.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; -import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:percent_indicator/linear_percent_indicator.dart'; -class FilterTable extends StatelessWidget { +class FilterTable extends StatelessWidget { final int totalEntryCount; - final Map entryCountMap; - final CollectionFilter Function(String key) filterBuilder; + final Map entryCountMap; + final CollectionFilter Function(T key) filterBuilder; + final bool sortByCount; + final int? maxRowCount; final FilterCallback onFilterSelection; const FilterTable({ @@ -18,6 +19,8 @@ class FilterTable extends StatelessWidget { required this.totalEntryCount, required this.entryCountMap, required this.filterBuilder, + required this.sortByCount, + required this.maxRowCount, required this.onFilterSelection, }) : super(key: key); @@ -27,11 +30,13 @@ class FilterTable extends StatelessWidget { @override Widget build(BuildContext context) { - final sortedEntries = entryCountMap.entries.toList() - ..sort((kv1, kv2) { + final sortedEntries = entryCountMap.entries.toList(); + if (sortByCount) { + sortedEntries.sort((kv1, kv2) { final c = kv2.value.compareTo(kv1.value); - return c != 0 ? c : compareAsciiUpperCase(kv1.key, kv2.key); + return c != 0 ? c : kv1.key.compareTo(kv2.key); }); + } final textScaleFactor = MediaQuery.textScaleFactorOf(context); final lineHeight = 16 * textScaleFactor; @@ -41,8 +46,9 @@ class FilterTable extends StatelessWidget { child: LayoutBuilder( builder: (context, constraints) { final showPercentIndicator = constraints.maxWidth - (chipWidth + countWidth) > percentIndicatorMinWidth; + final displayedEntries = maxRowCount != null ? sortedEntries.take(maxRowCount!) : sortedEntries; return Table( - children: sortedEntries.take(5).map((kv) { + children: displayedEntries.map((kv) { final filter = filterBuilder(kv.key); final label = filter.getLabel(context); final count = kv.value; diff --git a/lib/widgets/stats/stats_page.dart b/lib/widgets/stats/stats_page.dart index 884cc6280..6ece99212 100644 --- a/lib/widgets/stats/stats_page.dart +++ b/lib/widgets/stats/stats_page.dart @@ -4,6 +4,7 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/mime.dart'; +import 'package:aves/model/filters/rating.dart'; import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/settings/accessibility_animations.dart'; import 'package:aves/model/settings/settings.dart'; @@ -33,6 +34,7 @@ class StatsPage extends StatelessWidget { final CollectionLens? parentCollection; final Set entries; final Map entryCountPerCountry = {}, entryCountPerPlace = {}, entryCountPerTag = {}; + final Map entryCountPerRating = Map.fromEntries(List.generate(7, (i) => MapEntry(5 - i, 0))); static const mimeDonutMinWidth = 124.0; @@ -55,9 +57,13 @@ class StatsPage extends StatelessWidget { entryCountPerPlace[place] = (entryCountPerPlace[place] ?? 0) + 1; } } + entry.tags.forEach((tag) { entryCountPerTag[tag] = (entryCountPerTag[tag] ?? 0) + 1; }); + + final rating = entry.rating; + entryCountPerRating[rating] = (entryCountPerRating[rating] ?? 0) + 1; }); } @@ -115,13 +121,15 @@ class StatsPage extends StatelessWidget { ], ), ); + final showRatings = entryCountPerRating.entries.any((kv) => kv.key != 0 && kv.value > 0); child = ListView( children: [ mimeDonuts, locationIndicator, - ..._buildTopFilters(context, context.l10n.statsTopCountries, entryCountPerCountry, (s) => LocationFilter(LocationLevel.country, s)), - ..._buildTopFilters(context, context.l10n.statsTopPlaces, entryCountPerPlace, (s) => LocationFilter(LocationLevel.place, s)), - ..._buildTopFilters(context, context.l10n.statsTopTags, entryCountPerTag, (s) => TagFilter(s)), + ..._buildFilterSection(context, context.l10n.statsTopCountries, entryCountPerCountry, (v) => LocationFilter(LocationLevel.country, v)), + ..._buildFilterSection(context, context.l10n.statsTopPlaces, entryCountPerPlace, (v) => LocationFilter(LocationLevel.place, v)), + ..._buildFilterSection(context, context.l10n.statsTopTags, entryCountPerTag, (v) => TagFilter(v)), + if (showRatings) ..._buildFilterSection(context, context.l10n.searchSectionRating, entryCountPerRating, (v) => RatingFilter(v), sortByCount: false, maxRowCount: null), ], ); } @@ -243,12 +251,14 @@ class StatsPage extends StatelessWidget { }); } - List _buildTopFilters( + List _buildFilterSection( BuildContext context, String title, - Map entryCountMap, - CollectionFilter Function(String key) filterBuilder, - ) { + Map entryCountMap, + CollectionFilter Function(T key) filterBuilder, { + bool sortByCount = true, + int? maxRowCount = 5, + }) { if (entryCountMap.isEmpty) return []; return [ @@ -263,6 +273,8 @@ class StatsPage extends StatelessWidget { totalEntryCount: entries.length, entryCountMap: entryCountMap, filterBuilder: filterBuilder, + sortByCount: sortByCount, + maxRowCount: maxRowCount, onFilterSelection: (filter) => _onFilterSelection(context, filter), ), ]; From 862a8003fad3a4804bd6cf415bd2ef2ccf3c1dc6 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Wed, 5 Jan 2022 13:33:43 +0900 Subject: [PATCH 17/28] various fixes --- lib/image_providers/region_provider.dart | 1 + lib/image_providers/thumbnail_provider.dart | 3 ++- lib/image_providers/uri_image_provider.dart | 1 + lib/model/entry.dart | 14 +++++----- lib/model/entry_cache.dart | 19 ++++++++------ lib/ref/mime_types.dart | 29 +++++++++++++++++++-- lib/widgets/viewer/info/info_search.dart | 2 +- 7 files changed, 51 insertions(+), 18 deletions(-) diff --git a/lib/image_providers/region_provider.dart b/lib/image_providers/region_provider.dart index e583467f7..97faccc96 100644 --- a/lib/image_providers/region_provider.dart +++ b/lib/image_providers/region_provider.dart @@ -49,6 +49,7 @@ class RegionProvider extends ImageProvider { } return await decode(bytes); } catch (error) { + // loading may fail if the provided MIME type is incorrect (e.g. the Media Store may report a JPEG as a TIFF) debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$error'); throw StateError('$mimeType region decoding failed (page $pageId)'); } diff --git a/lib/image_providers/thumbnail_provider.dart b/lib/image_providers/thumbnail_provider.dart index 57fe937bd..3c5b5699c 100644 --- a/lib/image_providers/thumbnail_provider.dart +++ b/lib/image_providers/thumbnail_provider.dart @@ -50,7 +50,8 @@ class ThumbnailProvider extends ImageProvider { } return await decode(bytes); } catch (error) { - debugPrint('$runtimeType _loadAsync failed with uri=$uri, error=$error'); + // loading may fail if the provided MIME type is incorrect (e.g. the Media Store may report a JPEG as a TIFF) + debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$error'); throw StateError('$mimeType decoding failed (page $pageId)'); } } diff --git a/lib/image_providers/uri_image_provider.dart b/lib/image_providers/uri_image_provider.dart index 416d407f1..1aeca959e 100644 --- a/lib/image_providers/uri_image_provider.dart +++ b/lib/image_providers/uri_image_provider.dart @@ -68,6 +68,7 @@ class UriImage extends ImageProvider with EquatableMixin { } return await decode(bytes); } catch (error) { + // loading may fail if the provided MIME type is incorrect (e.g. the Media Store may report a JPEG as a TIFF) debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$error'); throw StateError('$mimeType decoding failed (page $pageId)'); } finally { diff --git a/lib/model/entry.dart b/lib/model/entry.dart index 2e071117e..812d3fad0 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -449,6 +449,7 @@ class AvesEntry { CatalogMetadata? get catalogMetadata => _catalogMetadata; set catalogMetadata(CatalogMetadata? newMetadata) { + final oldMimeType = mimeType; final oldDateModifiedSecs = dateModifiedSecs; final oldRotationDegrees = rotationDegrees; final oldIsFlipped = isFlipped; @@ -459,7 +460,7 @@ class AvesEntry { _tags = null; metadataChangeNotifier.notify(); - _onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); + _onVisualFieldChanged(oldMimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); } void clearMetadata() { @@ -583,6 +584,7 @@ class AvesEntry { } Future applyNewFields(Map newFields, {required bool persist}) async { + final oldMimeType = mimeType; final oldDateModifiedSecs = this.dateModifiedSecs; final oldRotationDegrees = this.rotationDegrees; final oldIsFlipped = this.isFlipped; @@ -622,7 +624,7 @@ class AvesEntry { if (catalogMetadata != null) await metadataDb.saveMetadata({catalogMetadata!}); } - await _onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); + await _onVisualFieldChanged(oldMimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); metadataChangeNotifier.notify(); } @@ -663,10 +665,10 @@ class AvesEntry { return completer.future; } - // when the entry image itself changed (e.g. after rotation) - Future _onVisualFieldChanged(int? oldDateModifiedSecs, int oldRotationDegrees, bool oldIsFlipped) async { - if (oldDateModifiedSecs != dateModifiedSecs || oldRotationDegrees != rotationDegrees || oldIsFlipped != isFlipped) { - await EntryCache.evict(uri, mimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); + // when the MIME type or the image itself changed (e.g. after rotation) + Future _onVisualFieldChanged(String oldMimeType, int? oldDateModifiedSecs, int oldRotationDegrees, bool oldIsFlipped) async { + if ((!MimeTypes.refersToSameType(oldMimeType, mimeType) && !MimeTypes.isVideo(oldMimeType)) || oldDateModifiedSecs != dateModifiedSecs || oldRotationDegrees != rotationDegrees || oldIsFlipped != isFlipped) { + await EntryCache.evict(uri, oldMimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); imageChangeNotifier.notify(); } } diff --git a/lib/model/entry_cache.dart b/lib/model/entry_cache.dart index a70fd6d63..c0ff892a6 100644 --- a/lib/model/entry_cache.dart +++ b/lib/model/entry_cache.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:aves/image_providers/thumbnail_provider.dart'; import 'package:aves/image_providers/uri_image_provider.dart'; +import 'package:flutter/foundation.dart'; class EntryCache { // ordered descending @@ -19,9 +20,11 @@ class EntryCache { String uri, String mimeType, int? dateModifiedSecs, - int oldRotationDegrees, - bool oldIsFlipped, + int rotationDegrees, + bool isFlipped, ) async { + debugPrint('Evict cached images for uri=$uri, mimeType=$mimeType, dateModifiedSecs=$dateModifiedSecs, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped'); + // TODO TLAD provide pageId parameter for multi page items, if someday image editing features are added for them int? pageId; @@ -30,8 +33,8 @@ class EntryCache { uri: uri, mimeType: mimeType, pageId: pageId, - rotationDegrees: oldRotationDegrees, - isFlipped: oldIsFlipped, + rotationDegrees: rotationDegrees, + isFlipped: isFlipped, ).evict(); // evict low quality thumbnail (without specified extents) @@ -40,8 +43,8 @@ class EntryCache { mimeType: mimeType, pageId: pageId, dateModifiedSecs: dateModifiedSecs ?? 0, - rotationDegrees: oldRotationDegrees, - isFlipped: oldIsFlipped, + rotationDegrees: rotationDegrees, + isFlipped: isFlipped, )).evict(); await Future.forEach( @@ -51,8 +54,8 @@ class EntryCache { mimeType: mimeType, pageId: pageId, dateModifiedSecs: dateModifiedSecs ?? 0, - rotationDegrees: oldRotationDegrees, - isFlipped: oldIsFlipped, + rotationDegrees: rotationDegrees, + isFlipped: isFlipped, extent: extent, )).evict()); } diff --git a/lib/ref/mime_types.dart b/lib/ref/mime_types.dart index 99f98642f..2b43ebf5e 100644 --- a/lib/ref/mime_types.dart +++ b/lib/ref/mime_types.dart @@ -2,6 +2,7 @@ class MimeTypes { static const anyImage = 'image/*'; static const bmp = 'image/bmp'; + static const bmpX = 'image/x-ms-bmp'; static const gif = 'image/gif'; static const heic = 'image/heic'; static const heif = 'image/heif'; @@ -43,6 +44,8 @@ class MimeTypes { static const avi = 'video/avi'; static const aviVnd = 'video/vnd.avi'; + static const flv = 'video/flv'; + static const flvX = 'video/x-flv'; static const mkv = 'video/x-matroska'; static const mov = 'video/quicktime'; static const mp2t = 'video/mp2t'; // .m2ts, .ts @@ -62,7 +65,7 @@ class MimeTypes { // groups // formats that support transparency - static const Set alphaImages = {bmp, gif, ico, png, svg, tiff, webp}; + static const Set alphaImages = {bmp, bmpX, gif, ico, png, svg, tiff, webp}; static const Set rawImages = {arw, cr2, crw, dcr, dng, erf, k25, kdc, mrw, nef, nrw, orf, pef, raf, raw, rw2, sr2, srf, srw, x3f}; @@ -71,11 +74,33 @@ class MimeTypes { static const Set _knownOpaqueImages = {heic, heif, jpeg}; - static const Set _knownVideos = {avi, aviVnd, mkv, mov, mp2t, mp2ts, mp4, mpeg, ogv, webm}; + static const Set _knownVideos = {avi, aviVnd, flv, flvX, mkv, mov, mp2t, mp2ts, mp4, mpeg, ogv, webm}; static final Set knownMediaTypes = {..._knownOpaqueImages, ...alphaImages, ...rawImages, ...undecodableImages, ..._knownVideos}; static bool isImage(String mimeType) => mimeType.startsWith('image'); static bool isVideo(String mimeType) => mimeType.startsWith('video'); + + static bool refersToSameType(String a, b) { + switch (a) { + case avi: + case aviVnd: + return [avi, aviVnd].contains(b); + case bmp: + case bmpX: + return [bmp, bmpX].contains(b); + case flv: + case flvX: + return [flv, flvX].contains(b); + case heic: + case heif: + return [heic, heif].contains(b); + case psdVnd: + case psdX: + return [psdVnd, psdX].contains(b); + default: + return a == b; + } + } } diff --git a/lib/widgets/viewer/info/info_search.dart b/lib/widgets/viewer/info/info_search.dart index 8f0bd4c2b..6dbb9200a 100644 --- a/lib/widgets/viewer/info/info_search.dart +++ b/lib/widgets/viewer/info/info_search.dart @@ -55,7 +55,7 @@ class InfoSearchDelegate extends SearchDelegate { final l10n = context.l10n; final suggestions = { l10n.viewerInfoSearchSuggestionDate: 'date or time or when -timer -uptime -exposure -timeline -verbatim', - l10n.viewerInfoSearchSuggestionDescription: 'abstract or description or comment or textual or title', + l10n.viewerInfoSearchSuggestionDescription: 'abstract or description or comment or textual or title -line', l10n.viewerInfoSearchSuggestionDimensions: 'width or height or dimension or framesize or imagelength', l10n.viewerInfoSearchSuggestionResolution: 'resolution', l10n.viewerInfoSearchSuggestionRights: 'rights or copyright or attribution or license or artist or creator or by-line or credit -tool', From aa6a00b080cd5ad9c01a6f660724235dd976ceb7 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Wed, 5 Jan 2022 18:06:21 +0900 Subject: [PATCH 18/28] #149 fav: toggle multiple items, thumbnail overlay icon --- lib/l10n/app_en.arb | 1 + lib/l10n/app_fr.arb | 1 + lib/l10n/app_ko.arb | 1 + lib/model/actions/entry_set_actions.dart | 8 ++ lib/model/entry.dart | 4 +- lib/model/favourites.dart | 4 +- lib/model/settings/defaults.dart | 1 + lib/model/settings/settings.dart | 6 + lib/widgets/collection/app_bar.dart | 117 ++++++++++-------- lib/widgets/collection/collection_grid.dart | 20 +-- .../collection/entry_set_action_delegate.dart | 18 +++ lib/widgets/common/favourite_toggler.dart | 87 +++++++++++++ lib/widgets/common/grid/theme.dart | 4 +- lib/widgets/common/identity/aves_icons.dart | 14 +++ lib/widgets/common/thumbnail/overlay.dart | 1 + .../settings/thumbnails/thumbnails.dart | 23 ++++ lib/widgets/viewer/overlay/top.dart | 90 +------------- untranslated.json | 2 + 18 files changed, 251 insertions(+), 151 deletions(-) create mode 100644 lib/widgets/common/favourite_toggler.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b34ff0cf5..8cbfe7f2b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -524,6 +524,7 @@ "settingsNavigationDrawerAddAlbum": "Add album", "settingsSectionThumbnails": "Thumbnails", + "settingsThumbnailShowFavouriteIcon": "Show favourite icon", "settingsThumbnailShowLocationIcon": "Show location icon", "settingsThumbnailShowMotionPhotoIcon": "Show motion photo icon", "settingsThumbnailShowRating": "Show rating", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 87b92dd51..3975fda04 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -371,6 +371,7 @@ "settingsNavigationDrawerAddAlbum": "Ajouter un album", "settingsSectionThumbnails": "Vignettes", + "settingsThumbnailShowFavouriteIcon": "Afficher l’icône de favori", "settingsThumbnailShowLocationIcon": "Afficher l’icône de lieu", "settingsThumbnailShowMotionPhotoIcon": "Afficher l’icône de photo animée", "settingsThumbnailShowRating": "Afficher la notation", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 56955ad05..595e1300f 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -371,6 +371,7 @@ "settingsNavigationDrawerAddAlbum": "앨범 추가", "settingsSectionThumbnails": "섬네일", + "settingsThumbnailShowFavouriteIcon": "즐겨찾기 아이콘 표시", "settingsThumbnailShowLocationIcon": "위치 아이콘 표시", "settingsThumbnailShowMotionPhotoIcon": "모션 포토 아이콘 표시", "settingsThumbnailShowRating": "별점 표시", diff --git a/lib/model/actions/entry_set_actions.dart b/lib/model/actions/entry_set_actions.dart index 6d8ae339c..5b7243b72 100644 --- a/lib/model/actions/entry_set_actions.dart +++ b/lib/model/actions/entry_set_actions.dart @@ -21,6 +21,7 @@ enum EntrySetAction { copy, move, rescan, + toggleFavourite, rotateCCW, rotateCW, flip, @@ -51,6 +52,7 @@ class EntrySetActions { EntrySetAction.delete, EntrySetAction.copy, EntrySetAction.move, + EntrySetAction.toggleFavourite, EntrySetAction.rescan, EntrySetAction.map, EntrySetAction.stats, @@ -94,6 +96,9 @@ extension ExtraEntrySetAction on EntrySetAction { return context.l10n.collectionActionMove; case EntrySetAction.rescan: return context.l10n.collectionActionRescan; + case EntrySetAction.toggleFavourite: + // different data depending on toggle state + return context.l10n.entryActionAddFavourite; case EntrySetAction.rotateCCW: return context.l10n.entryActionRotateCCW; case EntrySetAction.rotateCW: @@ -150,6 +155,9 @@ extension ExtraEntrySetAction on EntrySetAction { return AIcons.move; case EntrySetAction.rescan: return AIcons.refresh; + case EntrySetAction.toggleFavourite: + // different data depending on toggle state + return AIcons.favourite; case EntrySetAction.rotateCCW: return AIcons.rotateLeft; case EntrySetAction.rotateCW: diff --git a/lib/model/entry.dart b/lib/model/entry.dart index 812d3fad0..be81490c3 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -685,13 +685,13 @@ class AvesEntry { Future addToFavourites() async { if (!isFavourite) { - await favourites.add([this]); + await favourites.add({this}); } } Future removeFromFavourites() async { if (isFavourite) { - await favourites.remove([this]); + await favourites.remove({this}); } } diff --git a/lib/model/favourites.dart b/lib/model/favourites.dart index 4a8e225e8..aef618695 100644 --- a/lib/model/favourites.dart +++ b/lib/model/favourites.dart @@ -21,7 +21,7 @@ class Favourites with ChangeNotifier { FavouriteRow _entryToRow(AvesEntry entry) => FavouriteRow(contentId: entry.contentId!, path: entry.path!); - Future add(Iterable entries) async { + Future add(Set entries) async { final newRows = entries.map(_entryToRow); await metadataDb.addFavourites(newRows); @@ -30,7 +30,7 @@ class Favourites with ChangeNotifier { notifyListeners(); } - Future remove(Iterable entries) async { + Future remove(Set entries) async { final contentIds = entries.map((entry) => entry.contentId).toSet(); final removedRows = _rows.where((row) => contentIds.contains(row.contentId)).toSet(); diff --git a/lib/model/settings/defaults.dart b/lib/model/settings/defaults.dart index cfd155b42..9f7a6f968 100644 --- a/lib/model/settings/defaults.dart +++ b/lib/model/settings/defaults.dart @@ -43,6 +43,7 @@ class SettingsDefaults { EntrySetAction.share, EntrySetAction.delete, ]; + static const showThumbnailFavourite = true; static const showThumbnailLocation = true; static const showThumbnailMotionPhoto = true; static const showThumbnailRating = true; diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index 3abbdea7a..1b7f9c872 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -61,6 +61,7 @@ class Settings extends ChangeNotifier { static const collectionSortFactorKey = 'collection_sort_factor'; static const collectionBrowsingQuickActionsKey = 'collection_browsing_quick_actions'; static const collectionSelectionQuickActionsKey = 'collection_selection_quick_actions'; + static const showThumbnailFavouriteKey = 'show_thumbnail_favourite'; static const showThumbnailLocationKey = 'show_thumbnail_location'; static const showThumbnailMotionPhotoKey = 'show_thumbnail_motion_photo'; static const showThumbnailRatingKey = 'show_thumbnail_rating'; @@ -303,6 +304,10 @@ class Settings extends ChangeNotifier { set collectionSelectionQuickActions(List newValue) => setAndNotify(collectionSelectionQuickActionsKey, newValue.map((v) => v.toString()).toList()); + bool get showThumbnailFavourite => getBoolOrDefault(showThumbnailFavouriteKey, SettingsDefaults.showThumbnailFavourite); + + set showThumbnailFavourite(bool newValue) => setAndNotify(showThumbnailFavouriteKey, newValue); + bool get showThumbnailLocation => getBoolOrDefault(showThumbnailLocationKey, SettingsDefaults.showThumbnailLocation); set showThumbnailLocation(bool newValue) => setAndNotify(showThumbnailLocationKey, newValue); @@ -622,6 +627,7 @@ class Settings extends ChangeNotifier { case isInstalledAppAccessAllowedKey: case isErrorReportingAllowedKey: case mustBackTwiceToExitKey: + case showThumbnailFavouriteKey: case showThumbnailLocationKey: case showThumbnailMotionPhotoKey: case showThumbnailRatingKey: diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index 1bdaedae4..e8934726f 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -20,6 +20,7 @@ import 'package:aves/widgets/common/app_bar_subtitle.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/common/favourite_toggler.dart'; import 'package:aves/widgets/common/sliver_app_bar_title.dart'; import 'package:aves/widgets/dialogs/tile_view_dialog.dart'; import 'package:aves/widgets/search/search_delegate.dart'; @@ -102,50 +103,42 @@ class _CollectionAppBarState extends State with SingleTickerPr @override Widget build(BuildContext context) { final appMode = context.watch>().value; - return Selector, Tuple2>( - selector: (context, selection) => Tuple2(selection.isSelecting, selection.selectedItems.length), - builder: (context, s, child) { - final isSelecting = s.item1; - final selectedItemCount = s.item2; - _isSelectingNotifier.value = isSelecting; - return AnimatedBuilder( - animation: collection.filterChangeNotifier, - builder: (context, child) { - final removableFilters = appMode != AppMode.pickInternal; - return Selector( - selector: (context, query) => query.enabled, - builder: (context, queryEnabled, child) { - return SliverAppBar( - leading: appMode.hasDrawer ? _buildAppBarLeading(isSelecting) : null, - title: SliverAppBarTitleWrapper( - child: _buildAppBarTitle(isSelecting), - ), - actions: _buildActions( - isSelecting: isSelecting, - selectedItemCount: selectedItemCount, - ), - bottom: PreferredSize( - preferredSize: Size.fromHeight(appBarBottomHeight), - child: Column( - children: [ - if (showFilterBar) - FilterBar( - filters: collection.filters.where((v) => !(v is QueryFilter && v.live)).toSet(), - removable: removableFilters, - onTap: removableFilters ? collection.removeFilter : null, - ), - if (queryEnabled) - EntryQueryBar( - queryNotifier: context.select>((query) => query.queryNotifier), - focusNode: _queryBarFocusNode, - ) - ], - ), - ), - titleSpacing: 0, - floating: true, - ); - }, + final selection = context.watch>(); + final isSelecting = selection.isSelecting; + _isSelectingNotifier.value = isSelecting; + return AnimatedBuilder( + animation: collection.filterChangeNotifier, + builder: (context, child) { + final removableFilters = appMode != AppMode.pickInternal; + return Selector( + selector: (context, query) => query.enabled, + builder: (context, queryEnabled, child) { + return SliverAppBar( + leading: appMode.hasDrawer ? _buildAppBarLeading(isSelecting) : null, + title: SliverAppBarTitleWrapper( + child: _buildAppBarTitle(isSelecting), + ), + actions: _buildActions(selection), + bottom: PreferredSize( + preferredSize: Size.fromHeight(appBarBottomHeight), + child: Column( + children: [ + if (showFilterBar) + FilterBar( + filters: collection.filters.where((v) => !(v is QueryFilter && v.live)).toSet(), + removable: removableFilters, + onTap: removableFilters ? collection.removeFilter : null, + ), + if (queryEnabled) + EntryQueryBar( + queryNotifier: context.select>((query) => query.queryNotifier), + focusNode: _queryBarFocusNode, + ) + ], + ), + ), + titleSpacing: 0, + floating: true, ); }, ); @@ -204,10 +197,10 @@ class _CollectionAppBarState extends State with SingleTickerPr } } - List _buildActions({ - required bool isSelecting, - required int selectedItemCount, - }) { + List _buildActions(Selection selection) { + final isSelecting = selection.isSelecting; + final selectedItemCount = selection.selectedItems.length; + final appMode = context.watch>().value; bool isVisible(EntrySetAction action) => _actionDelegate.isVisible( action, @@ -227,7 +220,7 @@ class _CollectionAppBarState extends State with SingleTickerPr final browsingQuickActions = settings.collectionBrowsingQuickActions; final selectionQuickActions = settings.collectionSelectionQuickActions; final quickActionButtons = (isSelecting ? selectionQuickActions : browsingQuickActions).where(isVisible).map( - (action) => _toActionButton(action, enabled: canApply(action)), + (action) => _toActionButton(action, enabled: canApply(action), selection: selection), ); return [ @@ -238,14 +231,14 @@ class _CollectionAppBarState extends State with SingleTickerPr key: const Key('appbar-menu-button'), itemBuilder: (context) { final generalMenuItems = EntrySetActions.general.where(isVisible).map( - (action) => _toMenuItem(action, enabled: canApply(action)), + (action) => _toMenuItem(action, enabled: canApply(action), selection: selection), ); final browsingMenuActions = EntrySetActions.browsing.where((v) => !browsingQuickActions.contains(v)); final selectionMenuActions = EntrySetActions.selection.where((v) => !selectionQuickActions.contains(v)); final contextualMenuItems = [ ...(isSelecting ? selectionMenuActions : browsingMenuActions).where(isVisible).map( - (action) => _toMenuItem(action, enabled: canApply(action)), + (action) => _toMenuItem(action, enabled: canApply(action), selection: selection), ), if (isSelecting) PopupMenuItem( @@ -262,7 +255,7 @@ class _CollectionAppBarState extends State with SingleTickerPr EntrySetAction.editRating, EntrySetAction.editTags, EntrySetAction.removeMetadata, - ].map((action) => _toMenuItem(action, enabled: canApply(action))), + ].map((action) => _toMenuItem(action, enabled: canApply(action), selection: selection)), ], ), ), @@ -286,10 +279,14 @@ class _CollectionAppBarState extends State with SingleTickerPr ]; } + Set _getExpandedSelectedItems(Selection selection) { + return selection.selectedItems.expand((entry) => entry.burstEntries ?? {entry}).toSet(); + } + // key is expected by test driver (e.g. 'menu-configureView', 'menu-map') Key _getActionKey(EntrySetAction action) => Key('menu-${action.name}'); - Widget _toActionButton(EntrySetAction action, {required bool enabled}) { + Widget _toActionButton(EntrySetAction action, {required bool enabled, required Selection selection}) { final onPressed = enabled ? () => _onActionSelected(action) : null; switch (action) { case EntrySetAction.toggleTitleSearch: @@ -302,6 +299,11 @@ class _CollectionAppBarState extends State with SingleTickerPr ); }, ); + case EntrySetAction.toggleFavourite: + return FavouriteToggler( + entries: _getExpandedSelectedItems(selection), + onPressed: onPressed, + ); default: return IconButton( key: _getActionKey(action), @@ -312,7 +314,7 @@ class _CollectionAppBarState extends State with SingleTickerPr } } - PopupMenuItem _toMenuItem(EntrySetAction action, {required bool enabled}) { + PopupMenuItem _toMenuItem(EntrySetAction action, {required bool enabled, required Selection selection}) { late Widget child; switch (action) { case EntrySetAction.toggleTitleSearch: @@ -321,6 +323,12 @@ class _CollectionAppBarState extends State with SingleTickerPr isMenuItem: true, ); break; + case EntrySetAction.toggleFavourite: + child = FavouriteToggler( + entries: _getExpandedSelectedItems(selection), + isMenuItem: true, + ); + break; default: child = MenuRow(text: action.getText(context), icon: action.getIcon()); break; @@ -424,6 +432,7 @@ class _CollectionAppBarState extends State with SingleTickerPr case EntrySetAction.copy: case EntrySetAction.move: case EntrySetAction.rescan: + case EntrySetAction.toggleFavourite: case EntrySetAction.rotateCCW: case EntrySetAction.rotateCW: case EntrySetAction.flip: diff --git a/lib/widgets/collection/collection_grid.dart b/lib/widgets/collection/collection_grid.dart index 4df61a326..b1d647b86 100644 --- a/lib/widgets/collection/collection_grid.dart +++ b/lib/widgets/collection/collection_grid.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:aves/app_mode.dart'; import 'package:aves/model/entry.dart'; +import 'package:aves/model/favourites.dart'; import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/settings/settings.dart'; @@ -103,13 +104,18 @@ class _CollectionGridContent extends StatelessWidget { columnCount: columnCount, spacing: tileSpacing, tileExtent: thumbnailExtent, - tileBuilder: (entry) => InteractiveTile( - key: ValueKey(entry.contentId), - collection: collection, - entry: entry, - thumbnailExtent: thumbnailExtent, - tileLayout: tileLayout, - isScrollingNotifier: _isScrollingNotifier, + tileBuilder: (entry) => AnimatedBuilder( + animation: favourites, + builder: (context, child) { + return InteractiveTile( + key: ValueKey(entry.contentId), + collection: collection, + entry: entry, + thumbnailExtent: thumbnailExtent, + tileLayout: tileLayout, + isScrollingNotifier: _isScrollingNotifier, + ); + }, ), tileAnimationDelay: tileAnimationDelay, child: child!, diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index ab8211a1b..dc5b75c6f 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -7,6 +7,7 @@ import 'package:aves/model/actions/move_type.dart'; import 'package:aves/model/device.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/entry_metadata_edition.dart'; +import 'package:aves/model/favourites.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/highlight.dart'; @@ -73,6 +74,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa case EntrySetAction.copy: case EntrySetAction.move: case EntrySetAction.rescan: + case EntrySetAction.toggleFavourite: case EntrySetAction.rotateCCW: case EntrySetAction.rotateCW: case EntrySetAction.flip: @@ -115,6 +117,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa case EntrySetAction.copy: case EntrySetAction.move: case EntrySetAction.rescan: + case EntrySetAction.toggleFavourite: case EntrySetAction.rotateCCW: case EntrySetAction.rotateCW: case EntrySetAction.flip: @@ -167,6 +170,9 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa case EntrySetAction.rescan: _rescan(context); break; + case EntrySetAction.toggleFavourite: + _toggleFavourite(context); + break; case EntrySetAction.rotateCCW: _rotate(context, clockwise: false); break; @@ -214,6 +220,18 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa selection.browse(); } + Future _toggleFavourite(BuildContext context) async { + final selection = context.read>(); + final selectedItems = _getExpandedSelectedItems(selection); + if (selectedItems.every((entry) => entry.isFavourite)) { + await favourites.remove(selectedItems); + } else { + await favourites.add(selectedItems); + } + + selection.browse(); + } + Future _delete(BuildContext context) async { final source = context.read(); final selection = context.read>(); diff --git a/lib/widgets/common/favourite_toggler.dart b/lib/widgets/common/favourite_toggler.dart new file mode 100644 index 000000000..caec381e2 --- /dev/null +++ b/lib/widgets/common/favourite_toggler.dart @@ -0,0 +1,87 @@ +import 'package:aves/model/entry.dart'; +import 'package:aves/model/favourites.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/basic/menu.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/fx/sweeper.dart'; +import 'package:flutter/material.dart'; + +class FavouriteToggler extends StatefulWidget { + final Set entries; + final bool isMenuItem; + final VoidCallback? onPressed; + + const FavouriteToggler({ + Key? key, + required this.entries, + this.isMenuItem = false, + this.onPressed, + }) : super(key: key); + + @override + _FavouriteTogglerState createState() => _FavouriteTogglerState(); +} + +class _FavouriteTogglerState extends State { + final ValueNotifier isFavouriteNotifier = ValueNotifier(false); + + Set get entries => widget.entries; + + @override + void initState() { + super.initState(); + favourites.addListener(_onChanged); + _onChanged(); + } + + @override + void didUpdateWidget(covariant FavouriteToggler oldWidget) { + super.didUpdateWidget(oldWidget); + _onChanged(); + } + + @override + void dispose() { + favourites.removeListener(_onChanged); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: isFavouriteNotifier, + builder: (context, isFavourite, child) { + if (widget.isMenuItem) { + return isFavourite + ? MenuRow( + text: context.l10n.entryActionRemoveFavourite, + icon: const Icon(AIcons.favouriteActive), + ) + : MenuRow( + text: context.l10n.entryActionAddFavourite, + icon: const Icon(AIcons.favourite), + ); + } + return Stack( + alignment: Alignment.center, + children: [ + IconButton( + icon: Icon(isFavourite ? AIcons.favouriteActive : AIcons.favourite), + onPressed: widget.onPressed, + tooltip: isFavourite ? context.l10n.entryActionRemoveFavourite : context.l10n.entryActionAddFavourite, + ), + Sweeper( + key: ValueKey(entries.length == 1 ? entries.first : entries.length), + builder: (context) => const Icon(AIcons.favourite, color: Colors.redAccent), + toggledNotifier: isFavouriteNotifier, + ), + ], + ); + }, + ); + } + + void _onChanged() { + isFavouriteNotifier.value = entries.isNotEmpty && entries.every((entry) => entry.isFavourite); + } +} diff --git a/lib/widgets/common/grid/theme.dart b/lib/widgets/common/grid/theme.dart index 442754db6..2422ab2bd 100644 --- a/lib/widgets/common/grid/theme.dart +++ b/lib/widgets/common/grid/theme.dart @@ -28,6 +28,7 @@ class GridTheme extends StatelessWidget { iconSize: iconSize, fontSize: fontSize, highlightBorderWidth: highlightBorderWidth, + showFavourite: settings.showThumbnailFavourite, showLocation: showLocation ?? settings.showThumbnailLocation, showMotionPhoto: settings.showThumbnailMotionPhoto, showRating: settings.showThumbnailRating, @@ -42,12 +43,13 @@ class GridTheme extends StatelessWidget { class GridThemeData { final double iconSize, fontSize, highlightBorderWidth; - final bool showLocation, showMotionPhoto, showRating, showRaw, showVideoDuration; + final bool showFavourite, showLocation, showMotionPhoto, showRating, showRaw, showVideoDuration; const GridThemeData({ required this.iconSize, required this.fontSize, required this.highlightBorderWidth, + required this.showFavourite, required this.showLocation, required this.showMotionPhoto, required this.showRating, diff --git a/lib/widgets/common/identity/aves_icons.dart b/lib/widgets/common/identity/aves_icons.dart index a2f85c220..4527a50ac 100644 --- a/lib/widgets/common/identity/aves_icons.dart +++ b/lib/widgets/common/identity/aves_icons.dart @@ -72,6 +72,20 @@ class SphericalImageIcon extends StatelessWidget { } } +class FavouriteIcon extends StatelessWidget { + const FavouriteIcon({Key? key}) : super(key: key); + + static const scale = .9; + + @override + Widget build(BuildContext context) { + return const OverlayIcon( + icon: AIcons.favourite, + iconScale: scale, + ); + } +} + class GpsIcon extends StatelessWidget { const GpsIcon({Key? key}) : super(key: key); diff --git a/lib/widgets/common/thumbnail/overlay.dart b/lib/widgets/common/thumbnail/overlay.dart index 795a7e814..388604013 100644 --- a/lib/widgets/common/thumbnail/overlay.dart +++ b/lib/widgets/common/thumbnail/overlay.dart @@ -19,6 +19,7 @@ class ThumbnailEntryOverlay extends StatelessWidget { @override Widget build(BuildContext context) { final children = [ + if (entry.isFavourite && context.select((t) => t.showFavourite)) const FavouriteIcon(), if (entry.hasGps && context.select((t) => t.showLocation)) const GpsIcon(), if (entry.rating != 0 && context.select((t) => t.showRating)) RatingIcon(entry: entry), if (entry.isVideo) diff --git a/lib/widgets/settings/thumbnails/thumbnails.dart b/lib/widgets/settings/thumbnails/thumbnails.dart index ed8c8fb1a..1ff395e94 100644 --- a/lib/widgets/settings/thumbnails/thumbnails.dart +++ b/lib/widgets/settings/thumbnails/thumbnails.dart @@ -33,6 +33,29 @@ class ThumbnailsSection extends StatelessWidget { showHighlight: false, children: [ const CollectionActionsTile(), + Selector( + selector: (context, s) => s.showThumbnailFavourite, + builder: (context, current, child) => SwitchListTile( + value: current, + onChanged: (v) => settings.showThumbnailFavourite = v, + title: Row( + children: [ + Expanded(child: Text(context.l10n.settingsThumbnailShowFavouriteIcon)), + AnimatedOpacity( + opacity: opacityFor(current), + duration: Durations.toggleableTransitionAnimation, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: iconSize * (1 - FavouriteIcon.scale) / 2), + child: Icon( + AIcons.favourite, + size: iconSize * FavouriteIcon.scale, + ), + ), + ), + ], + ), + ), + ), Selector( selector: (context, s) => s.showThumbnailLocation, builder: (context, current, child) => SwitchListTile( diff --git a/lib/widgets/viewer/overlay/top.dart b/lib/widgets/viewer/overlay/top.dart index b8b8087cb..846f258d5 100644 --- a/lib/widgets/viewer/overlay/top.dart +++ b/lib/widgets/viewer/overlay/top.dart @@ -1,14 +1,11 @@ import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/device.dart'; import 'package:aves/model/entry.dart'; -import 'package:aves/model/favourites.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/durations.dart'; -import 'package:aves/theme/icons.dart'; 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/common/favourite_toggler.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'; @@ -204,8 +201,8 @@ class _TopOverlayRow extends StatelessWidget { void onPressed() => _onActionSelected(context, action); switch (action) { case EntryAction.toggleFavourite: - child = _FavouriteToggler( - entry: favouriteTargetEntry, + child = FavouriteToggler( + entries: {favouriteTargetEntry}, onPressed: onPressed, ); break; @@ -251,8 +248,8 @@ class _TopOverlayRow extends StatelessWidget { switch (action) { // in app actions case EntryAction.toggleFavourite: - child = _FavouriteToggler( - entry: favouriteTargetEntry, + child = FavouriteToggler( + entries: {favouriteTargetEntry}, isMenuItem: true, ); break; @@ -315,80 +312,3 @@ class _TopOverlayRow extends StatelessWidget { EntryActionDelegate(targetEntry).onActionSelected(context, action); } } - -class _FavouriteToggler extends StatefulWidget { - final AvesEntry entry; - final bool isMenuItem; - final VoidCallback? onPressed; - - const _FavouriteToggler({ - required this.entry, - this.isMenuItem = false, - this.onPressed, - }); - - @override - _FavouriteTogglerState createState() => _FavouriteTogglerState(); -} - -class _FavouriteTogglerState extends State<_FavouriteToggler> { - final ValueNotifier isFavouriteNotifier = ValueNotifier(false); - - @override - void initState() { - super.initState(); - favourites.addListener(_onChanged); - _onChanged(); - } - - @override - void didUpdateWidget(covariant _FavouriteToggler oldWidget) { - super.didUpdateWidget(oldWidget); - _onChanged(); - } - - @override - void dispose() { - favourites.removeListener(_onChanged); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return ValueListenableBuilder( - valueListenable: isFavouriteNotifier, - builder: (context, isFavourite, child) { - if (widget.isMenuItem) { - return isFavourite - ? MenuRow( - text: context.l10n.entryActionRemoveFavourite, - icon: const Icon(AIcons.favouriteActive), - ) - : MenuRow( - text: context.l10n.entryActionAddFavourite, - icon: const Icon(AIcons.favourite), - ); - } - return Stack( - alignment: Alignment.center, - children: [ - IconButton( - icon: Icon(isFavourite ? AIcons.favouriteActive : AIcons.favourite), - onPressed: widget.onPressed, - tooltip: isFavourite ? context.l10n.entryActionRemoveFavourite : context.l10n.entryActionAddFavourite, - ), - Sweeper( - key: ValueKey(widget.entry), - builder: (context) => const Icon(AIcons.favourite, color: Colors.redAccent), - toggledNotifier: isFavouriteNotifier, - ), - ], - ); - }, - ); - } - - void _onChanged() { - isFavouriteNotifier.value = widget.entry.isFavourite; - } -} diff --git a/untranslated.json b/untranslated.json index e9cb2d571..4789f58a8 100644 --- a/untranslated.json +++ b/untranslated.json @@ -12,6 +12,7 @@ "editEntryRatingDialogTitle", "collectionSortRating", "searchSectionRating", + "settingsThumbnailShowFavouriteIcon", "settingsThumbnailShowRating" ], @@ -42,6 +43,7 @@ "editEntryRatingDialogTitle", "collectionSortRating", "searchSectionRating", + "settingsThumbnailShowFavouriteIcon", "settingsThumbnailShowRating" ] } From 550130a2d0319bba67d79fc1c1b914639294cec6 Mon Sep 17 00:00:00 2001 From: n-berenice <82068197+n-berenice@users.noreply.github.com> Date: Wed, 5 Jan 2022 21:25:37 -0300 Subject: [PATCH 19/28] =?UTF-8?q?Add=20Spanish=20(M=C3=A9xico)=20language.?= =?UTF-8?q?=20(#150)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Create short_description.txt * edit * quick edit * GIMP magic * Update app_es.arb * Formatting and typos * Update app_es.arb --- .../app/src/main/res/values-es/strings.xml | 10 + .../android/es-MX/full_description.txt | 5 + .../android/es-MX/images/featureGraphic.png | Bin 0 -> 11613 bytes .../metadata/android/es-MX/images/icon.png | Bin 0 -> 14964 bytes .../android/es-MX/short_description.txt | 1 + lib/l10n/app_es.arb | 539 ++++++++++++++++++ lib/widgets/settings/language/locale.dart | 2 + whatsnew/whatsnew-es-MX | 6 + 8 files changed, 563 insertions(+) create mode 100644 android/app/src/main/res/values-es/strings.xml create mode 100644 fastlane/metadata/android/es-MX/full_description.txt create mode 100644 fastlane/metadata/android/es-MX/images/featureGraphic.png create mode 100644 fastlane/metadata/android/es-MX/images/icon.png create mode 100644 fastlane/metadata/android/es-MX/short_description.txt create mode 100644 lib/l10n/app_es.arb create mode 100644 whatsnew/whatsnew-es-MX diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml new file mode 100644 index 000000000..aadf5be08 --- /dev/null +++ b/android/app/src/main/res/values-es/strings.xml @@ -0,0 +1,10 @@ + + + Aves + Búsqueda + Videos + Explorar medios + Explorar imágenes & videos + Explorando medios + Anular + \ No newline at end of file diff --git a/fastlane/metadata/android/es-MX/full_description.txt b/fastlane/metadata/android/es-MX/full_description.txt new file mode 100644 index 000000000..5015acfec --- /dev/null +++ b/fastlane/metadata/android/es-MX/full_description.txt @@ -0,0 +1,5 @@ +Aves puede manejar todo tipo de imágenes y videos, incluyendo los típicos JPEG y MP4, pero además cosas mas exóticas como TIFF multipágina, SVG, viejos AVI y más! Inspecciona su colección multimedia para identificar fotos en movimiento, panoramas (conocidas como fotos esféricas), videos en 360° y también archivos GeoTIFF. + +La navegación y búsqueda son partes importantes de Aves. Su propósito es que los usuarios puedan fácimente ir de álbumes a fotos, etiquetas, mapas, etc. + +Aves se integra con Android (desde API 19 a 31, por ej. desde KitKat hasta S) con características como vínculos de aplicación y manejo de búsqueda global. También funciona como un visor y seleccionador multimedia. \ No newline at end of file diff --git a/fastlane/metadata/android/es-MX/images/featureGraphic.png b/fastlane/metadata/android/es-MX/images/featureGraphic.png new file mode 100644 index 0000000000000000000000000000000000000000..0ac17e308a0aacb15cf61c81b7fdcebf878a63a2 GIT binary patch literal 11613 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 literal 0 HcmV?d00001 diff --git a/fastlane/metadata/android/es-MX/images/icon.png b/fastlane/metadata/android/es-MX/images/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3b7a936cc63b6df13296c0c23151fc52e0607cfb GIT binary patch literal 14964 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 Z&%^t|N13Ame47-as;Gr7kv9+ce*m@K3Qqt4 literal 0 HcmV?d00001 diff --git a/fastlane/metadata/android/es-MX/short_description.txt b/fastlane/metadata/android/es-MX/short_description.txt new file mode 100644 index 000000000..03b729cdc --- /dev/null +++ b/fastlane/metadata/android/es-MX/short_description.txt @@ -0,0 +1 @@ +Galería y visor de metadatos \ No newline at end of file diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb new file mode 100644 index 000000000..9a746f5c0 --- /dev/null +++ b/lib/l10n/app_es.arb @@ -0,0 +1,539 @@ +{ + "appName": "Aves", + "welcomeMessage": "Bienvenido a Aves", + "welcomeOptional": "Opcional", + "welcomeTermsToggle": "Acepto los términos y condiciones", + "itemCount": "{count, plural, =1{1 elemento} other{{count} elementos}}", + + "timeSeconds": "{seconds, plural, =1{1 segundo} other{{seconds} segundos}}", + "timeMinutes": "{minutes, plural, =1{1 minuto} other{{minutes} minutos}}", + + "applyButtonLabel": "APLICAR", + "deleteButtonLabel": "BORRAR", + "nextButtonLabel": "SIGUIENTE", + "showButtonLabel": "MOSTRAR", + "hideButtonLabel": "OCULTAR", + "continueButtonLabel": "CONTINUAR", + + "cancelTooltip": "Cancelar", + "changeTooltip": "Cambiar", + "clearTooltip": "Limpiar", + "previousTooltip": "Anterior", + "nextTooltip": "Siguiente", + "showTooltip": "Mostrar", + "hideTooltip": "Ocultar", + "actionRemove": "Remover", + "resetButtonTooltip": "Restablecer", + + "doubleBackExitMessage": "Presione “atrás” nuevamente para salir.", + + "sourceStateLoading": "Cargando", + "sourceStateCataloguing": "Catalogando", + "sourceStateLocatingCountries": "Ubicando países", + "sourceStateLocatingPlaces": "Ubicando lugares", + + "chipActionDelete": "Borrar", + "chipActionGoToAlbumPage": "Mostrar en Álbumes", + "chipActionGoToCountryPage": "Mostrar en Países", + "chipActionGoToTagPage": "Mostrar en Etiquetas", + "chipActionHide": "Esconder", + "chipActionPin": "Fijar", + "chipActionUnpin": "Dejar de fijar", + "chipActionRename": "Renombrar", + "chipActionSetCover": "Elegir portada", + "chipActionCreateAlbum": "Crear álbum", + + "entryActionCopyToClipboard": "Copiar al portapapeles", + "entryActionDelete": "Borrar", + "entryActionExport": "Exportar", + "entryActionInfo": "Información", + "entryActionRename": "Renombrar", + "entryActionRotateCCW": "Rotar en sentido antihorario", + "entryActionRotateCW": "Rotar en sentido horario", + "entryActionFlip": "Voltear horizontalmente", + "entryActionPrint": "Imprimir", + "entryActionShare": "Compartir", + "entryActionViewSource": "Ver fuente", + "entryActionViewMotionPhotoVideo": "Abrir foto en movimiento", + "entryActionEdit": "Editar con…", + "entryActionOpen": "Abrir con…", + "entryActionSetAs": "Establecer como…", + "entryActionOpenMap": "Mostrar en aplicación de mapa…", + "entryActionRotateScreen": "Rotar pantalla", + "entryActionAddFavourite": "Agregar a favoritos", + "entryActionRemoveFavourite": "Quitar de favoritos", + + "videoActionCaptureFrame": "Capturar fotograma", + "videoActionPause": "Pausa", + "videoActionPlay": "Reproducir", + "videoActionReplay10": "Retroceder 10 segundos", + "videoActionSkip10": "Adelantar 10 segundos", + "videoActionSelectStreams": "Seleccionar pistas", + "videoActionSetSpeed": "Velocidad de reproducción", + "videoActionSettings": "Ajustes", + + "entryInfoActionEditDate": "Editar fecha y hora", + "entryInfoActionEditRating": "Editar clasificación", + "entryInfoActionEditTags": "Editar etiquetas", + "entryInfoActionRemoveMetadata": "Eliminar metadatos", + + "filterFavouriteLabel": "Favorito", + "filterLocationEmptyLabel": "No localizado", + "filterTagEmptyLabel": "Sin etiquetar", + "filterRatingUnratedLabel": "Sin clasificar", + "filterRatingRejectedLabel": "Rechazado", + "filterTypeAnimatedLabel": "Animado", + "filterTypeMotionPhotoLabel": "Foto en movimiento", + "filterTypePanoramaLabel": "Panorámica", + "filterTypeRawLabel": "Raw", + "filterTypeSphericalVideoLabel": "Video en 360°", + "filterTypeGeotiffLabel": "GeoTIFF", + "filterMimeImageLabel": "Imagen", + "filterMimeVideoLabel": "Video", + + "coordinateFormatDms": "GMS", + "coordinateFormatDecimal": "Grados decimales", + "coordinateDms": "{coordinate} {direction}", + "coordinateDmsNorth": "N", + "coordinateDmsSouth": "S", + "coordinateDmsEast": "E", + "coordinateDmsWest": "O", + + "unitSystemMetric": "Métrico", + "unitSystemImperial": "Imperial", + + "videoLoopModeNever": "Nunca", + "videoLoopModeShortOnly": "Sólo videos cortos", + "videoLoopModeAlways": "Siempre", + + "mapStyleGoogleNormal": "Mapas de Google", + "mapStyleGoogleHybrid": "Mapas de Google (Híbrido)", + "mapStyleGoogleTerrain": "Mapas de Google (Superficie)", + "mapStyleOsmHot": "OSM Humanitario", + "mapStyleStamenToner": "Stamen Monocromático (Toner)", + "mapStyleStamenWatercolor": "Stamen Acuarela (Watercolor)", + + "nameConflictStrategyRename": "Renombrar", + "nameConflictStrategyReplace": "Reemplazar", + "nameConflictStrategySkip": "Saltear", + + "keepScreenOnNever": "Nunca", + "keepScreenOnViewerOnly": "Sólo en el visor", + "keepScreenOnAlways": "Siempre", + + "accessibilityAnimationsRemove": "Prevenir efectos en pantalla", + "accessibilityAnimationsKeep": "Mantener efectos en pantalla", + + "albumTierNew": "Nuevo", + "albumTierPinned": "Fijado", + "albumTierSpecial": "Común", + "albumTierApps": "Aplicaciones", + "albumTierRegular": "Otros", + + "storageVolumeDescriptionFallbackPrimary": "Almacenamiento interno", + "storageVolumeDescriptionFallbackNonPrimary": "Tarjeta de memoria", + "rootDirectoryDescription": "el directorio raíz", + "otherDirectoryDescription": "Directorio “{name}”", + "storageAccessDialogTitle": "Acceso al almacenamiento", + "storageAccessDialogMessage": "Por favor seleccione {directory} en “{volume}” en la siguiente pantalla para permitir a esta aplicación tener acceso.", + "restrictedAccessDialogTitle": "Acceso restringido", + "restrictedAccessDialogMessage": "Esta aplicación no tiene permiso para modificar archivos de {directory} en “{volume}”.\n\nPor favor use un gestor de archivos o la aplicación de galería preinstalada para mover los elementos a otro directorio.", + "notEnoughSpaceDialogTitle": "Espacio insuficiente", + "notEnoughSpaceDialogMessage": "Esta operación necesita {neededSize} de espacio libre en “{volume}” para completarse, pero sólo hay {freeSize} disponible.", + + "missingSystemFilePickerDialogTitle": "Selector de archivos del sistema no disponible", + "missingSystemFilePickerDialogMessage": "El selector de archivos del sistema no se encuentra disponible o fue deshabilitado. Por favor habilítelo e intente nuevamente.", + + "unsupportedTypeDialogTitle": "Tipos de archivo incompatibles", + "unsupportedTypeDialogMessage": "{count, plural, =1{Esta operación no está disponible para un elemento del siguiente tipo: {types}.} other{Esta operación no está disponible para elementos de los siguientes tipos: {types}.}}", + + "nameConflictDialogSingleSourceMessage": "Algunos archivos en el directorio de destino tienen el mismo nombre.", + "nameConflictDialogMultipleSourceMessage": "Algunos archivos tienen el mismo nombre.", + + "addShortcutDialogLabel": "Etiqueta del atajo", + "addShortcutButtonLabel": "AGREGAR", + + "noMatchingAppDialogTitle": "Sin aplicación compatible", + "noMatchingAppDialogMessage": "No se encontraron aplicaciones para manejar esto.", + + "deleteEntriesConfirmationDialogMessage": "{count, plural, =1{¿Está seguro de borrar este elemento?} other{¿Está seguro de querer borrar {count} elementos?}}", + + "videoResumeDialogMessage": "¿Desea reanudar la reproducción a las {time}?", + "videoStartOverButtonLabel": "VOLVER A EMPEZAR", + "videoResumeButtonLabel": "REANUDAR", + + "setCoverDialogTitle": "Elegir carátula", + "setCoverDialogLatest": "Elemento más reciente", + "setCoverDialogCustom": "Personalizado", + + "hideFilterConfirmationDialogMessage": "Fotos y videos que concuerden serán ocultados de su colección. Puede volver a mostrarlos desde los ajustes de “Privacidad”.\n\n¿Está seguro de que desea ocultarlos?", + + "newAlbumDialogTitle": "Álbum nuevo", + "newAlbumDialogNameLabel": "Nombre del álbum", + "newAlbumDialogNameLabelAlreadyExistsHelper": "El directorio ya existe", + "newAlbumDialogStorageLabel": "Almacenamiento:", + + "renameAlbumDialogLabel": "Renombrar", + "renameAlbumDialogLabelAlreadyExistsHelper": "El directorio ya existe", + + "deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{¿Está seguro de que desea borrar este álbum y un elemento?} other{¿Está seguro de que desea borrar este álbum y sus {count} elementos?}}", + "deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{¿Está seguro de que desea borrar estos álbumes y un elemento?} other{¿Está seguro de que desea borrar estos álbumes y sus {count} elementos?}}", + + "exportEntryDialogFormat": "Formato:", + + "renameEntryDialogLabel": "Renombrar", + + "editEntryDateDialogTitle": "Fecha y hora", + "editEntryDateDialogSetCustom": "Establecer fecha personalizada", + "editEntryDateDialogCopyField": "Copiar de otra fecha", + "editEntryDateDialogExtractFromTitle": "Extraer del título", + "editEntryDateDialogShift": "Cambiar", + "editEntryDateDialogSourceFileModifiedDate": "Fecha de modificación del archivo", + "editEntryDateDialogTargetFieldsHeader": "Campos a modificar", + "editEntryDateDialogHours": "Horas", + "editEntryDateDialogMinutes": "Minutos", + + "editEntryRatingDialogTitle": "Clasificación", + + "removeEntryMetadataDialogTitle": "Eliminación de metadatos", + "removeEntryMetadataDialogMore": "Más", + + "removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP es necesario para reproducir la animación de una foto en movimiento.\n\n¿Está seguro de que desea removerlo?", + + "videoSpeedDialogLabel": "Velocidad de reproducción", + + "videoStreamSelectionDialogVideo": "Video", + "videoStreamSelectionDialogAudio": "Audio", + "videoStreamSelectionDialogText": "Subtítulos", + "videoStreamSelectionDialogOff": "Desactivado", + "videoStreamSelectionDialogTrack": "Pista", + "videoStreamSelectionDialogNoSelection": "No hay otras pistas.", + + "genericSuccessFeedback": "¡Completado!", + "genericFailureFeedback": "Falló", + + "menuActionConfigureView": "Ver", + "menuActionSelect": "Seleccionar", + "menuActionSelectAll": "Seleccionar todo", + "menuActionSelectNone": "Deseleccionar", + "menuActionMap": "Mapa", + "menuActionStats": "Estadísticas", + + "viewDialogTabSort": "Ordenar", + "viewDialogTabGroup": "Grupo", + "viewDialogTabLayout": "Disposición", + + "tileLayoutGrid": "Cuadrícula", + "tileLayoutList": "Lista", + + "aboutPageTitle": "Acerca de", + "aboutLinkSources": "Fuentes", + "aboutLinkLicense": "Licencia", + "aboutLinkPolicy": "Política de privacidad", + + "aboutUpdate": "Nueva versión disponible", + "aboutUpdateLinks1": "Una nueva versión de Aves se encuentra disponible en", + "aboutUpdateLinks2": "y", + "aboutUpdateLinks3": ".", + "aboutUpdateGitHub": "GitHub", + "aboutUpdateGooglePlay": "Google Play", + + "aboutBug": "Reporte de errores", + "aboutBugSaveLogInstruction": "Guardar registros de la aplicación a un archivo", + "aboutBugSaveLogButton": "Guardar", + "aboutBugCopyInfoInstruction": "Copiar información del sistema", + "aboutBugCopyInfoButton": "Copiar", + "aboutBugReportInstruction": "Reportar en GitHub con los registros y la información del sistema", + "aboutBugReportButton": "Reportar", + + "aboutCredits": "Créditos", + "aboutCreditsWorldAtlas1": "Esta aplicación usa un archivo TopoJSON de", + "aboutCreditsWorldAtlas2": "bajo licencia ISC.", + "aboutCreditsTranslators": "Traductores:", + "aboutCreditsTranslatorLine": "{language}: {names}", + + "aboutLicenses": "Licencias de código abierto", + "aboutLicensesBanner": "Esta aplicación usa los siguientes paquetes y librerías de código abierto.", + "aboutLicensesAndroidLibraries": "Librerías de Android", + "aboutLicensesFlutterPlugins": "Añadidos de Flutter", + "aboutLicensesFlutterPackages": "Paquetes de Flutter", + "aboutLicensesDartPackages": "Paquetes de Dart", + "aboutLicensesShowAllButtonLabel": "Mostrar todas las licencias", + + "policyPageTitle": "Política de privacidad", + + "collectionPageTitle": "Colección", + "collectionPickPageTitle": "Elegir", + "collectionSelectionPageTitle": "{count, plural, =0{Seleccionar} =1{1 elemento} other{{count} elementos}}", + + "collectionActionShowTitleSearch": "Mostrar filtros de títulos", + "collectionActionHideTitleSearch": "Ocultar filtros de títulos", + "collectionActionAddShortcut": "Agregar atajo", + "collectionActionCopy": "Copiar a álbum", + "collectionActionMove": "Mover a álbum", + "collectionActionRescan": "Volver a buscar", + "collectionActionEdit": "Editar", + + "collectionSearchTitlesHintText": "Buscar títulos", + + "collectionSortDate": "Por fecha", + "collectionSortSize": "Por tamaño", + "collectionSortName": "Por nombre de álbum y archivo", + "collectionSortRating": "Por clasificación", + + "collectionGroupAlbum": "Por álbum", + "collectionGroupMonth": "Por mes", + "collectionGroupDay": "Por día", + "collectionGroupNone": "No agrupar", + + "sectionUnknown": "Desconocido", + "dateToday": "Hoy", + "dateYesterday": "Ayer", + "dateThisMonth": "Este mes", + "collectionDeleteFailureFeedback": "{count, plural, =1{Error al borrar 1 elemento} other{Error al borrar {count} elementos}}", + "collectionCopyFailureFeedback": "{count, plural, =1{Error al copiar 1 item} other{Error al copiar {count} elementos}}", + "collectionMoveFailureFeedback": "{count, plural, =1{Error al mover 1 elemento} other{Error al mover {count} elementos}}", + "collectionEditFailureFeedback": "{count, plural, =1{Error al editar 1 elemento} other{Error al editar {count} elementos}}", + "collectionExportFailureFeedback": "{count, plural, =1{Error al exportar 1 página} other{Error al exportar {count} páginas}}", + "collectionCopySuccessFeedback": "{count, plural, =1{1 elemento copiado} other{Copiados{count} elementos}}", + "collectionMoveSuccessFeedback": "{count, plural, =1{1 elemento movido} other{Movidos {count} elementos}}", + "collectionEditSuccessFeedback": "{count, plural, =1{1 elemento editado} other{Editados {count} elementos}}", + + "collectionEmptyFavourites": "Sin favoritos", + "collectionEmptyVideos": "Sin videos", + "collectionEmptyImages": "Sin imágenes", + + "collectionSelectSectionTooltip": "Seleccionar sección", + "collectionDeselectSectionTooltip": "Deseleccionar sección", + + "drawerCollectionAll": "Toda la colección", + "drawerCollectionFavourites": "Favoritos", + "drawerCollectionImages": "Imágenes", + "drawerCollectionVideos": "Videos", + "drawerCollectionAnimated": "Animaciones", + "drawerCollectionMotionPhotos": "Fotos en movimiento", + "drawerCollectionPanoramas": "Panorámicas", + "drawerCollectionRaws": "Fotos Raw", + "drawerCollectionSphericalVideos": "Videos en 360°", + + "chipSortDate": "Por fecha", + "chipSortName": "Por nombre", + "chipSortCount": "Por número de elementos", + + "albumGroupTier": "Por nivel", + "albumGroupVolume": "Por volumen de almacenamiento", + "albumGroupNone": "No agrupar", + + "albumPickPageTitleCopy": "Copiar a álbum", + "albumPickPageTitleExport": "Exportar a álbum", + "albumPickPageTitleMove": "Mover a álbum", + "albumPickPageTitlePick": "Elegir álbum", + + "albumCamera": "Cámara", + "albumDownload": "Descargar", + "albumScreenshots": "Capturas de pantalla", + "albumScreenRecordings": "Grabaciones de pantalla", + "albumVideoCaptures": "Capturas en video", + + "albumPageTitle": "Álbumes", + "albumEmpty": "Sin álbumes", + "createAlbumTooltip": "Crear álbum", + "createAlbumButtonLabel": "CREAR", + "newFilterBanner": "nuevo", + + "countryPageTitle": "Países", + "countryEmpty": "Sin países", + + "tagPageTitle": "Etiquetas", + "tagEmpty": "Sin etiquetas", + + "searchCollectionFieldHint": "Buscar en colección", + "searchSectionRecent": "Reciente", + "searchSectionAlbums": "Álbumes", + "searchSectionCountries": "Países", + "searchSectionPlaces": "Lugares", + "searchSectionTags": "Etiquetas", + "searchSectionRating": "Clasificaciones", + + "settingsPageTitle": "Ajustes", + "settingsSystemDefault": "Sistema", + "settingsDefault": "Restablecer", + + "settingsActionExport": "Exportar", + "settingsActionImport": "Importar", + + "settingsSectionNavigation": "Navegación", + "settingsHome": "Inicio", + "settingsKeepScreenOnTile": "Mantener pantalla encendida", + "settingsKeepScreenOnTitle": "Mantener pantalla encendida", + "settingsDoubleBackExit": "Presione “atrás” dos veces para salir", + + "settingsNavigationDrawerTile": "Menú de navegación", + "settingsNavigationDrawerEditorTitle": "Menú de navegación", + "settingsNavigationDrawerBanner": "Toque y mantenga para mover y reordenar elementos del menú.", + "settingsNavigationDrawerTabTypes": "Tipos", + "settingsNavigationDrawerTabAlbums": "Álbumes", + "settingsNavigationDrawerTabPages": "Páginas", + "settingsNavigationDrawerAddAlbum": "Agregar álbum", + + "settingsSectionThumbnails": "Miniaturas", + "settingsThumbnailShowLocationIcon": "Mostrar icono de ubicación", + "settingsThumbnailShowMotionPhotoIcon": "Mostrar icono de foto en movimiento", + "settingsThumbnailShowRating": "Mostrar clasificación", + "settingsThumbnailShowRawIcon": "Mostrar icono Raw", + "settingsThumbnailShowVideoDuration": "Mostrar duración de video", + + "settingsCollectionQuickActionsTile": "Acciones rápidas", + "settingsCollectionQuickActionEditorTitle": "Acciones rápidas", + "settingsCollectionQuickActionTabBrowsing": "Búsqueda", + "settingsCollectionQuickActionTabSelecting": "Selección", + "settingsCollectionBrowsingQuickActionEditorBanner": "Toque y mantenga para mover botones y seleccionar cuáles acciones se muestran mientras busca elementos.", + "settingsCollectionSelectionQuickActionEditorBanner": "Toque y mantenga para mover botones y seleccionar cuáles acciones se muestran mientras selecciona elementos.", + + "settingsSectionViewer": "Visor", + "settingsViewerUseCutout": "Usar área recortada", + "settingsViewerMaximumBrightness": "Brillo máximo", + "settingsMotionPhotoAutoPlay": "Reproducir automáticamente fotos en movimiento", + "settingsImageBackground": "Imagen de fondo", + + "settingsViewerQuickActionsTile": "Acciones rápidas", + "settingsViewerQuickActionEditorTitle": "Acciones rápidas", + "settingsViewerQuickActionEditorBanner": "Toque y mantenga para mover botones y seleccionar cuáles acciones se muestran en el visor.", + "settingsViewerQuickActionEditorDisplayedButtons": "Botones mostrados", + "settingsViewerQuickActionEditorAvailableButtons": "Botones disponibles", + "settingsViewerQuickActionEmpty": "Sin botones", + + "settingsViewerOverlayTile": "Incrustaciones", + "settingsViewerOverlayTitle": "Incrustaciones", + "settingsViewerShowOverlayOnOpening": "Mostrar durante apertura", + "settingsViewerShowMinimap": "Mostrar mapa en miniatura", + "settingsViewerShowInformation": "Mostrar información", + "settingsViewerShowInformationSubtitle": "Mostrar título, fecha, ubicación, etc.", + "settingsViewerShowShootingDetails": "Mostrar detalles de toma", + "settingsViewerEnableOverlayBlurEffect": "Efecto de difuminado", + + "settingsVideoPageTitle": "Ajustes de video", + "settingsSectionVideo": "Video", + "settingsVideoShowVideos": "Mostrar videos", + "settingsVideoEnableHardwareAcceleration": "Aceleración por hardware", + "settingsVideoEnableAutoPlay": "Reproducción automática", + "settingsVideoLoopModeTile": "Modo bucle", + "settingsVideoLoopModeTitle": "Modo bucle", + "settingsVideoQuickActionsTile": "Acciones rápidas para videos", + "settingsVideoQuickActionEditorTitle": "Acciones rápidas", + + "settingsSubtitleThemeTile": "Subtítulos", + "settingsSubtitleThemeTitle": "Subtítulos", + "settingsSubtitleThemeSample": "Esto es un ejemplo.", + "settingsSubtitleThemeTextAlignmentTile": "Alineación de texto", + "settingsSubtitleThemeTextAlignmentTitle": "Alineación de texto", + "settingsSubtitleThemeTextSize": "Tamaño de texto", + "settingsSubtitleThemeShowOutline": "Mostrar contorno y sombra", + "settingsSubtitleThemeTextColor": "Color de texto", + "settingsSubtitleThemeTextOpacity": "Opacidad de texto", + "settingsSubtitleThemeBackgroundColor": "Color de fondo", + "settingsSubtitleThemeBackgroundOpacity": "Opacidad de fondo", + "settingsSubtitleThemeTextAlignmentLeft": "Izquierda", + "settingsSubtitleThemeTextAlignmentCenter": "Centro", + "settingsSubtitleThemeTextAlignmentRight": "Derecha", + + "settingsSectionPrivacy": "Privacidad", + "settingsAllowInstalledAppAccess": "Permita el acceso a lista de aplicaciones", + "settingsAllowInstalledAppAccessSubtitle": "Usado para mejorar los álbumes mostrados", + "settingsAllowErrorReporting": "Permitir reporte de errores anónimo", + "settingsSaveSearchHistory": "Guardar historial de búsqueda", + + "settingsHiddenItemsTile": "Elementos ocultos", + "settingsHiddenItemsTitle": "Elementos ocultos", + + "settingsHiddenFiltersTitle": "Filtros", + "settingsHiddenFiltersBanner": "Fotos y videos que concuerden con los filtros no aparecerán en su colección.", + "settingsHiddenFiltersEmpty": "Sin filtros", + + "settingsHiddenPathsTitle": "Ubicaciones ocultas", + "settingsHiddenPathsBanner": "Fotos y videos que se encuentren en estos directorios y cualquiera de sus subdirectorios no aparecerán en su colección.", + "addPathTooltip": "Añadir ubicación", + + "settingsStorageAccessTile": "Acceso al almacenamiento", + "settingsStorageAccessTitle": "Acceso al almacenamiento", + "settingsStorageAccessBanner": "Algunos directorios requieren un permiso de acceso explícito para que sea posible modificar los archivos que contienen. Puede revisar los directorios con permiso aquí.", + "settingsStorageAccessEmpty": "Sin permisos de acceso", + "settingsStorageAccessRevokeTooltip": "Revocar", + + "settingsSectionAccessibility": "Accesibilidad", + "settingsRemoveAnimationsTile": "Remover animaciones", + "settingsRemoveAnimationsTitle": "Remove animaciones", + "settingsTimeToTakeActionTile": "Hora de entrar en acción", + "settingsTimeToTakeActionTitle": "Hora de entrar en acción", + + "settingsSectionLanguage": "Idioma y formatos", + "settingsLanguage": "Idioma", + "settingsCoordinateFormatTile": "Formato de coordenadas", + "settingsCoordinateFormatTitle": "Formato de coordenadas", + "settingsUnitSystemTile": "Unidades", + "settingsUnitSystemTitle": "Unidades", + + "statsPageTitle": "Stats", + "statsWithGps": "{count, plural, =1{1 elemento con ubicación} other{{count} elementos con ubicación}}", + "statsTopCountries": "Países principales", + "statsTopPlaces": "Lugares principales", + "statsTopTags": "Etiquetas principales", + + "viewerOpenPanoramaButtonLabel": "ABRIR PANORÁMICA", + "viewerErrorUnknown": "¡Ups!", + "viewerErrorDoesNotExist": "El archivo no existe.", + + "viewerInfoPageTitle": "Información", + "viewerInfoBackToViewerTooltip": "Regresar al visor", + + "viewerInfoUnknown": "Desconocido", + "viewerInfoLabelTitle": "Título", + "viewerInfoLabelDate": "Fecha", + "viewerInfoLabelResolution": "Resolución", + "viewerInfoLabelSize": "Tamaño", + "viewerInfoLabelUri": "URI", + "viewerInfoLabelPath": "Ubicación", + "viewerInfoLabelDuration": "Duración", + "viewerInfoLabelOwner": "Propiedad de", + "viewerInfoLabelCoordinates": "Coordinadas", + "viewerInfoLabelAddress": "Dirección", + + "mapStyleTitle": "Estilo de mapa", + "mapStyleTooltip": "Selección de estilo de mapa", + "mapZoomInTooltip": "Acercar", + "mapZoomOutTooltip": "Alejar", + "mapPointNorthUpTooltip": "Apuntar el Norte hacia arriba", + "mapAttributionOsmHot": "Datos de mapas © [OpenStreetMap](https://www.openstreetmap.org/copyright) contribuidores • Teselas por [HOT](https://www.hotosm.org/) • Alojador por [OSM France](https://openstreetmap.fr/)", + "mapAttributionStamen": "Datos de mapas © [OpenStreetMap](https://www.openstreetmap.org/copyright) contribuidores • Teselas por [Stamen Design](http://stamen.com), [CC BY 3.0](http://creativecommons.org/licenses/by/3.0)", + "openMapPageTooltip": "Ver en página del mapa", + "mapEmptyRegion": "Sin imágenes en esta región", + + "viewerInfoOpenEmbeddedFailureFeedback": "Fallo al extraer datos embutidos", + "viewerInfoOpenLinkText": "Abrir", + "viewerInfoViewXmlLinkText": "Ver XML", + + "viewerInfoSearchFieldLabel": "Buscar metadatos", + "viewerInfoSearchEmpty": "Sin claves concordantes", + "viewerInfoSearchSuggestionDate": "Fecha y hora", + "viewerInfoSearchSuggestionDescription": "Descripción", + "viewerInfoSearchSuggestionDimensions": "Dimensiones", + "viewerInfoSearchSuggestionResolution": "Resolución", + "viewerInfoSearchSuggestionRights": "Derechos", + + "tagEditorPageTitle": "Editar Etiquetas", + "tagEditorPageNewTagFieldLabel": "Nueva etiqueta", + "tagEditorPageAddTagTooltip": "Añadir etiqueta", + "tagEditorSectionRecent": "Reciente", + + "panoramaEnableSensorControl": "Activar control de sensores", + "panoramaDisableSensorControl": "Desactivar control de sensores", + + "sourceViewerPageTitle": "Fuente", + + "filePickerShowHiddenFiles": "Mostrar archivos ocultos", + "filePickerDoNotShowHiddenFiles": "No mostrar archivos ocultos", + "filePickerOpenFrom": "Abrir desde", + "filePickerNoItems": "Sin elementos", + "filePickerUseThisFolder": "Usar esta carpeta", + "@filePickerUseThisFolder": {} +} diff --git a/lib/widgets/settings/language/locale.dart b/lib/widgets/settings/language/locale.dart index 1cdbba07e..061dae6ed 100644 --- a/lib/widgets/settings/language/locale.dart +++ b/lib/widgets/settings/language/locale.dart @@ -51,6 +51,8 @@ class LocaleTile extends StatelessWidget { return 'Deutsch'; case 'en': return 'English'; + case 'es': + return "Español"; case 'fr': return 'Français'; case 'ko': diff --git a/whatsnew/whatsnew-es-MX b/whatsnew/whatsnew-es-MX new file mode 100644 index 000000000..38c70f8ac --- /dev/null +++ b/whatsnew/whatsnew-es-MX @@ -0,0 +1,6 @@ +¡Gracias por utilizar Aves! +En la v1.5.9: +- vista de lista para items y álbumes +- el mover, editar o borrar items puede ser cancelado +- disfrute de la aplicación en Alemán +Registro de cambios completos disponible en GitHub \ No newline at end of file From 0ab8704fd8dc07423ade6034d13d900ffc55a79b Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Thu, 6 Jan 2022 09:53:23 +0900 Subject: [PATCH 20/28] fixes --- CHANGELOG.md | 12 ++++++++++-- fastlane/metadata/android/es-MX/images/icon.png | Bin 14964 -> 0 bytes lib/l10n/app_es.arb | 16 ++++++++-------- lib/widgets/about/credits.dart | 1 + lib/widgets/settings/language/locale.dart | 2 +- whatsnew/whatsnew-es-MX | 6 ------ 6 files changed, 20 insertions(+), 17 deletions(-) delete mode 100644 fastlane/metadata/android/es-MX/images/icon.png delete mode 100644 whatsnew/whatsnew-es-MX diff --git a/CHANGELOG.md b/CHANGELOG.md index 195c78e88..1817619c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,20 @@ All notable changes to this project will be documented in this file. ### Added +- Collection: toggle favourites in bulk +- Info: edit ratings of JPG/GIF/PNG/TIFF images via XMP +- Info: edit date of GIF images via XMP - Info: option to set date from other fields +- Spanish translation (thanks n-berenice) ### Changed -- editing an item orientation or tags automatically sets a metadata date (from the file modified - date), if it is missing +- editing an item orientation, rating or tags automatically sets a metadata date (from the file + modified date), if it is missing + +### Fixed + +- Exif and IPTC raw profile extraction from PNG in some cases ## [v1.5.9] - 2021-12-22 diff --git a/fastlane/metadata/android/es-MX/images/icon.png b/fastlane/metadata/android/es-MX/images/icon.png deleted file mode 100644 index 3b7a936cc63b6df13296c0c23151fc52e0607cfb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14964 zcmZ|0by$<_`v*K=#0X&{r5Phs1XQG((J3k=sf37>fb>ARQ%aB&6=_gFIt3mCq`MK2 z?i}pB`2LRfc>j7mczoF0?p^nFo$-lt;W}E%6r_x#5D0`qRRygJ{@;B49|;loTX*_3 zF$BU6QANu=@yOWn_4Ii1cjD@Ch1UJVXg;b0s<&3ehL-Aa9Y4nP@*}E#99Lo{*GrA) z-}f68x)spkqhx1v?tC{8k(Jd^gUGdJ5+W)b6Vp_!D|!XHf1}baPCA#RT&!0#?x$sR zTeeP*9U1E%eM@thJ!#DPYm%w39Lk0{UHJe1>jCy9RVo`MEFe=wxQL#N!F*%OGvsKO zA7i|<7CtR+N%p;xori8ZGWf8?>s+D_Gj}Cs0*^6cR$y*`&twkwkud%DApB#nSh*9t zcfxTuD-aje2elHQ<8VGzTxcuf@zzGKCZ=mIJ0VHu>fCoyb8~ZpM@B|u^a`Gw6ppMb zGcqyZ#wRDwd5ZNeKAV>I4{sbzfj=@28OSd*xF5e&R_-i_iHT7+F)^XFt~4Z+B|8~- zC7k~B`Y{U&ONzL-xK^6R=huNPWu*-bmya7vn@mc_Y+n7C6nXaS8E$xd{8age(Pfxg zer;22?Rlr7q9WPJ_KZXbvtoNY5_QAdf+V~(RxT)$WR7~EEemu0Z#*C%AozCDx`M_DJBjM4(Pvv+jJE-2^n~a{ajdBI5_o*nQv`<{hJVDH1YNGM#We5rlzye zC_Yk~fZa$7$b8hHd{Bzs)2B0|U}wH{TZ)rsk4&Ks9;WuL(B%zyl>PW|)SsW9UyZ#w zX=2lA1#fC;TZ=xlh?P1vaDDo8#2zf2BC*>Q-@j!F${6rcBu!(^?#JZVojZ3Neb)=V zJnur1T&&I02a=SX9P>3SsL0q3q6|Gf&se+~u6!#ejCbtc8run`_a1@A4g351Q#_g< zP@Ky5%DU$1<=NQLJ}X;XXc|*+=TE*2**{G0RdY;irX>4qU8xHmclY;ebJ}fDsZ~8{ z5ePD6M`Lx6q!KPGzcs)iyZbGOpAL+SJRJo0HU@&zg6@b9DpR}g{?yFx@(2oEQZ@0~ zVgI=Od_V-;FgJK&v zR#s9lM}=nPPe18Yn5Z>yE1K^d8O8`C_57uL>*n^<(|Zw1MGs;p>Bo;BP3%b96XVc7 z4?oaNcwd4gKC5<^QE4#6l~?opN7=u)K2pdgbNENW*^-H(XjUhKN}yTIoO%uITF0BF)99 z>%{9i=X22e{`mO#ayFAtrarQ&s!B>*>S&m^Vv~)$yS6zKyWn@ZVV0=LJ*4PJ*~k`1 zz@7LH+au7FAx{S%A0w2exOgt&(;35(C?PH$Q7^TIA?J5>bv2%?ue1&yeZKJQ>({Ry0b$7QS~R1IblLs(2-B)=TIc1i_`kcQ2Nwg%;2^7fFOKh`Hl*bQ zDfYdZ8`qMgcAE)QKYsj}G|Q?X<3oM@+sTZ(*?o9-*P;%w{q5zr6Y58F$Yzp`$ow009fv-2-$t98A+v(4jMhi__ z-ai|=xTGQkTZn*D@Tf}(uIX}w4zKS2?ZItq$sM%0rS@9)5#8Z8Ys5BLss@h_u4StP zp9Yr(g&}`MGttjYOc>70(XVJnh}iSC(sveBR#v|Hoq7*usTn-}El!`X`Q%C$8NtuT zhYhR~=w@p#Dd8vH*xWq)3cFm(4fx#&;GUDILhp{~Wz>^fU zd}r>&g4UVv@~pU+m>a|o(ll7_<;)?F!KI|{WPTklJ$w8(aH%sk@6)IDg_+=@ygUM> zJUBQKQQvc+??%P*9^q1Y;0ngKlYR}chvVOv#r;luK>1VR*6Zh|Xc5L}LGZ&seq(fY zmOlb^X@6Yjbp}{riKzz@zwtgaF&2&lbHO9(Hwg$r>8ojiQ#dv_xGme$7?<%hIsf*C*dj!g*urzY8=EvQ=#` zC1qq}jE{``)jbiuTHbXT`1MQa)924`6a7R#_nmQVpLXCCo&T*iIO=iSTaq4UX%Zc- zlloIRQ)SI&&F@1xa1Rz1Rz|u|yHV2iwa+h>g$=8enl^J2~bdW z)Y-N9FTpdU&L6|*39ruj{c?Z&NU978A)5BuD9(V~_7lHRlz+1f~%)H5Kn@$UE2?3c?-SB#Ka`C(SrgNNAj_{I@!wav^JQz!+E7Cmy8+Y z_sZ%WQ|ZhsVYg@&;tV4}Z$i zO1k3;%$$!0lA}V!zz}AW=}9F1QOgw$PLgFg_<3r+jPag?+xoC&Wu%nk1ggA#*j!My zbWEXG@5OYDa|VR2$YbQe@=o5#>Xg0R4fD5?WMNTJ3+OV}f4VfZv^mAay@g1po&&PD zIa3Ieh(nNkj;w+L0V5-$4k$QDcl3{WAs7@1IeCx<_kjv(ZF93I>%Ns5sbRzdo_e6c zc^iwDgzMTL0^c;=E7Xtb>Lt)NbPAr-EQBzL;%TJgrsNbcjWacinrJ18Pz~-t4emB@ z^EyUGuPXQN#Ua)tuo3XEK`5N`kxI|V|+@AjQL)yGy9n}pdz|8&gEVtdm?TzWEKoSp2KozI9euq7n( zRTq@z#|f((kzoNE+zKG`9cuq^^CQSVkxUYuh_9$8ua)k89t9mruvvYaZO%yW_x1@?4io+Wnd z`+d|31-bJ+ITHDi5OW-F!$+<7E~PuTB_DJ(2%&j_`avg$66@y07MYhr!)@Zs0O zLIo2Omj8NsOz0rnStQdk14__$@7{SFPuS#)Z4h>TEd>QT7%h}Q#7l7wpD4bX_TFh2 zBXBzF{s_y?&nJBIlezBgR_oVCd7^kx%x)}$P~^Lw_7kcz$2uI9gNX~|0lkzxHEeKj zkO+l1TsixqnLb*%>i!7g*mNoUt*}rG?|o9zPhS7a6-;bg>jQ+*krj~v2f)kup zL_|cH+lF3?ir{#c-_&KOQ;+Y(J(qz8AX7u0+ztkz2D;cIV`IKr65~BABK(t0|4s;G zeSMuhr3bW<7@jPj1nTB3Rk+}+NDCKUMy(j(LQ8T|j=w)7L&Cj}o3!SRKj>xlXrTbU zTR%J3A)$Y@k&K>4>m$tW+yj`KPYOqJfBwAr>IWk@iWC8}Unyo7TWT1fG-b=jzyBBb zWrzK7ZjrLLlb(+j85*OgUW5SueOzj4 zDyni~diKkg;9VsK8B$O`@~W!nz>1VWIRpLkXPX@-g2$qp#V{}iTU{a+mcBC+nnsrO zPJ@n%0d{@|-)6bVV-LA{#)VMr&un7D*Wsm!>-j6{cO|*fpVbdz=a7U7XhkYsu@tNs zb81}GW<_@lC5Cd=OkMDxP}xOA= zK)%J61q7#|OXxy^(+!-(aKn|~=6Ly+892mGe!`;W`1tMGkrx03Zwlgc3(#(E5BW`M zgMRM`Z0F77-VRD%7lDV%n2vSHDa-SG)cz9U0 z;K`eG3pY16UVi>xU$QU}Hy>CZhGX&Y@C?eI6N0Nkqm>9gYVoMC?9RB)Ny#OPvqwnk z5RYeFMRDg|gSV-3P6+01vv#nNko+S^f}mQ9_beY6wJ zco9LNp`4PE%nxk(HeFCbGcDBypz@xRjtJAHI?^tH6~ zx}qBMcY;#xbbe?nc{1&yqN3st%AtWl>f=bz)V@6}bUNNN1gR+`A`(f5r>3S>6>lZu z|MA9A7{maNSpgm1+uPeYO$$jkzTm6_xM;f3zkvkG+46Oxd^x5V#1vMAJxK`()}~wV zT|2(naG+4H(79jg#A!K}jlYFQpxcYPDRS0K1p*QUtV*YF1pK^w`Enj92hiXC!GZJU z_&p!&Ugvm)71=d>OiK$dD&n)Wva;A1EgrA3XS%^69#mg1dA&8|*kA${w39PlSkpH86hH)wx(d^$qn>xb_+%+9?) zso)-bs^Cpm(E%)b31O@K!sT?=qP?@z1>fUxz;rNE+QZKq@AY^Y9ynRBNPnd~IZ$wb zG64|rBQvx9`~pC>uMvfu93CLUap~!iU}?*%tKifiKv3Wn5C{dG%&1riKr(>MUrMwB zZukbNhNPrzy+ODOHvIEpDApGaE}IufQe{6C(%z2Ng2Fg?%=fm^iCNAZO6#z~zZqf4e1?mzS^n|)9xF+4Jo18M+}Rx(D0hqJ-2q`~;NlOir8KyqcA>^PneMfc^arS31sO~>M<*K`4G0zC>NbO*6*9SeT{(~-zg`j3$uXQ6?%fGFu>noKCYq0U z$$*3JzlW+VEb|TCC(_7d2;@Yu`4|Rr&K%jKgCe1%>|E@KWV!KY?^)Tn09A&x?}vk{ zL|v}0SdIGWxikpq)+v>CKa939Wm;bX^$P#$ZKpDf|GjA)9UcAh)vJoLonZdnlati0 z*#>Vm4$4guvSLW|@9u6-Mf4SclQ#71?5s6@zDhGN0(%@UdpmMLPkE&`ebwo0m?L&( zKaiO64SuQ&7r7>}S2)D?@ap(~-~`0B>NCIvCMPFr_~FOTU)tI(L@AOf`81gJA3aB0 zRA37pe=+6Sh$(x?qd=(d5AsNwCiJ+9iW7lGquDX-9w}(SKg&&Sy>ipogUi&PYlK1S zoK$plLjri>E-`{iIe9&M@382f$iuDscJ$dd#!&m;PIvKd^P?|1^9f9}w z_waTP-eSh;XT?t^aJXPb>u_g{`jV1u34|$Zy&A%tEL&>Yus$%0^QD!uvf?5oCB05v za073UVQlx{>f2q6uFFk-rA<*J)zMH;@iJB|XTSgWK?_3l>$h*MgZb)kMn=+b6pg@D ztxP8?b9XNg3K2&pyiy^o#ui`_Vs z)!uHMnfB*!c&h4bG?(P;q z!Nu@2nAjpBa9wB)8ES~c)C&RsS>bKtdz7v3GcqJ&irj-Pe0jCj5I(Jy{nB+^iH;VO z_mJ;EdA77r-;ux&Z%KaQ4B36 z#CPM*Pne1f8YqY8B?HR@(=<__yag%cEeNMCzY!xPBm0>oz#YK!MNMa~ z;Ki3}X|nm09DXwMvIPOZvz|8yX93w=nl;b>&$C5LOJw5N#l+I6-&Ly2YwjQPN+b9T z${^QZ1n>&5VtcJFYisLCbijtLg3duO&B}}W)m=Lq_@_XC7>x`2A|E`N9W~wLhr=C= zl6l=mctm35@eRto*sS?{`nVB%Cbcx6=Yy1(?k)Q7Kegq*e=UYSYlcNeE`2cU%|c8n zpw=`Yx>5gz{!9~c4H1^t=KW23m5iiv3hg{V=&n9<8> zY4y!@SKJiN;JeUUBx9G6!LiL^9%cK*v){TEURTLP`!TGeI+7aJj+CiPI>7{{MVg@v zJU%I)04kf_TN)hHHLh{O+ZZc#N5DSu$Ip!sf+lovBfN%*uZGOn*cj{R*u)!H7jHgQ zWe>XjJX%l^!$&t$>-i_%&&nN@`twv#rPb0HWl-b90U`?W(>Mk!7IaHz<0Huv zkd`Q~b6&b@Cx6}2aNIo(WxVt+QXvLBYX(R)fLAA1x9I=2nZU~)e;QR0M36bo2z~A9 zuQpV_igL0gr-!ZdeT)R^h;spz88ltkULIW3{j-hy8$mT3?KTRba)E+i?zFXpBqSsV zsJ1Ur1$43l-9z#%Ri8->5;+>Z6WPz;`rApt034;UXH z@VdUj4d$Pt7@sECw~dYKJs#;mUxyESqT+7c!sDfhSVAp&vUA{l5)u|( z@w!m_n9~&RKQkN=&J-BjfW|MsUX|W-CFKLUhA^FQ8laoSsh|uhEMae4TnL9aR^yxF zv7-DPjyvQF&}(cjBqWsm7_QP z-mm8xS-QKsGf2E5ptDHae_7A$SDsI)lv#EoLRFeeVVj(C-A%3L($;r^`Zk?D^?uKQ zQrx(q^!BYVFcJDmvI`0dzCMHGGKaa;kZYs!ei+#YSDSqoIpjeAm~+Aat5?EE;2m-y zz)cTuBd#UAU+v}@7@jCjzbW?xkAE#XK_Le9ey?8L?)xZlO>DIwNH?cl1^`n}@xuV? z0D_YdIDa?7%rIzR@O2YK&~^I`KkrW9>gwsCrfC7~6wbtCH`KT5?jAf}+Cij?TP+7#T4s(cz(Bpt?CFp(p8m*-Crk#?4LQ!5Gf4d2XSzcbwuBb>* z_scK|x%nDU_E&g#HEzh^G>EPy_v1pa@1;)q*BQ{dyU_MCa$}&pKuQVUG2X9Z-LY(Q zyY$}HuO9*N{(i4>fnt`zZ_>KZ7FupUC0z8Z`Ua&8R`BY$y2)a!#H6dYR}r*Wpv=@b zP&LL~LHxTdUvcH$%cPQ}p_a9=`7$~suzq8jwxw>TG4fHKl3^XQL^yO7bxmdD08%~q zD8L<*kbp35^tlP}k1=IGC?mt8qq#XbEx#@z$6Np838rZnWkSx~ZYiliIKy~HUJEld zT;^`KK82S>@|2(ffR>?J-nok?(`MQ0Xg+rz9T@>2&w1I=)^<+?9As`~<qY z{dR;bFM_Y$E%J03C=T+=^bt_LP8;O>$+qigQ(}D9T!Uwsw;u$UG`}41%AQ$VBK+HY z=X2O0vKcDZ?UY4=j?A%}(P_U9>2Kxk7j1hmh7>hurTzJTV&1a!+kroSAU}+H;aLxx zt(L2I2!zH7ArQ2Xo(5joMY9IvMg3*K^BmBN?%j(A74>G)Flcl{XZl4~L_PX!&HE$7 zg7#NNK|w}M>wu14Yhf_NT%%`kX*`<~mH*U9KxjQcrRe7Eg^9Q2_FBE&fVkYS8+hI?_0g zqq<#Qav@+?0SyigHlEtgk;OC%K&s~*#NVf-0o4GR_t&>SFh@dN;QZb~r#_1mnxyubZK z=qzQ%1){z)89BV+*9SA7t@r#|Tudu@vUCT@z>9Y}Z6WGDUqZsn*`g9jLIgh(gJWM) zev0n~O5}GRyZP~ZSuyxZdF3NZ_`ujukj57bqlH<<=LtII+kyA*KS&o`ss^t`1L5TA zv{MG?%_BBiev=BNV|MLy{%w!Ks;Y#uYAOGi9f}~zBlum2$8P zl3cU+s@FF`K~ z!k_?_i2&2ID zEPzUTn^OS(O9uzm!<0oSJPggeU$c=4w8+;JfQhQt69E8K?;~4Ch~WSc^=ymz8`_p~ z3JNlSbppf@3iJ;H#1SnX;PCo!#IXuIRFA$X<=q^%ewe4yoNtPJT^Q*0 z5fP-zD=Vbv08|P0E;l#}U&!MXnNXm7L7~w3S?`@--Gak{XXZXHg+3s)Vg7Z90^I^4 z`xkuLt)`IUN3vfjZGmXYgWstV=l`?kLA}~l`u1dK%_A}@Dk}f%Z1&fa-(k0t{Zjo zyP7r~daN1l_&?Uwby^?3^>yS`7OkwC+tCSXFyG@1w?EMS+Ga5W??R}~G`XoKr>5co z3m-G{V=2@tZUu;IZ?}zdX-Hc}QB#v5g|xh9jb6YM4b-q}+&eRoq{#i^s@L!m*(kBx zWbtnfemi_&FpHSYCvF3}P<1ddcpyfq|l_8v`Yv1n~9R2Ixt&}Phdv>@gghm;7A833Or=6IX(2@|T zRH34w334(-uJBE$#V=`N3OYGVjV3Ce< z-konrslUu6AZ$FbQO^XXT~CU@Dm3ou*L`5msr%X8wA{O|1x@7EYz658Rg6nY3JxQ& zBTE#t^#K*@ZaEAHXx1w8euHJZZ<{`158j;S^r-Z;wO1zjdi`Yv#B!;}*9N&|uxI`!LvUOLUsV!gZG~t;3 z-w$*Zr=h2RG5Os#0N=cQ^%+pqOrZ^m0gc{QnsK|Nra&$L;us)4ITaNM(%@F^qrJa* ze;{Rm-Ho6YmG_~9yFk9TN~$(P!;XvX;aZC3Y%tV&A>f{-V_ZBH^P_!0D;S_J7Y)T~RC3!wRT*+WLfD4`7b&?Tvr8r3UC5l;8zfjG3MP z203UEoWjCy5%B2sx1)m&+qFMn+$jH-NHtV$xlys+(iZ(ODhLV;q(9TsQ2_z?8fo`~ z)oqOFnjIqZFSodv;NS^D9<2-~s+O~Z)%c_&!ZeLv;mx+Fd2bBs*;!_mHYQqW=+2q1 zCIl!NZfK#BXOMf(N&QVCqqut%1Ml5VdZoe^{+9b%lskczO^wpke;-$Z0uH%~#PbV3 z1mv?p8NctqW;{-YpZ^9h;Oe=%C?P<2^qj$0*`)KI8sqo~$}jyjsWWqID&uLPhcrPP zUcHjXU@&vgvS;IfA{--~7-6ZaW(m>^28oD_#Hk}^h*!vDcXfYH zRNBZKACq|_a!HzDdt01x9!2&j(YKqa^VSn!zyVHA-$^Ye`T4E~e2Bi`b!8tLJ-vHx zJR3wSOZrk9@$Wu4TL6zdC5!lBZME5WqEi)gy#iv)E>o#k`bh!7@(b?0zuYbAx&LX1 zGQR`xx}zl z|4l-37uGdk12hgz32ds?a6v@6`(H2Iu@#Ot-=6;2)!$!zmaOmTDF#w;_h`&)shcMY z?d2s-ch@NBdQM=x(uNZ7!=IY``YGa}_-AcDn&>@T}!1+f2M=bjTyQe zUxchocz?BLzksZY2g%s?w==bF)1h1Aom+s@=olME0VOy?%9{?nK@m(V01Z&**)y_y z-GBR`+aaW9(Mp`wptJ`Tb+Vt3NgXt{viHy}W{g@Qd{x9Ud77Xw%|IM(t#i`2|!f ze_0%4+WVSeXf5k!|ccKkBa5 zLd4X?c3rGQ#c!29k>hxY97o@q(Z4vfF%S@0;}@uXR)`ry{_>rYA22orJT5w<2+N^= z=oek^$9?HPONvHQH-jvAq_5u@-;kdl%qekEk*D>gZKO~ixS-zH)+HU33@MMi3$%_0HqpGQL;$t)?dTN2JPcTbSBUGkFI4#z%O67R z+P;a*dJurBY^|D@HRtZ?>h!58Ijmm?ydgP^J1&ggA_&S}gLwb(<7HztaWnN=A*6lx z{P$n$w%Vj?<3X5_J<}*<6!gd-j`fTgu%^H4kaRbHs_rHJt zT)L}nCi8~QZ*mxrP!9xwfWI@#N>VFZb zRz?Nv3dZPPw&yOzWb=SHv&DtR$dbVPNAQZMSmn?!?%%(U85()`y@DMC6ZPJ82qf44 z9Bw(NjIme9H_7<|7|XlrS+9*T%NacDF?Pkf{%819^~{Mtbp4T&Be%MR2dBQIm33@y zaycPQ)u zx)uvDM&i=d;RyJvr86)oIQgPZKCopzaf?(c7&P;%2kT-udK1X8a-JcJ;9DS3#=5$a zf7FxPCTXT!B=fuij=QC$rKs!c7s?+le>QQ7OGuPEFa5%CZNPLeZ0rLEz=Ya8AwYD! zA^BGe{%|`|Mj5O{LX5c@<5p8tP~)|38*vW$YcL>HStCDau+~85(jh@NesNI`v;#SK zXZN@{j8Gritg?CgjK1MR$nL2V0QctOWflz5KBA+gW-`lDv|jk3lx(wd=)|r`(vN~^Y^Qy?i(I4SQf!b$F)$|yRfNOUB{*4W6G2cCAIAVGZGvBlOM?wzU967k?-eDvs zBDnwH0Zz70-%pYs!i|3+^b!S+2HIC9XpqK#x8AJ_v{N;=>Zh>t@&DdAQ)~ye=eNMX zz%Ow}hhHw&W0i;48X@>y#~&;u4M`JJ*?e|evV0n>)U=D?Ch+Fmu|aI6nI%KCQa$eK zLLff>l%=d!z@|)wStO%2a$$FO*A{5+1xLsJN5l?f^LLt7PiRVve}77Q(^K3u(g-neezene)rKV9d+HZiAMx6b-6ZP=+@S?+#|mq6ziiD?ENu zW>Kek7c&z&D||3GT6U(A37kz;)&EUtwYRsAlURU|y7ub9$=FLPdKEwXMoUH%h<9^Xrw$H{PKXDbHRu`IE-5>Q_>UT;NVa!pjA|v{|A^)fK$-W(JdbI%XHdT z&xCya$_u8@7IQ*b<$FMdo?nIt`o{j(dv-hsZ`*LNGhBt1T z;HF*rr9iLUa8Xv=(hoSb)d8L@@CnB%al-|Vucsuhk)e^1SMOU}I%)v@S+r#?4+$*A z4uzAMW2HQ1E*^}Rk|-v)xqC>$H7}PFwMRunM7+H{jErV>`YeF(=Cq+ zsHo+@s3XvINk3{47wc{83{Go&`g(o(r^^s1o)ieb!;iJ4QWFy&ud3UGoK`Nq)k=0xR}@84+}sVJ~(OklQFO+o;x$rF1_sPpk{TE2Rt-|Im_s-i(*}*Q#%UqoDiO@oD*O0gCou;j-N-v#lhe|ls&!KaQ=XJFQ@e+dQ<2cNlbO&%?zJ+Lvdv$I<$ zR4y##Vjw31n)D(tPK&{C+qtNe)TM9*P3?oOgzbv=Hcw8bl@r|xTvQUWS}<}a>sMYWXPdibirJBwc}jlUVAw8L*QIpPr!6r?jIXp41ljd1OfRCFyV#k zf1Cm@d;j6X2d!jR8|xF}`=wdaQe%8`HtELFKPQLucNMA7Rc>-ZhIw+NhWiT1biJrZ z`ZV0>Qmo8j2ff+mf8E^!fTri=Y(B`vL(6;R7?hv7V#rxfsB zo;!PA68_3giB@=AqANZ%9N(gxT4o$vwwaG?mgags!2XVp+Fsmv=M^CSiqa*ue=q!ap3JUTQn3XDXrz84izCRphYI#tIZhYj2F)5J; zU25398JDg%mkXAv-ucU^`^MDEt8Iotaq(7h&$}OyPJqat{w|}Og^;-6R+F3xYdSzk30Z?L&Zo!|y!Nobd`IeVL*F$4qjtPu{ zT~FJ9UEThZqabrVCxr93I$LGq$tAQ@bzDhEIvnaoq?K~2J4<}_j!|;!zsZS#8m>#d zEn4_+blx|wL9$>;+v-%Y9Lw3gXsDE!|Fwd?jl4qR1`i3+Xm|WYtW*s^UizRP46zVX zvPuGs*If$&JsaRqfS`e^1cpiiOiS4k2&=j+!7s{>A5;I|xTubSK^S=DwfW>03Wk(^ z+KrWbOc!y)909NKigp5l(K<=(V=w$-^NCGe&rznrJI3H!8_(6ll#LTw8D!`8E@>n> zp(C$7A&cV-h_I;c>*_rcOUK_{@ z=F!f4k|p7O!_(9AdUrrW1|yP>3=PA9lM7hlc59tc@mnu~DBs~$ld`FoM1k+>{F<8* z3jR04L9d9NqYX36a=bFD zhMJm@8eK)@`;eW5n+soDWB+~HzPFMn9bYsn4L1I?Acku1a(M@@FNjK>PP;W%9%V?B zArSJ&($HF+%g!}bh0gI|8ehjv>JE(x8hbunL5pb>I(a^0pl*KyY-KZt-ppr@(y`%a zv=+DpP3PNnJ0&KHa9o1;ENF{A4Q#W zw?V6qw&}|lxZarUaZp(f!R@?~bY4Wy>@9Wn01kCP$tLY{Jl_)7d9pQivUPnu9}r7* z*Mi)FGMn%Bqidq%4Mx{X8_!nKH%xu@I%Fh)(9mRBVcE@s{vKC^%F@=$?f=J0pmh9i z=0&?C7F~gmPAdA>t5U%wH8=HOkvK8y^fI7<3cuTkukvd%y@HUTyd$$Acc=gf9mlWu zr!Wxv_SG81kU75H!VW{>XfEm_bUr+V#EVwx9lr_lr$-FWV=0kff0W~y| z>Yn8tQo38If(c#7sDYVeHM)0ETx1rh(K}VJ8R7V%MT@sA{Uz>0r+$IB>WQ85Ih+1A zzhC7u{h9xHXT9Hcw?wn4(p7Z2$HLTYQ$wqHB=1-odM)uZ(6`VN$Y@s3gHOP|4-XGe zB;}4WsG#i(b9jNYkhgelk^H-orh*ner)wgVgA)$6*{nY8Z_bRA;^7oISn)}q3ah@zFJO~z?7G(p+r@NH^zD;xG zc#w4~!^S7zK>`_khPL7YwzEWq2#ymxeG~`_G7O_8qZJG%)>v~7$_&N*mRrtPmWrRz zKDC1!Q#%OU@P0S`%$$-ARdICqOiGF~YaWw(F~CCt`y|pr{{82E6ei)~|NF*f%PtPD Z&%^t|N13Ame47-as;Gr7kv9+ce*m@K3Qqt4 diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 9a746f5c0..9603f044c 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -25,7 +25,7 @@ "actionRemove": "Remover", "resetButtonTooltip": "Restablecer", - "doubleBackExitMessage": "Presione “atrás” nuevamente para salir.", + "doubleBackExitMessage": "Presione «atrás» nuevamente para salir.", "sourceStateLoading": "Cargando", "sourceStateCataloguing": "Catalogando", @@ -133,13 +133,13 @@ "storageVolumeDescriptionFallbackPrimary": "Almacenamiento interno", "storageVolumeDescriptionFallbackNonPrimary": "Tarjeta de memoria", "rootDirectoryDescription": "el directorio raíz", - "otherDirectoryDescription": "Directorio “{name}”", + "otherDirectoryDescription": "el directorio «{name}»", "storageAccessDialogTitle": "Acceso al almacenamiento", - "storageAccessDialogMessage": "Por favor seleccione {directory} en “{volume}” en la siguiente pantalla para permitir a esta aplicación tener acceso.", + "storageAccessDialogMessage": "Por favor seleccione {directory} en «{volume}» en la siguiente pantalla para permitir a esta aplicación tener acceso.", "restrictedAccessDialogTitle": "Acceso restringido", - "restrictedAccessDialogMessage": "Esta aplicación no tiene permiso para modificar archivos de {directory} en “{volume}”.\n\nPor favor use un gestor de archivos o la aplicación de galería preinstalada para mover los elementos a otro directorio.", + "restrictedAccessDialogMessage": "Esta aplicación no tiene permiso para modificar archivos de {directory} en «{volume}».\n\nPor favor use un gestor de archivos o la aplicación de galería preinstalada para mover los elementos a otro directorio.", "notEnoughSpaceDialogTitle": "Espacio insuficiente", - "notEnoughSpaceDialogMessage": "Esta operación necesita {neededSize} de espacio libre en “{volume}” para completarse, pero sólo hay {freeSize} disponible.", + "notEnoughSpaceDialogMessage": "Esta operación necesita {neededSize} de espacio libre en «{volume}» para completarse, pero sólo hay {freeSize} disponible.", "missingSystemFilePickerDialogTitle": "Selector de archivos del sistema no disponible", "missingSystemFilePickerDialogMessage": "El selector de archivos del sistema no se encuentra disponible o fue deshabilitado. Por favor habilítelo e intente nuevamente.", @@ -166,7 +166,7 @@ "setCoverDialogLatest": "Elemento más reciente", "setCoverDialogCustom": "Personalizado", - "hideFilterConfirmationDialogMessage": "Fotos y videos que concuerden serán ocultados de su colección. Puede volver a mostrarlos desde los ajustes de “Privacidad”.\n\n¿Está seguro de que desea ocultarlos?", + "hideFilterConfirmationDialogMessage": "Fotos y videos que concuerden serán ocultados de su colección. Puede volver a mostrarlos desde los ajustes de «Privacidad».\n\n¿Está seguro de que desea ocultarlos?", "newAlbumDialogTitle": "Álbum nuevo", "newAlbumDialogNameLabel": "Nombre del álbum", @@ -366,7 +366,7 @@ "settingsHome": "Inicio", "settingsKeepScreenOnTile": "Mantener pantalla encendida", "settingsKeepScreenOnTitle": "Mantener pantalla encendida", - "settingsDoubleBackExit": "Presione “atrás” dos veces para salir", + "settingsDoubleBackExit": "Presione «atrás» dos veces para salir", "settingsNavigationDrawerTile": "Menú de navegación", "settingsNavigationDrawerEditorTitle": "Menú de navegación", @@ -503,7 +503,7 @@ "mapZoomInTooltip": "Acercar", "mapZoomOutTooltip": "Alejar", "mapPointNorthUpTooltip": "Apuntar el Norte hacia arriba", - "mapAttributionOsmHot": "Datos de mapas © [OpenStreetMap](https://www.openstreetmap.org/copyright) contribuidores • Teselas por [HOT](https://www.hotosm.org/) • Alojador por [OSM France](https://openstreetmap.fr/)", + "mapAttributionOsmHot": "Datos de mapas © [OpenStreetMap](https://www.openstreetmap.org/copyright) contribuidores • Teselas por [HOT](https://www.hotosm.org/) • Alojado por [OSM France](https://openstreetmap.fr/)", "mapAttributionStamen": "Datos de mapas © [OpenStreetMap](https://www.openstreetmap.org/copyright) contribuidores • Teselas por [Stamen Design](http://stamen.com), [CC BY 3.0](http://creativecommons.org/licenses/by/3.0)", "openMapPageTooltip": "Ver en página del mapa", "mapEmptyRegion": "Sin imágenes en esta región", diff --git a/lib/widgets/about/credits.dart b/lib/widgets/about/credits.dart index cb2876ac7..a8d3c4cac 100644 --- a/lib/widgets/about/credits.dart +++ b/lib/widgets/about/credits.dart @@ -8,6 +8,7 @@ class AboutCredits extends StatelessWidget { static const translators = { 'Deutsch': 'JanWaldhorn', + 'Español': 'n-berenice', 'Русский': 'D3ZOXY', }; diff --git a/lib/widgets/settings/language/locale.dart b/lib/widgets/settings/language/locale.dart index 061dae6ed..2fcf15aab 100644 --- a/lib/widgets/settings/language/locale.dart +++ b/lib/widgets/settings/language/locale.dart @@ -52,7 +52,7 @@ class LocaleTile extends StatelessWidget { case 'en': return 'English'; case 'es': - return "Español"; + return 'Español'; case 'fr': return 'Français'; case 'ko': diff --git a/whatsnew/whatsnew-es-MX b/whatsnew/whatsnew-es-MX deleted file mode 100644 index 38c70f8ac..000000000 --- a/whatsnew/whatsnew-es-MX +++ /dev/null @@ -1,6 +0,0 @@ -¡Gracias por utilizar Aves! -En la v1.5.9: -- vista de lista para items y álbumes -- el mover, editar o borrar items puede ser cancelado -- disfrute de la aplicación en Alemán -Registro de cambios completos disponible en GitHub \ No newline at end of file From d5da17a614ea684cd91ab9eaa9caa09a9d49bac8 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Thu, 6 Jan 2022 10:39:43 +0900 Subject: [PATCH 21/28] l10n: updated feature graphic for DE / ES --- .../android/de/images/featureGraphic.png | Bin 0 -> 16486 bytes .../android/es-MX/images/featureGraphic.png | Bin 11613 -> 17195 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 fastlane/metadata/android/de/images/featureGraphic.png diff --git a/fastlane/metadata/android/de/images/featureGraphic.png b/fastlane/metadata/android/de/images/featureGraphic.png new file mode 100644 index 0000000000000000000000000000000000000000..a0b3a3e779baaae32073113b2e8d7539c0ad7a69 GIT binary patch literal 16486 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 HEcpKdJrA6~ literal 11613 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 From 05dc8beec06d24a8e93b82a1cd4086ec783d9346 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Thu, 6 Jan 2022 11:43:09 +0900 Subject: [PATCH 22/28] l10n --- README.md | 2 +- lib/l10n/app_de.arb | 14 +++++++ lib/l10n/app_en.arb | 49 ++++++++++++++++------- lib/l10n/app_fr.arb | 3 ++ lib/l10n/app_ko.arb | 3 ++ lib/l10n/app_ru.arb | 14 +++++++ lib/widgets/about/credits.dart | 2 +- lib/widgets/settings/language/locale.dart | 2 +- untranslated.json | 42 ++++--------------- 9 files changed, 80 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 78f1bc157..c4975eba2 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ At this stage this project does *not* accept PRs, except for translations. ### Translations -If you want to translate this app in your language and share the result, feel free to open a PR or send the translation by [email](mailto:gallery.aves@gmail.com). You can find some localization notes in [pubspec.yaml](https://github.com/deckerst/aves/blob/develop/pubspec.yaml). English, Korean and French are already handled. +If you want to translate this app in your language and share the result, feel free to open a PR or send the translation by [email](mailto:gallery.aves@gmail.com). You can find some localization notes in [pubspec.yaml](https://github.com/deckerst/aves/blob/develop/pubspec.yaml). English, Korean and French are already handled by me. Russian, German and Spanish are handled by generous volunteers. ### Donations diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index edebff219..b53ed158c 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -73,12 +73,15 @@ "videoActionSettings": "Einstellungen", "entryInfoActionEditDate": "Datum & Uhrzeit bearbeiten", + "entryInfoActionEditRating": "Bewertung bearbeiten", "entryInfoActionEditTags": "Tags bearbeiten", "entryInfoActionRemoveMetadata": "Metadaten entfernen", "filterFavouriteLabel": "Favorit", "filterLocationEmptyLabel": "Ungeortet", "filterTagEmptyLabel": "Unmarkiert", + "filterRatingUnratedLabel": "Nicht bewertet", + "filterRatingRejectedLabel": "Verworfen", "filterTypeAnimatedLabel": "Animationen", "filterTypeMotionPhotoLabel": "Bewegtes Foto", "filterTypePanoramaLabel": "Panorama", @@ -137,6 +140,8 @@ "restrictedAccessDialogMessage": "Diese Anwendung darf keine Dateien im {directory} von „{volume}“ verändern.\n\nBitte verwenden Sie einen vorinstallierten Dateimanager oder eine Galerie-App, um die Objekte in ein anderes Verzeichnis zu verschieben.", "notEnoughSpaceDialogTitle": "Nicht genug Platz", "notEnoughSpaceDialogMessage": "Diese Operation benötigt {neededSize} freien Platz auf „{volume}“, um abgeschlossen zu werden, aber es ist nur noch {freeSize} übrig.", + "missingSystemFilePickerDialogTitle": "Fehlender System-Dateiauswahldialog", + "missingSystemFilePickerDialogMessage": "Der System-Dateiauswahldialog fehlt oder ist deaktiviert. Bitte aktivieren Sie ihn und versuchen Sie es erneut.", "unsupportedTypeDialogTitle": "Nicht unterstützte Typen", "unsupportedTypeDialogMessage": " {count, plural, =1{Dieser Vorgang wird für Elemente des folgenden Typs nicht unterstützt: {types}.} other{Dieser Vorgang wird für Elemente der folgenden Typen nicht unterstützt: {types}.}}", @@ -178,11 +183,17 @@ "renameEntryDialogLabel": "Neuer Name", "editEntryDateDialogTitle": "Datum & Uhrzeit", + "editEntryDateDialogSetCustom": "Benutzerdefiniertes Datum einstellen", + "editEntryDateDialogCopyField": "Von anderem Datum kopieren", "editEntryDateDialogExtractFromTitle": "Auszug aus dem Titel", "editEntryDateDialogShift": "Verschieben", + "editEntryDateDialogSourceFileModifiedDate": "Änderungsdatum der Datei", + "editEntryDateDialogTargetFieldsHeader": "Zu ändernde Felder", "editEntryDateDialogHours": "Stunden", "editEntryDateDialogMinutes": "Minuten", + "editEntryRatingDialogTitle": "Bewertung", + "removeEntryMetadataDialogTitle": "Entfernung von Metadaten", "removeEntryMetadataDialogMore": "Mehr", @@ -267,6 +278,7 @@ "collectionSortDate": "Nach Datum", "collectionSortSize": "Nach Größe", "collectionSortName": "Nach Album & Dateiname", + "collectionSortRating": "Nach Bewertung", "collectionGroupAlbum": "Nach Album", "collectionGroupMonth": "Nach Monat", @@ -340,6 +352,7 @@ "searchSectionCountries": "Länder", "searchSectionPlaces": "Orte", "searchSectionTags": "Tags", + "searchSectionRating": "Bewertungen", "settingsPageTitle": "Einstellungen", "settingsSystemDefault": "System", @@ -365,6 +378,7 @@ "settingsSectionThumbnails": "Vorschaubilder", "settingsThumbnailShowLocationIcon": "Standort-Symbol anzeigen", "settingsThumbnailShowMotionPhotoIcon": "Bewegungsfoto-Symbol anzeigen", + "settingsThumbnailShowRating": "Bewertung anzeigen", "settingsThumbnailShowRawIcon": "Rohdaten-Symbol anzeigen", "settingsThumbnailShowVideoDuration": "Videodauer anzeigen", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8cbfe7f2b..a4b3ac6a8 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -112,10 +112,12 @@ "@coordinateDms": { "placeholders": { "coordinate": { - "type": "String" + "type": "String", + "example": "38° 41′ 47.72″" }, "direction": { - "type": "String" + "type": "String", + "example": "S" } } }, @@ -162,7 +164,9 @@ "@otherDirectoryDescription": { "placeholders": { "name": { - "type": "String" + "type": "String", + "example": "Pictures", + "description": "the name of a specific directory" } } }, @@ -171,10 +175,13 @@ "@storageAccessDialogMessage": { "placeholders": { "directory": { - "type": "String" + "type": "String", + "description": "the name of a directory, using the output of `rootDirectoryDescription` or `otherDirectoryDescription`" }, "volume": { - "type": "String" + "type": "String", + "example": "SD card", + "description": "the name of a storage volume" } } }, @@ -183,10 +190,13 @@ "@restrictedAccessDialogMessage": { "placeholders": { "directory": { - "type": "String" + "type": "String", + "description": "the name of a directory, using the output of `rootDirectoryDescription` or `otherDirectoryDescription`" }, "volume": { - "type": "String" + "type": "String", + "example": "SD card", + "description": "the name of a storage volume" } } }, @@ -195,13 +205,17 @@ "@notEnoughSpaceDialogMessage": { "placeholders": { "neededSize": { - "type": "String" + "type": "String", + "example": "314 MB" }, "freeSize": { - "type": "String" + "type": "String", + "example": "123 MB" }, "volume": { - "type": "String" + "type": "String", + "example": "SD card", + "description": "the name of a storage volume" } } }, @@ -214,7 +228,9 @@ "placeholders": { "count": {}, "types": { - "type": "String" + "type": "String", + "example": "GIF, TIFF, MP4", + "description": "a list of unsupported types" } } }, @@ -238,7 +254,10 @@ "videoResumeDialogMessage": "Do you want to resume playing at {time}?", "@videoResumeDialogMessage": { "placeholders": { - "time": {} + "time": { + "type": "String", + "example": "13:37" + } } }, "videoStartOverButtonLabel": "START OVER", @@ -346,10 +365,12 @@ "@aboutCreditsTranslatorLine": { "placeholders": { "language": { - "type": "String" + "type": "String", + "example": "Sumerian" }, "names": { - "type": "String" + "type": "String", + "example": "Reggie Lampert" } } }, diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 3975fda04..77d9a38af 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -73,6 +73,7 @@ "videoActionSettings": "Préférences", "entryInfoActionEditDate": "Modifier la date", + "entryInfoActionEditRating": "Modifier la notation", "entryInfoActionEditTags": "Modifier les libellés", "entryInfoActionRemoveMetadata": "Retirer les métadonnées", @@ -189,6 +190,8 @@ "editEntryDateDialogHours": "Heures", "editEntryDateDialogMinutes": "Minutes", + "editEntryRatingDialogTitle": "Notation", + "removeEntryMetadataDialogTitle": "Retrait de métadonnées", "removeEntryMetadataDialogMore": "Voir plus", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 595e1300f..2653a4d24 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -73,6 +73,7 @@ "videoActionSettings": "설정", "entryInfoActionEditDate": "날짜 및 시간 수정", + "entryInfoActionEditRating": "별점 수정", "entryInfoActionEditTags": "태그 수정", "entryInfoActionRemoveMetadata": "메타데이터 삭제", @@ -189,6 +190,8 @@ "editEntryDateDialogHours": "시간", "editEntryDateDialogMinutes": "분", + "editEntryRatingDialogTitle": "별점", + "removeEntryMetadataDialogTitle": "메타데이터 삭제", "removeEntryMetadataDialogMore": "더 보기", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 9d9e3809f..1d8649cc9 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -73,12 +73,15 @@ "videoActionSettings": "Настройки", "entryInfoActionEditDate": "Изменить дату и время", + "entryInfoActionEditRating": "Изменить рейтинг", "entryInfoActionEditTags": "Изменить теги", "entryInfoActionRemoveMetadata": "Удалить метаданные", "filterFavouriteLabel": "Избранное", "filterLocationEmptyLabel": "Без местоположения", "filterTagEmptyLabel": "Без тегов", + "filterRatingUnratedLabel": "Без рейтинга", + "filterRatingRejectedLabel": "Отклонённые", "filterTypeAnimatedLabel": "GIF", "filterTypeMotionPhotoLabel": "Живое фото", "filterTypePanoramaLabel": "Панорама", @@ -137,6 +140,8 @@ "restrictedAccessDialogMessage": "Этому приложению не разрешается изменять файлы в каталоге {directory} накопителя «{volume}».\n\nПожалуйста, используйте предустановленный файловый менеджер или галерею, чтобы переместить элементы в другой каталог.", "notEnoughSpaceDialogTitle": "Недостаточно свободного места.", "notEnoughSpaceDialogMessage": "Для завершения этой операции требуется {neededSize} свободного места на «{volume}», но осталось только {freeSize}.", + "missingSystemFilePickerDialogTitle": "Отсутствует системное приложение выбора файлов", + "missingSystemFilePickerDialogMessage": "Системное приложение выбора файлов отсутствует или отключено. Пожалуйста, включите его и повторите попытку.", "unsupportedTypeDialogTitle": "Неподдерживаемые форматы", "unsupportedTypeDialogMessage": "{count, plural, =1{Эта операция не поддерживается для объектов следующего формата: {types}.} other{Эта операция не поддерживается для объектов следующих форматов: {types}.}}", @@ -178,11 +183,17 @@ "renameEntryDialogLabel": "Новое название", "editEntryDateDialogTitle": "Дата и время", + "editEntryDateDialogSetCustom": "Задайте дату", + "editEntryDateDialogCopyField": "Копировать с другой даты", "editEntryDateDialogExtractFromTitle": "Извлечь из названия", "editEntryDateDialogShift": "Сдвиг", + "editEntryDateDialogSourceFileModifiedDate": "Дата изменения файла", + "editEntryDateDialogTargetFieldsHeader": "Поля для изменения", "editEntryDateDialogHours": "Часов", "editEntryDateDialogMinutes": "Минут", + "editEntryRatingDialogTitle": "Рейтинг", + "removeEntryMetadataDialogTitle": "Удаление метаданных", "removeEntryMetadataDialogMore": "Дополнительно", @@ -267,6 +278,7 @@ "collectionSortDate": "По дате", "collectionSortSize": "По размеру", "collectionSortName": "По имени альбома и файла", + "collectionSortRating": "По рейтингу", "collectionGroupAlbum": "По альбому", "collectionGroupMonth": "По месяцу", @@ -340,6 +352,7 @@ "searchSectionCountries": "Страны", "searchSectionPlaces": "Локации", "searchSectionTags": "Теги", + "searchSectionRating": "Рейтинги", "settingsPageTitle": "Настройки", "settingsSystemDefault": "Система", @@ -365,6 +378,7 @@ "settingsSectionThumbnails": "Эскизы", "settingsThumbnailShowLocationIcon": "Показать значок местоположения", "settingsThumbnailShowMotionPhotoIcon": "Показать значок живого фото", + "settingsThumbnailShowRating": "Показывать рейтинг", "settingsThumbnailShowRawIcon": "Показать значок RAW-файла", "settingsThumbnailShowVideoDuration": "Показывать продолжительность видео", diff --git a/lib/widgets/about/credits.dart b/lib/widgets/about/credits.dart index a8d3c4cac..35251d760 100644 --- a/lib/widgets/about/credits.dart +++ b/lib/widgets/about/credits.dart @@ -8,7 +8,7 @@ class AboutCredits extends StatelessWidget { static const translators = { 'Deutsch': 'JanWaldhorn', - 'Español': 'n-berenice', + 'Español (México)': 'n-berenice', 'Русский': 'D3ZOXY', }; diff --git a/lib/widgets/settings/language/locale.dart b/lib/widgets/settings/language/locale.dart index 2fcf15aab..631890ad2 100644 --- a/lib/widgets/settings/language/locale.dart +++ b/lib/widgets/settings/language/locale.dart @@ -52,7 +52,7 @@ class LocaleTile extends StatelessWidget { case 'en': return 'English'; case 'es': - return 'Español'; + return 'Español (México)'; case 'fr': return 'Français'; case 'ko': diff --git a/untranslated.json b/untranslated.json index 4789f58a8..df1b09c1c 100644 --- a/untranslated.json +++ b/untranslated.json @@ -1,49 +1,23 @@ { "de": [ - "entryInfoActionEditRating", - "filterRatingUnratedLabel", - "filterRatingRejectedLabel", - "missingSystemFilePickerDialogTitle", - "missingSystemFilePickerDialogMessage", - "editEntryDateDialogSetCustom", - "editEntryDateDialogCopyField", - "editEntryDateDialogSourceFileModifiedDate", - "editEntryDateDialogTargetFieldsHeader", - "editEntryRatingDialogTitle", - "collectionSortRating", - "searchSectionRating", - "settingsThumbnailShowFavouriteIcon", - "settingsThumbnailShowRating" + "settingsThumbnailShowFavouriteIcon" + ], + + "es": [ + "settingsThumbnailShowFavouriteIcon" ], "fr": [ - "entryInfoActionEditRating", "missingSystemFilePickerDialogTitle", - "missingSystemFilePickerDialogMessage", - "editEntryRatingDialogTitle" + "missingSystemFilePickerDialogMessage" ], "ko": [ - "entryInfoActionEditRating", "missingSystemFilePickerDialogTitle", - "missingSystemFilePickerDialogMessage", - "editEntryRatingDialogTitle" + "missingSystemFilePickerDialogMessage" ], "ru": [ - "entryInfoActionEditRating", - "filterRatingUnratedLabel", - "filterRatingRejectedLabel", - "missingSystemFilePickerDialogTitle", - "missingSystemFilePickerDialogMessage", - "editEntryDateDialogSetCustom", - "editEntryDateDialogCopyField", - "editEntryDateDialogSourceFileModifiedDate", - "editEntryDateDialogTargetFieldsHeader", - "editEntryRatingDialogTitle", - "collectionSortRating", - "searchSectionRating", - "settingsThumbnailShowFavouriteIcon", - "settingsThumbnailShowRating" + "settingsThumbnailShowFavouriteIcon" ] } From 1fc9fb040e53b1e4c888bec56a1c048f36a5d01b Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Thu, 6 Jan 2022 12:54:38 +0900 Subject: [PATCH 23/28] info: easier access to rating/tag edition --- lib/model/actions/entry_info_actions.dart | 4 +- lib/model/actions/entry_set_actions.dart | 4 +- lib/model/actions/events.dart | 6 +- lib/model/filters/location.dart | 2 +- lib/model/filters/tag.dart | 2 +- lib/theme/icons.dart | 11 +-- .../entry_editors/edit_entry_tags_dialog.dart | 2 +- lib/widgets/viewer/info/basic_section.dart | 86 ++++++++++++------- lib/widgets/viewer/info/info_page.dart | 20 ++--- lib/widgets/viewer/panorama_page.dart | 2 +- 10 files changed, 81 insertions(+), 58 deletions(-) diff --git a/lib/model/actions/entry_info_actions.dart b/lib/model/actions/entry_info_actions.dart index 94ff10857..ffc6ca198 100644 --- a/lib/model/actions/entry_info_actions.dart +++ b/lib/model/actions/entry_info_actions.dart @@ -50,9 +50,9 @@ extension ExtraEntryInfoAction on EntryInfoAction { case EntryInfoAction.editDate: return AIcons.date; case EntryInfoAction.editRating: - return AIcons.rating; + return AIcons.editRating; case EntryInfoAction.editTags: - return AIcons.addTag; + return AIcons.editTags; case EntryInfoAction.removeMetadata: return AIcons.clear; // motion photo diff --git a/lib/model/actions/entry_set_actions.dart b/lib/model/actions/entry_set_actions.dart index 5b7243b72..f3601560b 100644 --- a/lib/model/actions/entry_set_actions.dart +++ b/lib/model/actions/entry_set_actions.dart @@ -167,9 +167,9 @@ extension ExtraEntrySetAction on EntrySetAction { case EntrySetAction.editDate: return AIcons.date; case EntrySetAction.editRating: - return AIcons.rating; + return AIcons.editRating; case EntrySetAction.editTags: - return AIcons.addTag; + return AIcons.editTags; case EntrySetAction.removeMetadata: return AIcons.clear; } diff --git a/lib/model/actions/events.dart b/lib/model/actions/events.dart index 248e9dd88..e6fea841f 100644 --- a/lib/model/actions/events.dart +++ b/lib/model/actions/events.dart @@ -1,9 +1,13 @@ +import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; @immutable -class ActionEvent { +class ActionEvent extends Equatable { final T action; + @override + List get props => [action]; + const ActionEvent(this.action); } diff --git a/lib/model/filters/location.dart b/lib/model/filters/location.dart index 235475160..6d09d3e21 100644 --- a/lib/model/filters/location.dart +++ b/lib/model/filters/location.dart @@ -69,7 +69,7 @@ class LocationFilter extends CollectionFilter { ); } } - return Icon(_location.isEmpty ? AIcons.locationOff : AIcons.location, size: size); + return Icon(_location.isEmpty ? AIcons.locationUnlocated : AIcons.location, size: size); } @override diff --git a/lib/model/filters/tag.dart b/lib/model/filters/tag.dart index cda7ef146..1ec3b2a4d 100644 --- a/lib/model/filters/tag.dart +++ b/lib/model/filters/tag.dart @@ -44,7 +44,7 @@ class TagFilter extends CollectionFilter { String getLabel(BuildContext context) => tag.isEmpty ? context.l10n.filterTagEmptyLabel : tag; @override - Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => showGenericIcon ? Icon(tag.isEmpty ? AIcons.tagOff : AIcons.tag, size: size) : null; + Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => showGenericIcon ? Icon(tag.isEmpty ? AIcons.tagUntagged : AIcons.tag, size: size) : null; @override String get category => type; diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index 2299830f7..c91a307b7 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -19,7 +19,7 @@ class AIcons { static const IconData home = Icons.home_outlined; static const IconData language = Icons.translate_outlined; static const IconData location = Icons.place_outlined; - static const IconData locationOff = Icons.location_off_outlined; + static const IconData locationUnlocated = Icons.location_off_outlined; static const IconData mainStorage = Icons.smartphone_outlined; static const IconData privacy = MdiIcons.shieldAccountOutline; static const IconData rating = Icons.star_border_outlined; @@ -29,12 +29,12 @@ class AIcons { static const IconData raw = Icons.raw_on_outlined; static const IconData shooting = Icons.camera_outlined; static const IconData removableStorage = Icons.sd_storage_outlined; - static const IconData sensorControl = Icons.explore_outlined; - static const IconData sensorControlOff = Icons.explore_off_outlined; + static const IconData sensorControlEnabled = Icons.explore_outlined; + static const IconData sensorControlDisabled = Icons.explore_off_outlined; static const IconData settings = Icons.settings_outlined; static const IconData text = Icons.format_quote_outlined; static const IconData tag = Icons.local_offer_outlined; - static const IconData tagOff = MdiIcons.tagOffOutline; + static const IconData tagUntagged = MdiIcons.tagOffOutline; // view static const IconData group = Icons.group_work_outlined; @@ -44,7 +44,6 @@ class AIcons { // actions static const IconData add = Icons.add_circle_outline; static const IconData addShortcut = Icons.add_to_home_screen_outlined; - static const IconData addTag = MdiIcons.tagPlusOutline; static const IconData cancel = Icons.cancel_outlined; static const IconData replay10 = Icons.replay_10_outlined; static const IconData skip10 = Icons.forward_10_outlined; @@ -55,6 +54,8 @@ class AIcons { static const IconData debug = Icons.whatshot_outlined; static const IconData delete = Icons.delete_outlined; static const IconData edit = Icons.edit_outlined; + static const IconData editRating = MdiIcons.starPlusOutline; + static const IconData editTags = MdiIcons.tagPlusOutline; static const IconData export = MdiIcons.fileExportOutline; static const IconData flip = Icons.flip_outlined; static const IconData favourite = Icons.favorite_border; diff --git a/lib/widgets/dialogs/entry_editors/edit_entry_tags_dialog.dart b/lib/widgets/dialogs/entry_editors/edit_entry_tags_dialog.dart index cebab3027..d3f7ea85e 100644 --- a/lib/widgets/dialogs/entry_editors/edit_entry_tags_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/edit_entry_tags_dialog.dart @@ -122,7 +122,7 @@ class _TagEditorPageState extends State { child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(AIcons.tagOff, color: untaggedColor), + const Icon(AIcons.tagUntagged, color: untaggedColor), const SizedBox(width: 8), Text( l10n.filterTagEmptyLabel, diff --git a/lib/widgets/viewer/info/basic_section.dart b/lib/widgets/viewer/info/basic_section.dart index d8c414987..70556e422 100644 --- a/lib/widgets/viewer/info/basic_section.dart +++ b/lib/widgets/viewer/info/basic_section.dart @@ -9,7 +9,6 @@ import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/filters/type.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/theme/format.dart'; -import 'package:aves/theme/icons.dart'; 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'; @@ -24,7 +23,7 @@ class BasicSection extends StatelessWidget { final AvesEntry entry; final CollectionLens? collection; final EntryInfoActionDelegate actionDelegate; - final ValueNotifier isEditingTagNotifier; + final ValueNotifier isEditingMetadataNotifier; final FilterCallback onFilter; const BasicSection({ @@ -32,7 +31,7 @@ class BasicSection extends StatelessWidget { required this.entry, this.collection, required this.actionDelegate, - required this.isEditingTagNotifier, + required this.isEditingMetadataNotifier, required this.onFilter, }) : super(key: key); @@ -77,6 +76,7 @@ class BasicSection extends StatelessWidget { ), OwnerProp(entry: entry), _buildChips(context), + _buildEditButtons(context), ], ); }); @@ -106,58 +106,78 @@ class BasicSection extends StatelessWidget { if (entry.isFavourite) FavouriteFilter.instance, ]..sort(); - final children = [ - ...effectiveFilters.map((filter) => AvesFilterChip( - filter: filter, - onTap: onFilter, - )), - if (actionDelegate.canApply(EntryInfoAction.editTags)) _buildEditTagButton(context), - ]; - - return children.isEmpty - ? const SizedBox() - : Padding( - padding: const EdgeInsets.symmetric(horizontal: AvesFilterChip.outlineWidth / 2) + const EdgeInsets.only(top: 8), - child: Wrap( - spacing: 8, - runSpacing: 8, - children: children, - ), - ); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: AvesFilterChip.outlineWidth / 2) + const EdgeInsets.only(top: 8), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: effectiveFilters + .map((filter) => AvesFilterChip( + filter: filter, + onTap: onFilter, + )) + .toList(), + ), + ); }, ); } - Widget _buildEditTagButton(BuildContext context) { - const action = EntryInfoAction.editTags; - return ValueListenableBuilder( - valueListenable: isEditingTagNotifier, - builder: (context, isEditing, child) { + Widget _buildEditButtons(BuildContext context) { + final children = [ + EntryInfoAction.editRating, + EntryInfoAction.editTags, + ].where(actionDelegate.canApply).map((v) => _buildEditMetadataButton(context, v)).toList(); + + return children.isEmpty + ? const SizedBox() + : TooltipTheme( + data: TooltipTheme.of(context).copyWith( + preferBelow: false, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: AvesFilterChip.outlineWidth / 2) + const EdgeInsets.only(top: 8), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: children, + ), + ), + ); + } + + Widget _buildEditMetadataButton(BuildContext context, EntryInfoAction action) { + return ValueListenableBuilder( + valueListenable: isEditingMetadataNotifier, + builder: (context, editingAction, child) { + final isEditing = editingAction != null; return Stack( children: [ DecoratedBox( - decoration: const BoxDecoration( + decoration: BoxDecoration( border: Border.fromBorderSide(BorderSide( - color: AvesFilterChip.defaultOutlineColor, + color: isEditing ? Theme.of(context).disabledColor : AvesFilterChip.defaultOutlineColor, width: AvesFilterChip.outlineWidth, )), - borderRadius: BorderRadius.all(Radius.circular(AvesFilterChip.defaultRadius)), + borderRadius: const BorderRadius.all(Radius.circular(AvesFilterChip.defaultRadius)), ), child: IconButton( - icon: const Icon(AIcons.addTag), + icon: action.getIcon(), onPressed: isEditing ? null : () => actionDelegate.onActionSelected(context, action), tooltip: action.getText(context), ), ), - if (isEditing) - const Positioned.fill( - child: Padding( + Positioned.fill( + child: Visibility( + visible: editingAction == action, + child: const Padding( padding: EdgeInsets.all(1.0), child: CircularProgressIndicator( strokeWidth: AvesFilterChip.outlineWidth, ), ), ), + ), ], ); }, diff --git a/lib/widgets/viewer/info/info_page.dart b/lib/widgets/viewer/info/info_page.dart index 9ea65c17e..ad14c8ee6 100644 --- a/lib/widgets/viewer/info/info_page.dart +++ b/lib/widgets/viewer/info/info_page.dart @@ -150,7 +150,7 @@ class _InfoPageContentState extends State<_InfoPageContent> { final List _subscriptions = []; late EntryInfoActionDelegate _actionDelegate; final ValueNotifier> _metadataNotifier = ValueNotifier({}); - final ValueNotifier _isEditingTagNotifier = ValueNotifier(false); + final ValueNotifier _isEditingMetadataNotifier = ValueNotifier(null); static const horizontalPadding = EdgeInsets.symmetric(horizontal: 8); @@ -197,7 +197,7 @@ class _InfoPageContentState extends State<_InfoPageContent> { entry: entry, collection: collection, actionDelegate: _actionDelegate, - isEditingTagNotifier: _isEditingTagNotifier, + isEditingMetadataNotifier: _isEditingMetadataNotifier, onFilter: _goToCollection, ); final locationAtTop = widget.split && entry.hasGps; @@ -255,15 +255,13 @@ class _InfoPageContentState extends State<_InfoPageContent> { } void _onActionDelegateEvent(ActionEvent event) { - if (event.action == EntryInfoAction.editTags) { - Future.delayed(Durations.dialogTransitionAnimation).then((_) { - if (event is ActionStartedEvent) { - _isEditingTagNotifier.value = true; - } else if (event is ActionEndedEvent) { - _isEditingTagNotifier.value = false; - } - }); - } + Future.delayed(Durations.dialogTransitionAnimation).then((_) { + if (event is ActionStartedEvent) { + _isEditingMetadataNotifier.value = event.action; + } else if (event is ActionEndedEvent) { + _isEditingMetadataNotifier.value = null; + } + }); } void _goToCollection(CollectionFilter filter) { diff --git a/lib/widgets/viewer/panorama_page.dart b/lib/widgets/viewer/panorama_page.dart index 5cdaa4dd1..2ca1be7a2 100644 --- a/lib/widgets/viewer/panorama_page.dart +++ b/lib/widgets/viewer/panorama_page.dart @@ -109,7 +109,7 @@ class _PanoramaPageState extends State { valueListenable: _sensorControl, builder: (context, sensorControl, child) { return IconButton( - icon: Icon(sensorControl == SensorControl.None ? AIcons.sensorControl : AIcons.sensorControlOff), + icon: Icon(sensorControl == SensorControl.None ? AIcons.sensorControlEnabled : AIcons.sensorControlDisabled), onPressed: _toggleSensor, tooltip: sensorControl == SensorControl.None ? context.l10n.panoramaEnableSensorControl : context.l10n.panoramaDisableSensorControl, ); From 4a786e0b6f3aab0f6e3bc2b2303c33d8faf4b6c6 Mon Sep 17 00:00:00 2001 From: n-berenice <82068197+n-berenice@users.noreply.github.com> Date: Thu, 6 Jan 2022 22:09:38 -0300 Subject: [PATCH 24/28] Done untranslated.json (#151) * missing string #380 * added missing string #379 --- lib/l10n/app_de.arb | 1 + lib/l10n/app_es.arb | 1 + 2 files changed, 2 insertions(+) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index b53ed158c..f986966cd 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -376,6 +376,7 @@ "settingsNavigationDrawerAddAlbum": "Album hinzufügen", "settingsSectionThumbnails": "Vorschaubilder", + "settingsThumbnailShowFavouriteIcon": "Favoriten-Symbol anzeigen", "settingsThumbnailShowLocationIcon": "Standort-Symbol anzeigen", "settingsThumbnailShowMotionPhotoIcon": "Bewegungsfoto-Symbol anzeigen", "settingsThumbnailShowRating": "Bewertung anzeigen", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 9603f044c..f23a0af12 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -377,6 +377,7 @@ "settingsNavigationDrawerAddAlbum": "Agregar álbum", "settingsSectionThumbnails": "Miniaturas", + "settingsThumbnailShowFavouriteIcon": "Mostrar icono de favoritos", "settingsThumbnailShowLocationIcon": "Mostrar icono de ubicación", "settingsThumbnailShowMotionPhotoIcon": "Mostrar icono de foto en movimiento", "settingsThumbnailShowRating": "Mostrar clasificación", From bd080fb26a210a88077743fd48c894c9608b86cc Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Fri, 7 Jan 2022 10:39:31 +0900 Subject: [PATCH 25/28] l10n --- lib/l10n/app_de.arb | 2 +- lib/l10n/app_fr.arb | 2 ++ lib/l10n/app_ko.arb | 2 ++ untranslated.json | 18 ------------------ 4 files changed, 5 insertions(+), 19 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index f986966cd..867ecab0d 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -183,7 +183,7 @@ "renameEntryDialogLabel": "Neuer Name", "editEntryDateDialogTitle": "Datum & Uhrzeit", - "editEntryDateDialogSetCustom": "Benutzerdefiniertes Datum einstellen", + "editEntryDateDialogSetCustom": "Datum einstellen", "editEntryDateDialogCopyField": "Von anderem Datum kopieren", "editEntryDateDialogExtractFromTitle": "Auszug aus dem Titel", "editEntryDateDialogShift": "Verschieben", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 77d9a38af..701a808e8 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -140,6 +140,8 @@ "restrictedAccessDialogMessage": "Cette app ne peut pas modifier les fichiers du {directory} de «\u00A0{volume}\u00A0».\n\nVeuillez utiliser une app pré-installée pour déplacer les fichiers vers un autre dossier.", "notEnoughSpaceDialogTitle": "Espace insuffisant", "notEnoughSpaceDialogMessage": "Cette opération nécessite {neededSize} d’espace disponible sur «\u00A0{volume}\u00A0», mais il ne reste que {freeSize}.", + "missingSystemFilePickerDialogTitle": "Sélecteur de fichiers désactivé", + "missingSystemFilePickerDialogMessage": "Le sélecteur de fichiers du système est absent ou désactivé. Veuillez le réactiver et réessayer.", "unsupportedTypeDialogTitle": "Formats non supportés", "unsupportedTypeDialogMessage": "{count, plural, =1{Cette opération n’est pas disponible pour les fichiers au format suivant : {types}.} other{Cette opération n’est pas disponible pour les fichiers aux formats suivants : {types}.}}", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 2653a4d24..ac2d83ce4 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -140,6 +140,8 @@ "restrictedAccessDialogMessage": "“{volume}”의 {directory}에 있는 파일의 접근이 제한됩니다.\n\n기본으로 설치된 갤러리나 파일 관리 앱을 사용해서 다른 폴더로 파일을 이동하세요.", "notEnoughSpaceDialogTitle": "저장공간 부족", "notEnoughSpaceDialogMessage": "“{volume}”에 필요 공간은 {neededSize}인데 사용 가능한 용량은 {freeSize}만 남아있습니다.", + "missingSystemFilePickerDialogTitle": "기본 파일 선택기 없음", + "missingSystemFilePickerDialogMessage": "기본 파일 선택기가 없거나 비활성화딥니다. 파일 선택기를 켜고 다시 시도하세요.", "unsupportedTypeDialogTitle": "미지원 형식", "unsupportedTypeDialogMessage": "{count, plural, other{이 작업은 다음 항목의 형식을 지원하지 않습니다: {types}.}}", diff --git a/untranslated.json b/untranslated.json index df1b09c1c..3b8f080bb 100644 --- a/untranslated.json +++ b/untranslated.json @@ -1,22 +1,4 @@ { - "de": [ - "settingsThumbnailShowFavouriteIcon" - ], - - "es": [ - "settingsThumbnailShowFavouriteIcon" - ], - - "fr": [ - "missingSystemFilePickerDialogTitle", - "missingSystemFilePickerDialogMessage" - ], - - "ko": [ - "missingSystemFilePickerDialogTitle", - "missingSystemFilePickerDialogMessage" - ], - "ru": [ "settingsThumbnailShowFavouriteIcon" ] From dfb51ddb0b67794bbff9a1cf802dd2b60c7e32ff Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Fri, 7 Jan 2022 13:47:46 +0900 Subject: [PATCH 26/28] #147 viewer: open in album context, when possible --- lib/widgets/home_page.dart | 54 +++++++++++++++++++++++++++++++++----- 1 file changed, 48 insertions(+), 6 deletions(-) diff --git a/lib/widgets/home_page.dart b/lib/widgets/home_page.dart index f04182b61..aeb9d078f 100644 --- a/lib/widgets/home_page.dart +++ b/lib/widgets/home_page.dart @@ -2,11 +2,13 @@ import 'dart:async'; import 'package:aves/app_mode.dart'; import 'package:aves/model/entry.dart'; +import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/settings/home_page.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/model/source/enums.dart'; import 'package:aves/services/analysis_service.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/services/global_search.dart'; @@ -18,6 +20,7 @@ import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/search/search_delegate.dart'; import 'package:aves/widgets/search/search_page.dart'; import 'package:aves/widgets/viewer/entry_viewer_page.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:permission_handler/permission_handler.dart'; @@ -114,7 +117,7 @@ class _HomePageState extends State { context.read>().value = appMode; unawaited(reportService.setCustomKey('app_mode', appMode.toString())); - if (appMode != AppMode.view) { + if (appMode != AppMode.view || _isViewerSourceable(_viewerEntry!)) { debugPrint('Storage check complete in ${stopwatch.elapsed.inMilliseconds}ms'); unawaited(GlobalSearch.registerCallback()); unawaited(AnalysisService.registerCallback()); @@ -127,11 +130,13 @@ class _HomePageState extends State { // e.g. when opening the viewer in `view` mode should replace a viewer in `main` mode unawaited(Navigator.pushAndRemoveUntil( context, - _getRedirectRoute(appMode), + await _getRedirectRoute(appMode), (route) => false, )); } + bool _isViewerSourceable(AvesEntry viewerEntry) => viewerEntry.directory != null && !settings.hiddenFilters.any((filter) => filter.test(viewerEntry)); + Future _initViewerEntry({required String uri, required String? mimeType}) async { if (uri.startsWith('/')) { // convert this file path to a proper URI @@ -145,13 +150,50 @@ class _HomePageState extends State { return entry; } - Route _getRedirectRoute(AppMode appMode) { + Future _getRedirectRoute(AppMode appMode) async { if (appMode == AppMode.view) { + AvesEntry viewerEntry = _viewerEntry!; + CollectionLens? collection; + + final source = context.read(); + if (source.initialized) { + final album = viewerEntry.directory; + if (album != null) { + // wait for collection to pass the `loading` state + final completer = Completer(); + void _onSourceStateChanged() { + if (source.stateNotifier.value != SourceState.loading) { + source.stateNotifier.removeListener(_onSourceStateChanged); + completer.complete(); + } + } + + source.stateNotifier.addListener(_onSourceStateChanged); + await completer.future; + + collection = CollectionLens( + source: source, + filters: {AlbumFilter(album, source.getAlbumDisplayName(context, album))}, + ); + final viewerEntryPath = viewerEntry.path; + final collectionEntry = collection.sortedEntries.firstWhereOrNull((entry) => entry.path == viewerEntryPath); + if (collectionEntry != null) { + viewerEntry = collectionEntry; + } else { + debugPrint('collection does not contain viewerEntry=$viewerEntry'); + collection = null; + } + } + } + return DirectMaterialPageRoute( settings: const RouteSettings(name: EntryViewerPage.routeName), - builder: (_) => EntryViewerPage( - initialEntry: _viewerEntry!, - ), + builder: (_) { + return EntryViewerPage( + collection: collection, + initialEntry: viewerEntry, + ); + }, ); } From 099a151d1d2e48081f029df5df6c18025b58b4b3 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Fri, 7 Jan 2022 16:44:59 +0900 Subject: [PATCH 27/28] minor changes --- CHANGELOG.md | 2 ++ lib/widgets/aves_app.dart | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1817619c0..1b10d1cee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ All notable changes to this project will be documented in this file. - editing an item orientation, rating or tags automatically sets a metadata date (from the file modified date), if it is missing +- Viewer: when opening an item from another app, it is now possible to scroll to other items in the + album ### Fixed diff --git a/lib/widgets/aves_app.dart b/lib/widgets/aves_app.dart index 80c6846b0..ef35eb8fb 100644 --- a/lib/widgets/aves_app.dart +++ b/lib/widgets/aves_app.dart @@ -106,6 +106,12 @@ class _AvesAppState extends State { home: home, navigatorObservers: _navigatorObservers, builder: (context, child) { + // Flutter has various page transition implementations for Android: + // - `FadeUpwardsPageTransitionsBuilder` on Oreo / API 27 and below + // - `OpenUpwardsPageTransitionsBuilder` on Pie / API 28 + // - `ZoomPageTransitionsBuilder` on Android 10 / API 29 and above + // As of Flutter v2.8.1, `FadeUpwardsPageTransitionsBuilder` is the default, regardless of versions. + // In practice, `ZoomPageTransitionsBuilder` feels unstable when transitioning from Album to Collection. if (!areAnimationsEnabled) { child = Theme( data: Theme.of(context).copyWith( From c8c2537996bb01748f4c9378b9713f267748d6f8 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Fri, 7 Jan 2022 16:48:10 +0900 Subject: [PATCH 28/28] version bump --- CHANGELOG.md | 2 ++ pubspec.yaml | 2 +- whatsnew/whatsnew-en-US | 8 ++++---- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b10d1cee..006314a32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [v1.5.10] - 2022-01-07 + ### Added - Collection: toggle favourites in bulk diff --git a/pubspec.yaml b/pubspec.yaml index 3c42fcd40..49396db87 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: aves description: A visual media gallery and metadata explorer app. repository: https://github.com/deckerst/aves -version: 1.5.9+63 +version: 1.5.10+64 publish_to: none environment: diff --git a/whatsnew/whatsnew-en-US b/whatsnew/whatsnew-en-US index 743503987..9394939f5 100644 --- a/whatsnew/whatsnew-en-US +++ b/whatsnew/whatsnew-en-US @@ -1,6 +1,6 @@ Thanks for using Aves! -In v1.5.9: -- list view for items and albums -- moving, editing or deleting items can be cancelled -- enjoy the app in German +In v1.5.10: +- show, search and edit ratings +- add many items to favourites at once +- enjoy the app in Spanish Full changelog available on GitHub \ No newline at end of file