#146 info: option to set date from other fields

This commit is contained in:
Thibault Deckers 2021-12-28 10:37:52 +09:00
parent 9d5a834fca
commit da7b2ee8c1
17 changed files with 568 additions and 380 deletions

View file

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

View file

@ -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"

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -180,9 +180,12 @@
"editEntryDateDialogTitle": "날짜 및 시간", "editEntryDateDialogTitle": "날짜 및 시간",
"editEntryDateDialogSet": "편집", "editEntryDateDialogSet": "편집",
"editEntryDateDialogShift": "시간 이동", "editEntryDateDialogShift": "시간 이동",
"editEntryDateDialogExtractFromTitle": "제목에서 추출",
"editEntryDateDialogClear": "삭제", "editEntryDateDialogClear": "삭제",
"editEntryDateDialogFieldSelection": "필드 선택", "editEntryDateDialogSourceFieldLabel": "값:",
"editEntryDateDialogSourceCustomDate": "지정 날짜",
"editEntryDateDialogSourceTitle": "제목에서 추출",
"editEntryDateDialogSourceFileModifiedDate": "파일 수정한 날짜",
"editEntryDateDialogTargetFieldsHeader": "수정할 필드",
"editEntryDateDialogHours": "시간", "editEntryDateDialogHours": "시간",
"editEntryDateDialogMinutes": "분", "editEntryDateDialogMinutes": "분",

View file

@ -180,9 +180,7 @@
"editEntryDateDialogTitle": "Дата и время", "editEntryDateDialogTitle": "Дата и время",
"editEntryDateDialogSet": "Задать", "editEntryDateDialogSet": "Задать",
"editEntryDateDialogShift": "Сдвиг", "editEntryDateDialogShift": "Сдвиг",
"editEntryDateDialogExtractFromTitle": "Извлечь из названия",
"editEntryDateDialogClear": "Очистить", "editEntryDateDialogClear": "Очистить",
"editEntryDateDialogFieldSelection": "Выбор поля",
"editEntryDateDialogHours": "Часов", "editEntryDateDialogHours": "Часов",
"editEntryDateDialogMinutes": "Минут", "editEntryDateDialogMinutes": "Минут",

View file

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

View file

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

View file

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

View file

@ -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:

View file

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

View file

@ -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,

View 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(),
),
),
),
);
}
}

View file

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

View file

@ -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,
), ),
], ],
), ),

View file

@ -1 +1,17 @@
{} {
"de": [
"editEntryDateDialogSourceFieldLabel",
"editEntryDateDialogSourceCustomDate",
"editEntryDateDialogSourceTitle",
"editEntryDateDialogSourceFileModifiedDate",
"editEntryDateDialogTargetFieldsHeader"
],
"ru": [
"editEntryDateDialogSourceFieldLabel",
"editEntryDateDialogSourceCustomDate",
"editEntryDateDialogSourceTitle",
"editEntryDateDialogSourceFileModifiedDate",
"editEntryDateDialogTargetFieldsHeader"
]
}