#883 bulk converting motion photos to still images

This commit is contained in:
Thibault Deckers 2024-06-23 02:36:44 +02:00
parent 87cfae1e9a
commit 978c22dc50
12 changed files with 328 additions and 227 deletions

View file

@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file.
- Collection: stack RAW and JPEG with same file names - Collection: stack RAW and JPEG with same file names
- Collection: ask to rename/replace/skip when converting items with name conflict - Collection: ask to rename/replace/skip when converting items with name conflict
- Export: bulk converting motion photos to still images
## <a id="v1.11.3"></a>[v1.11.3] - 2024-06-17 ## <a id="v1.11.3"></a>[v1.11.3] - 2024-06-17

View file

@ -3,6 +3,7 @@ import 'dart:collection';
import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/entry/extensions/multipage.dart'; import 'package:aves/model/entry/extensions/multipage.dart';
import 'package:aves/model/entry/extensions/props.dart';
import 'package:aves/model/entry/sort.dart'; import 'package:aves/model/entry/sort.dart';
import 'package:aves/model/favourites.dart'; import 'package:aves/model/favourites.dart';
import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/album.dart';
@ -13,7 +14,6 @@ import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/filters/query.dart'; import 'package:aves/model/filters/query.dart';
import 'package:aves/model/filters/rating.dart'; import 'package:aves/model/filters/rating.dart';
import 'package:aves/model/filters/trash.dart'; import 'package:aves/model/filters/trash.dart';
import 'package:aves/model/filters/type.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/events.dart'; import 'package:aves/model/source/events.dart';
@ -224,7 +224,7 @@ class CollectionLens with ChangeNotifier {
} }
void _stackDevelopedRaws() { void _stackDevelopedRaws() {
final allRawEntries = _filteredSortedEntries.where(TypeFilter.raw.test).toSet(); final allRawEntries = _filteredSortedEntries.where((entry) => entry.isRaw).toSet();
if (allRawEntries.isNotEmpty) { if (allRawEntries.isNotEmpty) {
final allDevelopedEntries = _filteredSortedEntries.where(MimeFilter(MimeTypes.jpeg).test).toSet(); final allDevelopedEntries = _filteredSortedEntries.where(MimeFilter(MimeTypes.jpeg).test).toSet();
final rawEntriesByDir = groupBy<AvesEntry, String?>(allRawEntries, (entry) => entry.directory); final rawEntriesByDir = groupBy<AvesEntry, String?>(allRawEntries, (entry) => entry.directory);

View file

@ -193,15 +193,17 @@ class PlatformMediaEditService implements MediaEditService {
@immutable @immutable
class EntryConvertOptions extends Equatable { class EntryConvertOptions extends Equatable {
final EntryConvertAction action;
final String mimeType; final String mimeType;
final bool writeMetadata; final bool writeMetadata;
final LengthUnit lengthUnit; final LengthUnit lengthUnit;
final int width, height, quality; final int width, height, quality;
@override @override
List<Object?> get props => [mimeType, writeMetadata, lengthUnit, width, height, quality]; List<Object?> get props => [action, mimeType, writeMetadata, lengthUnit, width, height, quality];
const EntryConvertOptions({ const EntryConvertOptions({
required this.action,
required this.mimeType, required this.mimeType,
required this.writeMetadata, required this.writeMetadata,
required this.lengthUnit, required this.lengthUnit,

View file

@ -5,45 +5,46 @@ import 'package:flutter/material.dart';
extension ExtraEntrySetActionView on EntrySetAction { extension ExtraEntrySetActionView on EntrySetAction {
String getText(BuildContext context) { String getText(BuildContext context) {
final l10n = context.l10n;
return switch (this) { return switch (this) {
// general // general
EntrySetAction.configureView => context.l10n.menuActionConfigureView, EntrySetAction.configureView => l10n.menuActionConfigureView,
EntrySetAction.select => context.l10n.menuActionSelect, EntrySetAction.select => l10n.menuActionSelect,
EntrySetAction.selectAll => context.l10n.menuActionSelectAll, EntrySetAction.selectAll => l10n.menuActionSelectAll,
EntrySetAction.selectNone => context.l10n.menuActionSelectNone, EntrySetAction.selectNone => l10n.menuActionSelectNone,
// browsing // browsing
EntrySetAction.searchCollection => MaterialLocalizations.of(context).searchFieldLabel, EntrySetAction.searchCollection => MaterialLocalizations.of(context).searchFieldLabel,
EntrySetAction.toggleTitleSearch => EntrySetAction.toggleTitleSearch =>
// different data depending on toggle state // different data depending on toggle state
context.l10n.collectionActionShowTitleSearch, l10n.collectionActionShowTitleSearch,
EntrySetAction.addShortcut => context.l10n.collectionActionAddShortcut, EntrySetAction.addShortcut => l10n.collectionActionAddShortcut,
EntrySetAction.setHome => context.l10n.collectionActionSetHome, EntrySetAction.setHome => l10n.collectionActionSetHome,
EntrySetAction.emptyBin => context.l10n.collectionActionEmptyBin, EntrySetAction.emptyBin => l10n.collectionActionEmptyBin,
// browsing or selecting // browsing or selecting
EntrySetAction.map => context.l10n.menuActionMap, EntrySetAction.map => l10n.menuActionMap,
EntrySetAction.slideshow => context.l10n.menuActionSlideshow, EntrySetAction.slideshow => l10n.menuActionSlideshow,
EntrySetAction.stats => context.l10n.menuActionStats, EntrySetAction.stats => l10n.menuActionStats,
EntrySetAction.rescan => context.l10n.collectionActionRescan, EntrySetAction.rescan => l10n.collectionActionRescan,
// selecting // selecting
EntrySetAction.share => context.l10n.entryActionShare, EntrySetAction.share => l10n.entryActionShare,
EntrySetAction.delete => context.l10n.entryActionDelete, EntrySetAction.delete => l10n.entryActionDelete,
EntrySetAction.restore => context.l10n.entryActionRestore, EntrySetAction.restore => l10n.entryActionRestore,
EntrySetAction.copy => context.l10n.collectionActionCopy, EntrySetAction.copy => l10n.collectionActionCopy,
EntrySetAction.move => context.l10n.collectionActionMove, EntrySetAction.move => l10n.collectionActionMove,
EntrySetAction.rename => context.l10n.entryActionRename, EntrySetAction.rename => l10n.entryActionRename,
EntrySetAction.convert => context.l10n.entryActionConvert, EntrySetAction.convert => l10n.entryActionConvert,
EntrySetAction.toggleFavourite => EntrySetAction.toggleFavourite =>
// different data depending on toggle state // different data depending on toggle state
context.l10n.entryActionAddFavourite, l10n.entryActionAddFavourite,
EntrySetAction.rotateCCW => context.l10n.entryActionRotateCCW, EntrySetAction.rotateCCW => l10n.entryActionRotateCCW,
EntrySetAction.rotateCW => context.l10n.entryActionRotateCW, EntrySetAction.rotateCW => l10n.entryActionRotateCW,
EntrySetAction.flip => context.l10n.entryActionFlip, EntrySetAction.flip => l10n.entryActionFlip,
EntrySetAction.editDate => context.l10n.entryInfoActionEditDate, EntrySetAction.editDate => l10n.entryInfoActionEditDate,
EntrySetAction.editLocation => context.l10n.entryInfoActionEditLocation, EntrySetAction.editLocation => l10n.entryInfoActionEditLocation,
EntrySetAction.editTitleDescription => context.l10n.entryInfoActionEditTitleDescription, EntrySetAction.editTitleDescription => l10n.entryInfoActionEditTitleDescription,
EntrySetAction.editRating => context.l10n.entryInfoActionEditRating, EntrySetAction.editRating => l10n.entryInfoActionEditRating,
EntrySetAction.editTags => context.l10n.entryInfoActionEditTags, EntrySetAction.editTags => l10n.entryInfoActionEditTags,
EntrySetAction.removeMetadata => context.l10n.entryInfoActionRemoveMetadata, EntrySetAction.removeMetadata => l10n.entryInfoActionRemoveMetadata,
}; };
} }

View file

@ -0,0 +1,21 @@
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves_model/aves_model.dart';
import 'package:flutter/widgets.dart';
extension ExtraEntryConvertActionView on EntryConvertAction {
String getText(BuildContext context) {
final l10n = context.l10n;
return switch (this) {
EntryConvertAction.convert => l10n.entryActionConvert,
EntryConvertAction.convertMotionPhotoToStillImage => l10n.entryActionConvertMotionPhotoToStillImage,
};
}
IconData getIconData() {
return switch (this) {
EntryConvertAction.convert => AIcons.convert,
EntryConvertAction.convertMotionPhotoToStillImage => AIcons.convertToStillImage,
};
}
}

View file

@ -5,6 +5,7 @@ import 'package:aves/model/device.dart';
import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/entry/extensions/favourites.dart'; import 'package:aves/model/entry/extensions/favourites.dart';
import 'package:aves/model/entry/extensions/metadata_edition.dart'; import 'package:aves/model/entry/extensions/metadata_edition.dart';
import 'package:aves/model/entry/extensions/multipage.dart';
import 'package:aves/model/entry/extensions/props.dart'; import 'package:aves/model/entry/extensions/props.dart';
import 'package:aves/model/favourites.dart'; import 'package:aves/model/favourites.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
@ -20,6 +21,7 @@ import 'package:aves/model/vaults/vaults.dart';
import 'package:aves/services/app_service.dart'; import 'package:aves/services/app_service.dart';
import 'package:aves/services/common/image_op_events.dart'; import 'package:aves/services/common/image_op_events.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/services/media/media_edit_service.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/theme/themes.dart'; import 'package:aves/theme/themes.dart';
import 'package:aves/utils/collection_utils.dart'; import 'package:aves/utils/collection_utils.dart';
@ -34,6 +36,7 @@ import 'package:aves/widgets/common/search/route.dart';
import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart'; import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart';
import 'package:aves/widgets/dialogs/aves_confirmation_dialog.dart'; import 'package:aves/widgets/dialogs/aves_confirmation_dialog.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/dialogs/convert_entry_dialog.dart';
import 'package:aves/widgets/dialogs/entry_editors/rename_entry_set_page.dart'; import 'package:aves/widgets/dialogs/entry_editors/rename_entry_set_page.dart';
import 'package:aves/widgets/dialogs/pick_dialogs/location_pick_page.dart'; import 'package:aves/widgets/dialogs/pick_dialogs/location_pick_page.dart';
import 'package:aves/widgets/map/map_page.dart'; import 'package:aves/widgets/map/map_page.dart';
@ -366,9 +369,23 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
_browse(context); _browse(context);
} }
void _convert(BuildContext context) { Future<void> _convert(BuildContext context) async {
final entries = _getTargetItems(context); final entries = _getTargetItems(context);
convert(context, entries);
final options = await showDialog<EntryConvertOptions>(
context: context,
builder: (context) => ConvertEntryDialog(entries: entries),
routeSettings: const RouteSettings(name: ConvertEntryDialog.routeName),
);
if (options == null) return;
switch (options.action) {
case EntryConvertAction.convert:
await doExport(context, entries, options);
case EntryConvertAction.convertMotionPhotoToStillImage:
final todoItems = entries.where((entry) => entry.isMotionPhoto).toSet();
await _edit(context, todoItems, (entry) => entry.removeTrailerVideo());
}
_browse(context); _browse(context);
} }

View file

@ -28,7 +28,6 @@ import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
import 'package:aves/widgets/common/action_mixins/size_aware.dart'; import 'package:aves/widgets/common/action_mixins/size_aware.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_confirmation_dialog.dart'; import 'package:aves/widgets/dialogs/aves_confirmation_dialog.dart';
import 'package:aves/widgets/dialogs/convert_entry_dialog.dart';
import 'package:aves/widgets/dialogs/pick_dialogs/album_pick_page.dart'; import 'package:aves/widgets/dialogs/pick_dialogs/album_pick_page.dart';
import 'package:aves/widgets/dialogs/selection_dialogs/single_selection.dart'; import 'package:aves/widgets/dialogs/selection_dialogs/single_selection.dart';
import 'package:aves/widgets/viewer/controls/notifications.dart'; import 'package:aves/widgets/viewer/controls/notifications.dart';
@ -38,14 +37,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
Future<void> convert(BuildContext context, Set<AvesEntry> targetEntries) async { Future<void> doExport(BuildContext context, Set<AvesEntry> targetEntries, EntryConvertOptions options) async {
final options = await showDialog<EntryConvertOptions>(
context: context,
builder: (context) => ConvertEntryDialog(entries: targetEntries),
routeSettings: const RouteSettings(name: ConvertEntryDialog.routeName),
);
if (options == null) return;
final destinationAlbum = await pickAlbum(context: context, moveType: MoveType.export); final destinationAlbum = await pickAlbum(context: context, moveType: MoveType.export);
if (destinationAlbum == null) return; if (destinationAlbum == null) return;
if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return; if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return;

View file

@ -1,5 +1,6 @@
import 'package:aves/model/app/support.dart'; import 'package:aves/model/app/support.dart';
import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/entry/extensions/multipage.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/ref/mime_types.dart'; import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/media/media_edit_service.dart'; import 'package:aves/services/media/media_edit_service.dart';
@ -7,6 +8,7 @@ import 'package:aves/theme/durations.dart';
import 'package:aves/theme/text.dart'; import 'package:aves/theme/text.dart';
import 'package:aves/theme/themes.dart'; import 'package:aves/theme/themes.dart';
import 'package:aves/utils/mime_utils.dart'; import 'package:aves/utils/mime_utils.dart';
import 'package:aves/view/src/metadata/convert_action.dart';
import 'package:aves/view/view.dart'; import 'package:aves/view/view.dart';
import 'package:aves/widgets/common/basic/list_tiles/slider.dart'; import 'package:aves/widgets/common/basic/list_tiles/slider.dart';
import 'package:aves/widgets/common/basic/text/change_highlight.dart'; import 'package:aves/widgets/common/basic/text/change_highlight.dart';
@ -34,6 +36,8 @@ class ConvertEntryDialog extends StatefulWidget {
} }
class _ConvertEntryDialogState extends State<ConvertEntryDialog> { class _ConvertEntryDialogState extends State<ConvertEntryDialog> {
late List<EntryConvertAction> _actionOptions;
EntryConvertAction _action = EntryConvertAction.convert;
final TextEditingController _widthController = TextEditingController(), _heightController = TextEditingController(); final TextEditingController _widthController = TextEditingController(), _heightController = TextEditingController();
final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false); final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false);
late ValueNotifier<String> _mimeTypeNotifier; late ValueNotifier<String> _mimeTypeNotifier;
@ -44,14 +48,16 @@ class _ConvertEntryDialogState extends State<ConvertEntryDialog> {
Set<AvesEntry> get entries => widget.entries; Set<AvesEntry> get entries => widget.entries;
static const imageExportFormats = [ EdgeInsets get contentHorizontalPadding => const EdgeInsets.symmetric(horizontal: AvesDialog.defaultHorizontalContentPadding);
static const _imageExportFormats = [
MimeTypes.bmp, MimeTypes.bmp,
MimeTypes.jpeg, MimeTypes.jpeg,
MimeTypes.png, MimeTypes.png,
MimeTypes.webp, MimeTypes.webp,
]; ];
static const qualityFormats = [ static const _qualityFormats = [
MimeTypes.jpeg, MimeTypes.jpeg,
MimeTypes.webp, MimeTypes.webp,
]; ];
@ -59,6 +65,10 @@ class _ConvertEntryDialogState extends State<ConvertEntryDialog> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_actionOptions = [
EntryConvertAction.convert,
if (entries.any((entry) => entry.isMotionPhoto)) EntryConvertAction.convertMotionPhotoToStillImage,
];
_mimeTypeNotifier = ValueNotifier(settings.convertMimeType); _mimeTypeNotifier = ValueNotifier(settings.convertMimeType);
_quality = settings.convertQuality; _quality = settings.convertQuality;
_writeMetadata = settings.convertWriteMetadata; _writeMetadata = settings.convertWriteMetadata;
@ -95,192 +105,41 @@ class _ConvertEntryDialogState extends State<ConvertEntryDialog> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n;
const contentHorizontalPadding = EdgeInsets.symmetric(horizontal: AvesDialog.defaultHorizontalContentPadding);
final colorScheme = Theme.of(context).colorScheme;
final trailingStyle = TextStyle(color: colorScheme.onSurfaceVariant);
final trailingChangeShadowColor = colorScheme.onSurface;
// used by the drop down to match input decoration
final textFieldDecorationBorder = Border(
bottom: BorderSide(
color: colorScheme.onSurface.withOpacity(0.38),
width: 1.0,
),
);
return AvesDialog( return AvesDialog(
scrollableContent: [ scrollableContent: [
const SizedBox(height: 16), const SizedBox(height: 16),
Padding( if (_actionOptions.length > 1)
padding: contentHorizontalPadding, Padding(
child: Row( padding: contentHorizontalPadding,
child: TextDropdownButton<EntryConvertAction>(
values: _actionOptions,
valueText: (v) => v.getText(context),
valueIcon: (v) => v.getIconData(),
value: _action,
onChanged: (v) {
_action = v!;
_validate();
setState(() {});
},
isExpanded: true,
dropdownColor: Themes.thirdLayerColor(context),
),
),
AnimatedSwitcher(
duration: context.read<DurationsData>().formTransition,
switchInCurve: Curves.easeInOutCubic,
switchOutCurve: Curves.easeInOutCubic,
transitionBuilder: AvesTransitions.formTransitionBuilder,
child: Column(
key: ValueKey(_action),
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text(l10n.exportEntryDialogFormat), if (_action == EntryConvertAction.convert) ..._buildConvertContent(context),
const SizedBox(width: AvesDialog.controlCaptionPadding), if (_action == EntryConvertAction.convertMotionPhotoToStillImage) const SizedBox(height: 16),
TextDropdownButton<String>(
values: imageExportFormats,
valueText: MimeUtils.displayType,
value: _mimeTypeNotifier.value,
onChanged: (selected) {
if (selected != null) {
setState(() => _mimeTypeNotifier.value = selected);
}
},
),
], ],
), ),
), ),
Padding(
padding: contentHorizontalPadding,
child: Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Expanded(
child: TextField(
controller: _widthController,
decoration: InputDecoration(labelText: l10n.exportEntryDialogWidth),
keyboardType: TextInputType.number,
onChanged: (value) {
final width = int.tryParse(value);
if (width != null) {
switch (_lengthUnit) {
case LengthUnit.px:
_heightController.text = '${(width / entries.first.displayAspectRatio).round()}';
case LengthUnit.percent:
_heightController.text = '$width';
}
} else {
_heightController.text = '';
}
_validate();
},
),
),
const SizedBox(width: 8),
const Text(AText.resolutionSeparator),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _heightController,
decoration: InputDecoration(labelText: l10n.exportEntryDialogHeight),
keyboardType: TextInputType.number,
onChanged: (value) {
final height = int.tryParse(value);
if (height != null) {
switch (_lengthUnit) {
case LengthUnit.px:
_widthController.text = '${(height * entries.first.displayAspectRatio).round()}';
case LengthUnit.percent:
_widthController.text = '$height';
}
} else {
_widthController.text = '';
}
_validate();
},
),
),
const SizedBox(width: 16),
TextDropdownButton<LengthUnit>(
values: _lengthUnitOptions,
valueText: (v) => v.getText(context),
value: _lengthUnit,
onChanged: _lengthUnitOptions.length > 1
? (v) {
if (v != null && _lengthUnit != v) {
_lengthUnit = v;
_initDimensions();
_validate();
setState(() {});
}
}
: null,
underline: Container(
height: 1.0,
decoration: BoxDecoration(
border: textFieldDecorationBorder,
),
),
itemHeight: 60,
dropdownColor: Themes.thirdLayerColor(context),
),
],
),
),
ValueListenableBuilder<String>(
valueListenable: _mimeTypeNotifier,
builder: (context, mimeType, child) {
Widget child;
if (qualityFormats.contains(mimeType)) {
child = SliderListTile(
value: _quality.toDouble(),
onChanged: (v) => setState(() => _quality = v.round()),
min: 0,
max: 100,
title: context.l10n.exportEntryDialogQuality,
titlePadding: contentHorizontalPadding,
titleTrailing: (context, value) => ChangeHighlightText(
'${value.round()}',
style: trailingStyle.copyWith(
shadows: [
Shadow(
color: trailingChangeShadowColor.withOpacity(0),
blurRadius: 0,
)
],
),
changedStyle: trailingStyle.copyWith(
shadows: [
Shadow(
color: trailingChangeShadowColor,
blurRadius: 3,
)
],
),
duration: context.read<DurationsData>().formTextStyleTransition,
),
);
} else {
child = const SizedBox();
}
return AnimatedSwitcher(
duration: context.read<DurationsData>().formTransition,
switchInCurve: Curves.easeInOutCubic,
switchOutCurve: Curves.easeInOutCubic,
transitionBuilder: AvesTransitions.formTransitionBuilder,
child: child,
);
},
),
ValueListenableBuilder<String>(
valueListenable: _mimeTypeNotifier,
builder: (context, mimeType, child) {
Widget child;
if (AppSupport.canEditExif(mimeType) || AppSupport.canEditIptc(mimeType) || AppSupport.canEditXmp(mimeType)) {
child = SwitchListTile(
value: _writeMetadata,
onChanged: (v) => setState(() => _writeMetadata = v),
title: Text(context.l10n.exportEntryDialogWriteMetadata),
contentPadding: const EdgeInsetsDirectional.only(
start: AvesDialog.defaultHorizontalContentPadding,
end: AvesDialog.defaultHorizontalContentPadding - 8,
),
);
} else {
child = const SizedBox(height: 16);
}
return AnimatedSwitcher(
duration: context.read<DurationsData>().formTransition,
switchInCurve: Curves.easeInOutCubic,
switchOutCurve: Curves.easeInOutCubic,
transitionBuilder: AvesTransitions.formTransitionBuilder,
child: child,
);
},
),
], ],
actions: [ actions: [
const CancelButton(), const CancelButton(),
@ -294,6 +153,7 @@ class _ConvertEntryDialogState extends State<ConvertEntryDialog> {
final height = int.tryParse(_heightController.text); final height = int.tryParse(_heightController.text);
final options = (width != null && height != null) final options = (width != null && height != null)
? EntryConvertOptions( ? EntryConvertOptions(
action: _action,
mimeType: _mimeTypeNotifier.value, mimeType: _mimeTypeNotifier.value,
writeMetadata: _writeMetadata, writeMetadata: _writeMetadata,
lengthUnit: _lengthUnit, lengthUnit: _lengthUnit,
@ -312,7 +172,7 @@ class _ConvertEntryDialogState extends State<ConvertEntryDialog> {
Navigator.maybeOf(context)?.pop(options); Navigator.maybeOf(context)?.pop(options);
} }
: null, : null,
child: Text(l10n.applyButtonLabel), child: Text(context.l10n.applyButtonLabel),
); );
}, },
), ),
@ -320,6 +180,193 @@ class _ConvertEntryDialogState extends State<ConvertEntryDialog> {
); );
} }
List<Widget> _buildConvertContent(BuildContext context) {
final l10n = context.l10n;
final colorScheme = Theme.of(context).colorScheme;
final trailingStyle = TextStyle(color: colorScheme.onSurfaceVariant);
final trailingChangeShadowColor = colorScheme.onSurface;
// used by the drop down to match input decoration
final textFieldDecorationBorder = Border(
bottom: BorderSide(
color: colorScheme.onSurface.withOpacity(0.38),
width: 1.0,
),
);
return [
Padding(
padding: contentHorizontalPadding,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.exportEntryDialogFormat),
const SizedBox(width: AvesDialog.controlCaptionPadding),
TextDropdownButton<String>(
values: _imageExportFormats,
valueText: MimeUtils.displayType,
value: _mimeTypeNotifier.value,
onChanged: (selected) {
if (selected != null) {
setState(() => _mimeTypeNotifier.value = selected);
}
},
),
],
),
),
Padding(
padding: contentHorizontalPadding,
child: Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Expanded(
child: TextField(
controller: _widthController,
decoration: InputDecoration(labelText: l10n.exportEntryDialogWidth),
keyboardType: TextInputType.number,
onChanged: (value) {
final width = int.tryParse(value);
if (width != null) {
switch (_lengthUnit) {
case LengthUnit.px:
_heightController.text = '${(width / entries.first.displayAspectRatio).round()}';
case LengthUnit.percent:
_heightController.text = '$width';
}
} else {
_heightController.text = '';
}
_validate();
},
),
),
const SizedBox(width: 8),
const Text(AText.resolutionSeparator),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _heightController,
decoration: InputDecoration(labelText: l10n.exportEntryDialogHeight),
keyboardType: TextInputType.number,
onChanged: (value) {
final height = int.tryParse(value);
if (height != null) {
switch (_lengthUnit) {
case LengthUnit.px:
_widthController.text = '${(height * entries.first.displayAspectRatio).round()}';
case LengthUnit.percent:
_widthController.text = '$height';
}
} else {
_widthController.text = '';
}
_validate();
},
),
),
const SizedBox(width: 16),
TextDropdownButton<LengthUnit>(
values: _lengthUnitOptions,
valueText: (v) => v.getText(context),
value: _lengthUnit,
onChanged: _lengthUnitOptions.length > 1
? (v) {
if (v != null && _lengthUnit != v) {
_lengthUnit = v;
_initDimensions();
_validate();
setState(() {});
}
}
: null,
underline: Container(
height: 1.0,
decoration: BoxDecoration(
border: textFieldDecorationBorder,
),
),
itemHeight: 60,
dropdownColor: Themes.thirdLayerColor(context),
),
],
),
),
ValueListenableBuilder<String>(
valueListenable: _mimeTypeNotifier,
builder: (context, mimeType, child) {
Widget child;
if (_qualityFormats.contains(mimeType)) {
child = SliderListTile(
value: _quality.toDouble(),
onChanged: (v) => setState(() => _quality = v.round()),
min: 0,
max: 100,
title: context.l10n.exportEntryDialogQuality,
titlePadding: contentHorizontalPadding,
titleTrailing: (context, value) => ChangeHighlightText(
'${value.round()}',
style: trailingStyle.copyWith(
shadows: [
Shadow(
color: trailingChangeShadowColor.withOpacity(0),
blurRadius: 0,
)
],
),
changedStyle: trailingStyle.copyWith(
shadows: [
Shadow(
color: trailingChangeShadowColor,
blurRadius: 3,
)
],
),
duration: context.read<DurationsData>().formTextStyleTransition,
),
);
} else {
child = const SizedBox();
}
return AnimatedSwitcher(
duration: context.read<DurationsData>().formTransition,
switchInCurve: Curves.easeInOutCubic,
switchOutCurve: Curves.easeInOutCubic,
transitionBuilder: AvesTransitions.formTransitionBuilder,
child: child,
);
},
),
ValueListenableBuilder<String>(
valueListenable: _mimeTypeNotifier,
builder: (context, mimeType, child) {
Widget child;
if (AppSupport.canEditExif(mimeType) || AppSupport.canEditIptc(mimeType) || AppSupport.canEditXmp(mimeType)) {
child = SwitchListTile(
value: _writeMetadata,
onChanged: (v) => setState(() => _writeMetadata = v),
title: Text(context.l10n.exportEntryDialogWriteMetadata),
contentPadding: const EdgeInsetsDirectional.only(
start: AvesDialog.defaultHorizontalContentPadding,
end: AvesDialog.defaultHorizontalContentPadding - 8,
),
);
} else {
child = const SizedBox(height: 16);
}
return AnimatedSwitcher(
duration: context.read<DurationsData>().formTransition,
switchInCurve: Curves.easeInOutCubic,
switchOutCurve: Curves.easeInOutCubic,
transitionBuilder: AvesTransitions.formTransitionBuilder,
child: child,
);
},
),
];
}
Future<void> _validate() async { Future<void> _validate() async {
final width = int.tryParse(_widthController.text); final width = int.tryParse(_widthController.text);
final height = int.tryParse(_heightController.text); final height = int.tryParse(_heightController.text);

View file

@ -15,6 +15,7 @@ import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/vaults/vaults.dart'; import 'package:aves/model/vaults/vaults.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/services/media/media_edit_service.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/collection/collection_page.dart';
import 'package:aves/widgets/common/action_mixins/entry_storage.dart'; import 'package:aves/widgets/common/action_mixins/entry_storage.dart';
@ -26,6 +27,7 @@ import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart'; import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart';
import 'package:aves/widgets/dialogs/aves_confirmation_dialog.dart'; import 'package:aves/widgets/dialogs/aves_confirmation_dialog.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/dialogs/convert_entry_dialog.dart';
import 'package:aves/widgets/dialogs/entry_editors/rename_entry_dialog.dart'; import 'package:aves/widgets/dialogs/entry_editors/rename_entry_dialog.dart';
import 'package:aves/widgets/viewer/action/entry_info_action_delegate.dart'; import 'package:aves/widgets/viewer/action/entry_info_action_delegate.dart';
import 'package:aves/widgets/viewer/action/printer.dart'; import 'package:aves/widgets/viewer/action/printer.dart';
@ -198,7 +200,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
case EntryAction.restore: case EntryAction.restore:
_move(context, targetEntry, moveType: MoveType.fromBin); _move(context, targetEntry, moveType: MoveType.fromBin);
case EntryAction.convert: case EntryAction.convert:
convert(context, {targetEntry}); _convert(context, targetEntry);
case EntryAction.print: case EntryAction.print:
EntryPrinter(targetEntry).print(context); EntryPrinter(targetEntry).print(context);
case EntryAction.rename: case EntryAction.rename:
@ -444,6 +446,22 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
entries: {targetEntry}, entries: {targetEntry},
); );
Future<void> _convert(BuildContext context, AvesEntry targetEntry) async {
final options = await showDialog<EntryConvertOptions>(
context: context,
builder: (context) => ConvertEntryDialog(entries: {targetEntry}),
routeSettings: const RouteSettings(name: ConvertEntryDialog.routeName),
);
if (options == null) return;
switch (options.action) {
case EntryConvertAction.convert:
await doExport(context, {targetEntry}, options);
case EntryConvertAction.convertMotionPhotoToStillImage:
await _metadataActionDelegate.onActionSelected(context, targetEntry, collection, EntryAction.convertMotionPhotoToStillImage);
}
}
Future<void> _rename(BuildContext context, AvesEntry targetEntry) async { Future<void> _rename(BuildContext context, AvesEntry targetEntry) async {
final newName = await showDialog<String>( final newName = await showDialog<String>(
context: context, context: context,

View file

@ -92,7 +92,7 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
} }
} }
void onActionSelected(BuildContext context, AvesEntry targetEntry, CollectionLens? collection, EntryAction action) async { Future<void> onActionSelected(BuildContext context, AvesEntry targetEntry, CollectionLens? collection, EntryAction action) async {
await reportService.log('$action'); await reportService.log('$action');
_eventStreamController.add(ActionStartedEvent(action)); _eventStreamController.add(ActionStartedEvent(action));
switch (action) { switch (action) {

View file

@ -94,7 +94,7 @@ class InfoAppBar extends StatelessWidget {
onSelected: (action) async { onSelected: (action) async {
// wait for the popup menu to hide before proceeding with the action // wait for the popup menu to hide before proceeding with the action
await Future.delayed(animations.popUpAnimationDelay * timeDilation); await Future.delayed(animations.popUpAnimationDelay * timeDilation);
actionDelegate.onActionSelected(context, entry, collection, action); await actionDelegate.onActionSelected(context, entry, collection, action);
}, },
popUpAnimationStyle: animations.popUpAnimationStyle, popUpAnimationStyle: animations.popUpAnimationStyle,
), ),

View file

@ -15,6 +15,8 @@ enum DateFieldSource {
exifGpsDate, exifGpsDate,
} }
enum EntryConvertAction { convert, convertMotionPhotoToStillImage }
enum LengthUnit { px, percent } enum LengthUnit { px, percent }
enum LocationEditAction { enum LocationEditAction {