import 'package:aves/model/entry.dart'; import 'package:aves/model/metadata/date_modifier.dart'; import 'package:aves/model/metadata/enums.dart'; import 'package:aves/theme/format.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/material.dart'; import 'aves_dialog.dart'; class EditEntryDateDialog extends StatefulWidget { final AvesEntry entry; const EditEntryDateDialog({ Key? key, required this.entry, }) : super(key: key); @override _EditEntryDateDialogState createState() => _EditEntryDateDialogState(); } class _EditEntryDateDialogState extends State { DateEditAction _action = DateEditAction.set; late Set _fields; late DateTime _dateTime; int _shiftMinutes = 60; bool _showOptions = false; AvesEntry get entry => widget.entry; @override void initState() { super.initState(); _fields = { MetadataField.exifDate, MetadataField.exifDateDigitized, MetadataField.exifDateOriginal, }; _dateTime = entry.bestDate ?? DateTime.now(); } @override Widget build(BuildContext context) { final l10n = context.l10n; 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, l10n.localeName)), ), ), Padding( padding: const EdgeInsetsDirectional.only(end: 12), child: IconButton( icon: const Icon(AIcons.edit), onPressed: _action == DateEditAction.set ? _editDate : null, tooltip: context.l10n.changeTooltip, ), ), ], ); 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: context.l10n.changeTooltip, ), ), ], ); final clearTile = RadioListTile( value: DateEditAction.clear, groupValue: _action, onChanged: _updateAction, title: _tileText(l10n.editEntryDateDialogClear), ); 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( context: context, title: context.l10n.editEntryDateDialogTitle, scrollableContent: [ setTile, shiftTile, clearTile, Padding( padding: const EdgeInsets.only(bottom: 1), child: ExpansionPanelList( expansionCallback: (index, isExpanded) { setState(() => _showOptions = !isExpanded); }, 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(context.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')}'; } String _fieldTitle(MetadataField field) { switch (field) { case MetadataField.exifDate: return 'Exif date'; case MetadataField.exifDateOriginal: return 'Exif original date'; case MetadataField.exifDateDigitized: return 'Exif digitized date'; case MetadataField.exifGpsDate: return 'Exif GPS date'; } } Future _editDate() async { final _date = await showDatePicker( context: context, initialDate: _dateTime, firstDate: DateTime(0), lastDate: DateTime.now(), confirmText: context.l10n.nextButtonLabel, ); if (_date == null) return; final _time = await showTimePicker( context: context, initialTime: TimeOfDay.fromDateTime(_dateTime), ); if (_time == null) return; setState(() => _dateTime = DateTime( _date.year, _date.month, _date.day, _time.hour, _time.minute, )); } 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); break; case DateEditAction.shift: modifier = DateModifier(_action, _fields, shiftMinutes: _shiftMinutes); break; case DateEditAction.clear: modifier = DateModifier(_action, _fields); break; } 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( context: context, 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(), ), ), ), ); } }