#146 info: option to set date from other fields
This commit is contained in:
parent
9d5a834fca
commit
da7b2ee8c1
17 changed files with 568 additions and 380 deletions
|
@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
## <a id="unreleased"></a>[Unreleased]
|
## <a id="unreleased"></a>[Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Info: option to set date from other fields
|
||||||
|
|
||||||
## <a id="v1.5.9"></a>[v1.5.9] - 2021-12-22
|
## <a id="v1.5.9"></a>[v1.5.9] - 2021-12-22
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
@ -92,6 +92,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
"getXmp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getXmp) }
|
"getXmp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getXmp) }
|
||||||
"hasContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::hasContentResolverProp) }
|
"hasContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::hasContentResolverProp) }
|
||||||
"getContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverProp) }
|
"getContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverProp) }
|
||||||
|
"getDate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getDate) }
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -876,6 +877,57 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
result.success(value?.toString())
|
result.success(value?.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getDate(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
val mimeType = call.argument<String>("mimeType")
|
||||||
|
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||||
|
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
|
val field = call.argument<String>("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 {
|
companion object {
|
||||||
private val LOG_TAG = LogUtils.createTag<MetadataFetchHandler>()
|
private val LOG_TAG = LogUtils.createTag<MetadataFetchHandler>()
|
||||||
const val CHANNEL = "deckers.thibault/aves/metadata_fetch"
|
const val CHANNEL = "deckers.thibault/aves/metadata_fetch"
|
||||||
|
|
|
@ -180,9 +180,7 @@
|
||||||
"editEntryDateDialogTitle": "Datum & Uhrzeit",
|
"editEntryDateDialogTitle": "Datum & Uhrzeit",
|
||||||
"editEntryDateDialogSet": "Festlegen",
|
"editEntryDateDialogSet": "Festlegen",
|
||||||
"editEntryDateDialogShift": "Verschieben",
|
"editEntryDateDialogShift": "Verschieben",
|
||||||
"editEntryDateDialogExtractFromTitle": "Auszug aus dem Titel",
|
|
||||||
"editEntryDateDialogClear": "Aufräumen",
|
"editEntryDateDialogClear": "Aufräumen",
|
||||||
"editEntryDateDialogFieldSelection": "Feldauswahl",
|
|
||||||
"editEntryDateDialogHours": "Stunden",
|
"editEntryDateDialogHours": "Stunden",
|
||||||
"editEntryDateDialogMinutes": "Minuten",
|
"editEntryDateDialogMinutes": "Minuten",
|
||||||
|
|
||||||
|
|
|
@ -273,9 +273,12 @@
|
||||||
"editEntryDateDialogTitle": "Date & Time",
|
"editEntryDateDialogTitle": "Date & Time",
|
||||||
"editEntryDateDialogSet": "Set",
|
"editEntryDateDialogSet": "Set",
|
||||||
"editEntryDateDialogShift": "Shift",
|
"editEntryDateDialogShift": "Shift",
|
||||||
"editEntryDateDialogExtractFromTitle": "Extract from title",
|
|
||||||
"editEntryDateDialogClear": "Clear",
|
"editEntryDateDialogClear": "Clear",
|
||||||
"editEntryDateDialogFieldSelection": "Field selection",
|
"editEntryDateDialogSourceFieldLabel": "Value:",
|
||||||
|
"editEntryDateDialogSourceCustomDate": "custom date",
|
||||||
|
"editEntryDateDialogSourceTitle": "extracted from title",
|
||||||
|
"editEntryDateDialogSourceFileModifiedDate": "file modified date",
|
||||||
|
"editEntryDateDialogTargetFieldsHeader": "Fields to modify",
|
||||||
"editEntryDateDialogHours": "Hours",
|
"editEntryDateDialogHours": "Hours",
|
||||||
"editEntryDateDialogMinutes": "Minutes",
|
"editEntryDateDialogMinutes": "Minutes",
|
||||||
|
|
||||||
|
|
|
@ -180,9 +180,12 @@
|
||||||
"editEntryDateDialogTitle": "Date & Heure",
|
"editEntryDateDialogTitle": "Date & Heure",
|
||||||
"editEntryDateDialogSet": "Régler",
|
"editEntryDateDialogSet": "Régler",
|
||||||
"editEntryDateDialogShift": "Décaler",
|
"editEntryDateDialogShift": "Décaler",
|
||||||
"editEntryDateDialogExtractFromTitle": "Extraire du titre",
|
|
||||||
"editEntryDateDialogClear": "Effacer",
|
"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",
|
"editEntryDateDialogHours": "Heures",
|
||||||
"editEntryDateDialogMinutes": "Minutes",
|
"editEntryDateDialogMinutes": "Minutes",
|
||||||
|
|
||||||
|
|
|
@ -180,9 +180,12 @@
|
||||||
"editEntryDateDialogTitle": "날짜 및 시간",
|
"editEntryDateDialogTitle": "날짜 및 시간",
|
||||||
"editEntryDateDialogSet": "편집",
|
"editEntryDateDialogSet": "편집",
|
||||||
"editEntryDateDialogShift": "시간 이동",
|
"editEntryDateDialogShift": "시간 이동",
|
||||||
"editEntryDateDialogExtractFromTitle": "제목에서 추출",
|
|
||||||
"editEntryDateDialogClear": "삭제",
|
"editEntryDateDialogClear": "삭제",
|
||||||
"editEntryDateDialogFieldSelection": "필드 선택",
|
"editEntryDateDialogSourceFieldLabel": "값:",
|
||||||
|
"editEntryDateDialogSourceCustomDate": "지정 날짜",
|
||||||
|
"editEntryDateDialogSourceTitle": "제목에서 추출",
|
||||||
|
"editEntryDateDialogSourceFileModifiedDate": "파일 수정한 날짜",
|
||||||
|
"editEntryDateDialogTargetFieldsHeader": "수정할 필드",
|
||||||
"editEntryDateDialogHours": "시간",
|
"editEntryDateDialogHours": "시간",
|
||||||
"editEntryDateDialogMinutes": "분",
|
"editEntryDateDialogMinutes": "분",
|
||||||
|
|
||||||
|
|
|
@ -180,9 +180,7 @@
|
||||||
"editEntryDateDialogTitle": "Дата и время",
|
"editEntryDateDialogTitle": "Дата и время",
|
||||||
"editEntryDateDialogSet": "Задать",
|
"editEntryDateDialogSet": "Задать",
|
||||||
"editEntryDateDialogShift": "Сдвиг",
|
"editEntryDateDialogShift": "Сдвиг",
|
||||||
"editEntryDateDialogExtractFromTitle": "Извлечь из названия",
|
|
||||||
"editEntryDateDialogClear": "Очистить",
|
"editEntryDateDialogClear": "Очистить",
|
||||||
"editEntryDateDialogFieldSelection": "Выбор поля",
|
|
||||||
"editEntryDateDialogHours": "Часов",
|
"editEntryDateDialogHours": "Часов",
|
||||||
"editEntryDateDialogMinutes": "Минут",
|
"editEntryDateDialogMinutes": "Минут",
|
||||||
|
|
||||||
|
|
|
@ -671,15 +671,55 @@ class AvesEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Set<EntryDataType>> editDate(DateModifier modifier) async {
|
Future<Set<EntryDataType>> editDate(DateModifier modifier) async {
|
||||||
if (modifier.action == DateEditAction.extractFromTitle) {
|
final action = modifier.action;
|
||||||
final _title = bestTitle;
|
if (action == DateEditAction.set) {
|
||||||
if (_title == null) return {};
|
final source = modifier.setSource;
|
||||||
final date = parseUnknownDateFormat(_title);
|
if (source == null) {
|
||||||
if (date == null) {
|
await reportService.recordError('edit date with action=$action but source is null', null);
|
||||||
await reportService.recordError('failed to parse date from title=$_title', null);
|
|
||||||
return {};
|
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);
|
final newFields = await metadataEditService.editDate(this, modifier);
|
||||||
return newFields.isEmpty
|
return newFields.isEmpty
|
||||||
|
|
|
@ -4,7 +4,7 @@ import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
@immutable
|
@immutable
|
||||||
class DateModifier {
|
class DateModifier {
|
||||||
static const allDateFields = [
|
static const writableDateFields = [
|
||||||
MetadataField.exifDate,
|
MetadataField.exifDate,
|
||||||
MetadataField.exifDateOriginal,
|
MetadataField.exifDateOriginal,
|
||||||
MetadataField.exifDateDigitized,
|
MetadataField.exifDateDigitized,
|
||||||
|
@ -13,8 +13,15 @@ class DateModifier {
|
||||||
|
|
||||||
final DateEditAction action;
|
final DateEditAction action;
|
||||||
final Set<MetadataField> fields;
|
final Set<MetadataField> fields;
|
||||||
final DateTime? dateTime;
|
final DateSetSource? setSource;
|
||||||
|
final DateTime? setDateTime;
|
||||||
final int? shiftMinutes;
|
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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,10 +8,19 @@ enum MetadataField {
|
||||||
enum DateEditAction {
|
enum DateEditAction {
|
||||||
set,
|
set,
|
||||||
shift,
|
shift,
|
||||||
extractFromTitle,
|
|
||||||
clear,
|
clear,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum DateSetSource {
|
||||||
|
custom,
|
||||||
|
title,
|
||||||
|
fileModifiedDate,
|
||||||
|
exifDate,
|
||||||
|
exifDateOriginal,
|
||||||
|
exifDateDigitized,
|
||||||
|
exifGpsDate,
|
||||||
|
}
|
||||||
|
|
||||||
enum MetadataType {
|
enum MetadataType {
|
||||||
// JPEG COM marker or GIF comment
|
// JPEG COM marker or GIF comment
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -77,9 +77,9 @@ class PlatformMetadataEditService implements MetadataEditService {
|
||||||
try {
|
try {
|
||||||
final result = await platform.invokeMethod('editDate', <String, dynamic>{
|
final result = await platform.invokeMethod('editDate', <String, dynamic>{
|
||||||
'entry': _toPlatformEntryMap(entry),
|
'entry': _toPlatformEntryMap(entry),
|
||||||
'dateMillis': modifier.dateTime?.millisecondsSinceEpoch,
|
'dateMillis': modifier.setDateTime?.millisecondsSinceEpoch,
|
||||||
'shiftMinutes': modifier.shiftMinutes,
|
'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<String, dynamic>();
|
if (result != null) return (result as Map).cast<String, dynamic>();
|
||||||
} on PlatformException catch (e, stack) {
|
} on PlatformException catch (e, stack) {
|
||||||
|
@ -140,19 +140,6 @@ class PlatformMetadataEditService implements MetadataEditService {
|
||||||
return {};
|
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) {
|
String _toPlatformMetadataType(MetadataType type) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case MetadataType.comment:
|
case MetadataType.comment:
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/entry_xmp_iptc.dart';
|
import 'package:aves/model/entry_xmp_iptc.dart';
|
||||||
import 'package:aves/model/metadata/catalog.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/metadata/overlay.dart';
|
||||||
import 'package:aves/model/multipage.dart';
|
import 'package:aves/model/multipage.dart';
|
||||||
import 'package:aves/model/panorama.dart';
|
import 'package:aves/model/panorama.dart';
|
||||||
|
@ -28,6 +29,8 @@ abstract class MetadataFetchService {
|
||||||
Future<bool> hasContentResolverProp(String prop);
|
Future<bool> hasContentResolverProp(String prop);
|
||||||
|
|
||||||
Future<String?> getContentResolverProp(AvesEntry entry, String prop);
|
Future<String?> getContentResolverProp(AvesEntry entry, String prop);
|
||||||
|
|
||||||
|
Future<DateTime?> getDate(AvesEntry entry, MetadataField field);
|
||||||
}
|
}
|
||||||
|
|
||||||
class PlatformMetadataFetchService implements MetadataFetchService {
|
class PlatformMetadataFetchService implements MetadataFetchService {
|
||||||
|
@ -223,4 +226,22 @@ class PlatformMetadataFetchService implements MetadataFetchService {
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<DateTime?> getDate(AvesEntry entry, MetadataField field) async {
|
||||||
|
try {
|
||||||
|
final result = await platform.invokeMethod('getDate', <String, dynamic>{
|
||||||
|
'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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -97,6 +97,7 @@ class DurationsProvider extends StatelessWidget {
|
||||||
class DurationsData {
|
class DurationsData {
|
||||||
// common animations
|
// common animations
|
||||||
final Duration expansionTileAnimation;
|
final Duration expansionTileAnimation;
|
||||||
|
final Duration formTransition;
|
||||||
final Duration iconAnimation;
|
final Duration iconAnimation;
|
||||||
final Duration staggeredAnimation;
|
final Duration staggeredAnimation;
|
||||||
final Duration staggeredAnimationPageTarget;
|
final Duration staggeredAnimationPageTarget;
|
||||||
|
@ -111,6 +112,7 @@ class DurationsData {
|
||||||
|
|
||||||
const DurationsData({
|
const DurationsData({
|
||||||
this.expansionTileAnimation = const Duration(milliseconds: 200),
|
this.expansionTileAnimation = const Duration(milliseconds: 200),
|
||||||
|
this.formTransition = const Duration(milliseconds: 200),
|
||||||
this.iconAnimation = const Duration(milliseconds: 300),
|
this.iconAnimation = const Duration(milliseconds: 300),
|
||||||
this.staggeredAnimation = const Duration(milliseconds: 375),
|
this.staggeredAnimation = const Duration(milliseconds: 375),
|
||||||
this.staggeredAnimationPageTarget = const Duration(milliseconds: 800),
|
this.staggeredAnimationPageTarget = const Duration(milliseconds: 800),
|
||||||
|
@ -123,6 +125,7 @@ class DurationsData {
|
||||||
return DurationsData(
|
return DurationsData(
|
||||||
// as of Flutter v2.5.1, `ExpansionPanelList` throws if animation duration is zero
|
// as of Flutter v2.5.1, `ExpansionPanelList` throws if animation duration is zero
|
||||||
expansionTileAnimation: const Duration(microseconds: 1),
|
expansionTileAnimation: const Duration(microseconds: 1),
|
||||||
|
formTransition: Duration.zero,
|
||||||
iconAnimation: Duration.zero,
|
iconAnimation: Duration.zero,
|
||||||
staggeredAnimation: Duration.zero,
|
staggeredAnimation: Duration.zero,
|
||||||
staggeredAnimationPageTarget: Duration.zero,
|
staggeredAnimationPageTarget: Duration.zero,
|
||||||
|
|
82
lib/widgets/common/basic/wheel.dart
Normal file
82
lib/widgets/common/basic/wheel.dart
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class WheelSelector<T> extends StatefulWidget {
|
||||||
|
final ValueNotifier<T> valueNotifier;
|
||||||
|
final List<T> 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<T>();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WheelSelectorState<T> extends State<WheelSelector<T>> {
|
||||||
|
late final ScrollController _controller;
|
||||||
|
|
||||||
|
static const itemSize = Size(40, 40);
|
||||||
|
|
||||||
|
ValueNotifier<T> get valueNotifier => widget.valueNotifier;
|
||||||
|
|
||||||
|
List<T> 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(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,13 +4,13 @@ import 'package:aves/model/metadata/enums.dart';
|
||||||
import 'package:aves/theme/durations.dart';
|
import 'package:aves/theme/durations.dart';
|
||||||
import 'package:aves/theme/format.dart';
|
import 'package:aves/theme/format.dart';
|
||||||
import 'package:aves/theme/icons.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/extensions/build_context.dart';
|
||||||
import 'package:aves/widgets/common/providers/media_query_data_provider.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:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../aves_dialog.dart';
|
|
||||||
|
|
||||||
class EditEntryDateDialog extends StatefulWidget {
|
class EditEntryDateDialog extends StatefulWidget {
|
||||||
final AvesEntry entry;
|
final AvesEntry entry;
|
||||||
|
|
||||||
|
@ -25,169 +25,291 @@ class EditEntryDateDialog extends StatefulWidget {
|
||||||
|
|
||||||
class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
|
class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
|
||||||
DateEditAction _action = DateEditAction.set;
|
DateEditAction _action = DateEditAction.set;
|
||||||
late Set<MetadataField> _fields;
|
DateSetSource _setSource = DateSetSource.custom;
|
||||||
late DateTime _dateTime;
|
late DateTime _setDateTime;
|
||||||
int _shiftMinutes = 60;
|
late ValueNotifier<int> _shiftHour, _shiftMinute;
|
||||||
|
late ValueNotifier<String> _shiftSign;
|
||||||
bool _showOptions = false;
|
bool _showOptions = false;
|
||||||
|
final Set<MetadataField> _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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_fields = {
|
_initSet();
|
||||||
MetadataField.exifDate,
|
_initShift(60);
|
||||||
MetadataField.exifDateDigitized,
|
}
|
||||||
MetadataField.exifDateOriginal,
|
|
||||||
};
|
void _initSet() {
|
||||||
_dateTime = entry.bestDate ?? DateTime.now();
|
_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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MediaQueryDataProvider(
|
return MediaQueryDataProvider(
|
||||||
child: Builder(
|
child: TooltipTheme(
|
||||||
builder: (context) {
|
data: TooltipTheme.of(context).copyWith(
|
||||||
|
preferBelow: false,
|
||||||
|
),
|
||||||
|
child: Builder(builder: (context) {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
final locale = l10n.localeName;
|
|
||||||
final use24hour = context.select<MediaQueryData, bool>((v) => v.alwaysUse24HourFormat);
|
|
||||||
|
|
||||||
void _updateAction(DateEditAction? action) {
|
return AvesDialog(
|
||||||
if (action == null) return;
|
title: l10n.editEntryDateDialogTitle,
|
||||||
setState(() => _action = action);
|
scrollableContent: [
|
||||||
}
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 16, top: 8, right: 16),
|
||||||
Widget _tileText(String text) => Text(
|
child: DropdownButton<DateEditAction>(
|
||||||
text,
|
items: DateEditAction.values
|
||||||
softWrap: false,
|
.map((v) => DropdownMenuItem<DateEditAction>(
|
||||||
overflow: TextOverflow.fade,
|
value: v,
|
||||||
maxLines: 1,
|
child: Text(_actionText(context, v)),
|
||||||
);
|
))
|
||||||
|
.toList(),
|
||||||
final setTile = Row(
|
value: _action,
|
||||||
children: [
|
onChanged: (v) => setState(() => _action = v!),
|
||||||
Expanded(
|
isExpanded: true,
|
||||||
child: RadioListTile<DateEditAction>(
|
dropdownColor: dropdownColor,
|
||||||
value: DateEditAction.set,
|
|
||||||
groupValue: _action,
|
|
||||||
onChanged: _updateAction,
|
|
||||||
title: _tileText(l10n.editEntryDateDialogSet),
|
|
||||||
subtitle: Text(formatDateTime(_dateTime, locale, use24hour)),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
AnimatedSwitcher(
|
||||||
padding: const EdgeInsetsDirectional.only(end: 12),
|
duration: context.read<DurationsData>().formTransition,
|
||||||
child: IconButton(
|
switchInCurve: Curves.easeInOutCubic,
|
||||||
icon: const Icon(AIcons.edit),
|
switchOutCurve: Curves.easeInOutCubic,
|
||||||
onPressed: _action == DateEditAction.set ? _editDate : null,
|
transitionBuilder: _formTransitionBuilder,
|
||||||
tooltip: l10n.changeTooltip,
|
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<DateEditAction>(
|
|
||||||
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<DateEditAction>(
|
|
||||||
value: DateEditAction.extractFromTitle,
|
|
||||||
groupValue: _action,
|
|
||||||
onChanged: _updateAction,
|
|
||||||
title: _tileText(l10n.editEntryDateDialogExtractFromTitle),
|
|
||||||
);
|
|
||||||
final clearTile = RadioListTile<DateEditAction>(
|
|
||||||
value: DateEditAction.clear,
|
|
||||||
groupValue: _action,
|
|
||||||
onChanged: _updateAction,
|
|
||||||
title: _tileText(l10n.editEntryDateDialogClear),
|
|
||||||
);
|
|
||||||
|
|
||||||
final animationDuration = context.select<DurationsData, Duration>((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() {
|
Widget _formTransitionBuilder(Widget child, Animation<double> animation) => FadeTransition(
|
||||||
final abs = _shiftMinutes.abs();
|
opacity: animation,
|
||||||
final h = abs ~/ 60;
|
child: SizeTransition(
|
||||||
final m = abs % 60;
|
sizeFactor: animation,
|
||||||
return '${_shiftMinutes.isNegative ? '-' : '+'}$h:${m.toString().padLeft(2, '0')}';
|
axisAlignment: -1,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
List<Widget> _buildSetContent(BuildContext context) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
final locale = l10n.localeName;
|
||||||
|
final use24hour = context.select<MediaQueryData, bool>((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<DateSetSource>(
|
||||||
|
items: DateSetSource.values
|
||||||
|
.map((v) => DropdownMenuItem<DateSetSource>(
|
||||||
|
value: v,
|
||||||
|
child: Text(_setSourceText(context, v)),
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
selectedItemBuilder: (context) => DateSetSource.values
|
||||||
|
.map((v) => DropdownMenuItem<DateSetSource>(
|
||||||
|
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<DurationsData>().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<DurationsData>().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) {
|
String _fieldTitle(MetadataField field) {
|
||||||
|
@ -206,7 +328,7 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
|
||||||
Future<void> _editDate() async {
|
Future<void> _editDate() async {
|
||||||
final _date = await showDatePicker(
|
final _date = await showDatePicker(
|
||||||
context: context,
|
context: context,
|
||||||
initialDate: _dateTime,
|
initialDate: _setDateTime,
|
||||||
firstDate: DateTime(0),
|
firstDate: DateTime(0),
|
||||||
lastDate: DateTime.now(),
|
lastDate: DateTime.now(),
|
||||||
confirmText: context.l10n.nextButtonLabel,
|
confirmText: context.l10n.nextButtonLabel,
|
||||||
|
@ -215,11 +337,11 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
|
||||||
|
|
||||||
final _time = await showTimePicker(
|
final _time = await showTimePicker(
|
||||||
context: context,
|
context: context,
|
||||||
initialTime: TimeOfDay.fromDateTime(_dateTime),
|
initialTime: TimeOfDay.fromDateTime(_setDateTime),
|
||||||
);
|
);
|
||||||
if (_time == null) return;
|
if (_time == null) return;
|
||||||
|
|
||||||
setState(() => _dateTime = DateTime(
|
setState(() => _setDateTime = DateTime(
|
||||||
_date.year,
|
_date.year,
|
||||||
_date.month,
|
_date.month,
|
||||||
_date.day,
|
_date.day,
|
||||||
|
@ -228,28 +350,16 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
void _editShift() async {
|
|
||||||
final picked = await showDialog<int>(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => TimeShiftDialog(
|
|
||||||
initialShiftMinutes: _shiftMinutes,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (picked == null) return;
|
|
||||||
|
|
||||||
setState(() => _shiftMinutes = picked);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _submit(BuildContext context) {
|
void _submit(BuildContext context) {
|
||||||
late DateModifier modifier;
|
late DateModifier modifier;
|
||||||
switch (_action) {
|
switch (_action) {
|
||||||
case DateEditAction.set:
|
case DateEditAction.set:
|
||||||
modifier = DateModifier(_action, _fields, dateTime: _dateTime);
|
modifier = DateModifier(_action, _fields, setSource: _setSource, setDateTime: _setDateTime);
|
||||||
break;
|
break;
|
||||||
case DateEditAction.shift:
|
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;
|
break;
|
||||||
case DateEditAction.extractFromTitle:
|
|
||||||
case DateEditAction.clear:
|
case DateEditAction.clear:
|
||||||
modifier = DateModifier(_action, _fields);
|
modifier = DateModifier(_action, _fields);
|
||||||
break;
|
break;
|
||||||
|
@ -257,185 +367,3 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
|
||||||
Navigator.pop(context, modifier);
|
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<TimeShiftDialog> {
|
|
||||||
late ValueNotifier<int> _hour, _minute;
|
|
||||||
late ValueNotifier<String> _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<T> extends StatefulWidget {
|
|
||||||
final ValueNotifier<T> valueNotifier;
|
|
||||||
final List<T> 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<T>();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _WheelState<T> extends State<_Wheel<T>> {
|
|
||||||
late final ScrollController _controller;
|
|
||||||
|
|
||||||
static const itemSize = Size(40, 40);
|
|
||||||
|
|
||||||
ValueNotifier<T> get valueNotifier => widget.valueNotifier;
|
|
||||||
|
|
||||||
List<T> 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(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -69,7 +69,7 @@ class _RemoveEntryMetadataDialogState extends State<RemoveEntryMetadataDialog> {
|
||||||
),
|
),
|
||||||
isExpanded: _showMore,
|
isExpanded: _showMore,
|
||||||
canTapOnHeader: true,
|
canTapOnHeader: true,
|
||||||
backgroundColor: Theme.of(context).dialogBackgroundColor,
|
backgroundColor: Colors.transparent,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
@ -1 +1,17 @@
|
||||||
{}
|
{
|
||||||
|
"de": [
|
||||||
|
"editEntryDateDialogSourceFieldLabel",
|
||||||
|
"editEntryDateDialogSourceCustomDate",
|
||||||
|
"editEntryDateDialogSourceTitle",
|
||||||
|
"editEntryDateDialogSourceFileModifiedDate",
|
||||||
|
"editEntryDateDialogTargetFieldsHeader"
|
||||||
|
],
|
||||||
|
|
||||||
|
"ru": [
|
||||||
|
"editEntryDateDialogSourceFieldLabel",
|
||||||
|
"editEntryDateDialogSourceCustomDate",
|
||||||
|
"editEntryDateDialogSourceTitle",
|
||||||
|
"editEntryDateDialogSourceFileModifiedDate",
|
||||||
|
"editEntryDateDialogTargetFieldsHeader"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue