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" + ] +}