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