diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ab0576f6..4656ee724 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: ask to rename/replace/skip when converting items with name conflict +- Export: bulk converting motion photos to still images ## [v1.11.3] - 2024-06-17 diff --git a/lib/model/source/collection_lens.dart b/lib/model/source/collection_lens.dart index 5e2e7222b..e6329dbd7 100644 --- a/lib/model/source/collection_lens.dart +++ b/lib/model/source/collection_lens.dart @@ -3,6 +3,7 @@ import 'dart:collection'; import 'package:aves/model/entry/entry.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/favourites.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/rating.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/source/collection_source.dart'; import 'package:aves/model/source/events.dart'; @@ -224,7 +224,7 @@ class CollectionLens with ChangeNotifier { } void _stackDevelopedRaws() { - final allRawEntries = _filteredSortedEntries.where(TypeFilter.raw.test).toSet(); + final allRawEntries = _filteredSortedEntries.where((entry) => entry.isRaw).toSet(); if (allRawEntries.isNotEmpty) { final allDevelopedEntries = _filteredSortedEntries.where(MimeFilter(MimeTypes.jpeg).test).toSet(); final rawEntriesByDir = groupBy(allRawEntries, (entry) => entry.directory); diff --git a/lib/services/media/media_edit_service.dart b/lib/services/media/media_edit_service.dart index 927c79e72..fd3810e97 100644 --- a/lib/services/media/media_edit_service.dart +++ b/lib/services/media/media_edit_service.dart @@ -193,15 +193,17 @@ class PlatformMediaEditService implements MediaEditService { @immutable class EntryConvertOptions extends Equatable { + final EntryConvertAction action; final String mimeType; final bool writeMetadata; final LengthUnit lengthUnit; final int width, height, quality; @override - List get props => [mimeType, writeMetadata, lengthUnit, width, height, quality]; + List get props => [action, mimeType, writeMetadata, lengthUnit, width, height, quality]; const EntryConvertOptions({ + required this.action, required this.mimeType, required this.writeMetadata, required this.lengthUnit, diff --git a/lib/view/src/actions/entry_set.dart b/lib/view/src/actions/entry_set.dart index 06313a94e..28dbfd77a 100644 --- a/lib/view/src/actions/entry_set.dart +++ b/lib/view/src/actions/entry_set.dart @@ -5,45 +5,46 @@ import 'package:flutter/material.dart'; extension ExtraEntrySetActionView on EntrySetAction { String getText(BuildContext context) { + final l10n = context.l10n; return switch (this) { // general - EntrySetAction.configureView => context.l10n.menuActionConfigureView, - EntrySetAction.select => context.l10n.menuActionSelect, - EntrySetAction.selectAll => context.l10n.menuActionSelectAll, - EntrySetAction.selectNone => context.l10n.menuActionSelectNone, + EntrySetAction.configureView => l10n.menuActionConfigureView, + EntrySetAction.select => l10n.menuActionSelect, + EntrySetAction.selectAll => l10n.menuActionSelectAll, + EntrySetAction.selectNone => l10n.menuActionSelectNone, // browsing EntrySetAction.searchCollection => MaterialLocalizations.of(context).searchFieldLabel, EntrySetAction.toggleTitleSearch => // different data depending on toggle state - context.l10n.collectionActionShowTitleSearch, - EntrySetAction.addShortcut => context.l10n.collectionActionAddShortcut, - EntrySetAction.setHome => context.l10n.collectionActionSetHome, - EntrySetAction.emptyBin => context.l10n.collectionActionEmptyBin, + l10n.collectionActionShowTitleSearch, + EntrySetAction.addShortcut => l10n.collectionActionAddShortcut, + EntrySetAction.setHome => l10n.collectionActionSetHome, + EntrySetAction.emptyBin => l10n.collectionActionEmptyBin, // browsing or selecting - EntrySetAction.map => context.l10n.menuActionMap, - EntrySetAction.slideshow => context.l10n.menuActionSlideshow, - EntrySetAction.stats => context.l10n.menuActionStats, - EntrySetAction.rescan => context.l10n.collectionActionRescan, + EntrySetAction.map => l10n.menuActionMap, + EntrySetAction.slideshow => l10n.menuActionSlideshow, + EntrySetAction.stats => l10n.menuActionStats, + EntrySetAction.rescan => l10n.collectionActionRescan, // selecting - EntrySetAction.share => context.l10n.entryActionShare, - EntrySetAction.delete => context.l10n.entryActionDelete, - EntrySetAction.restore => context.l10n.entryActionRestore, - EntrySetAction.copy => context.l10n.collectionActionCopy, - EntrySetAction.move => context.l10n.collectionActionMove, - EntrySetAction.rename => context.l10n.entryActionRename, - EntrySetAction.convert => context.l10n.entryActionConvert, + EntrySetAction.share => l10n.entryActionShare, + EntrySetAction.delete => l10n.entryActionDelete, + EntrySetAction.restore => l10n.entryActionRestore, + EntrySetAction.copy => l10n.collectionActionCopy, + EntrySetAction.move => l10n.collectionActionMove, + EntrySetAction.rename => l10n.entryActionRename, + EntrySetAction.convert => l10n.entryActionConvert, EntrySetAction.toggleFavourite => // different data depending on toggle state - context.l10n.entryActionAddFavourite, - EntrySetAction.rotateCCW => context.l10n.entryActionRotateCCW, - EntrySetAction.rotateCW => context.l10n.entryActionRotateCW, - EntrySetAction.flip => context.l10n.entryActionFlip, - EntrySetAction.editDate => context.l10n.entryInfoActionEditDate, - EntrySetAction.editLocation => context.l10n.entryInfoActionEditLocation, - EntrySetAction.editTitleDescription => context.l10n.entryInfoActionEditTitleDescription, - EntrySetAction.editRating => context.l10n.entryInfoActionEditRating, - EntrySetAction.editTags => context.l10n.entryInfoActionEditTags, - EntrySetAction.removeMetadata => context.l10n.entryInfoActionRemoveMetadata, + l10n.entryActionAddFavourite, + EntrySetAction.rotateCCW => l10n.entryActionRotateCCW, + EntrySetAction.rotateCW => l10n.entryActionRotateCW, + EntrySetAction.flip => l10n.entryActionFlip, + EntrySetAction.editDate => l10n.entryInfoActionEditDate, + EntrySetAction.editLocation => l10n.entryInfoActionEditLocation, + EntrySetAction.editTitleDescription => l10n.entryInfoActionEditTitleDescription, + EntrySetAction.editRating => l10n.entryInfoActionEditRating, + EntrySetAction.editTags => l10n.entryInfoActionEditTags, + EntrySetAction.removeMetadata => l10n.entryInfoActionRemoveMetadata, }; } diff --git a/lib/view/src/metadata/convert_action.dart b/lib/view/src/metadata/convert_action.dart new file mode 100644 index 000000000..54f9f9872 --- /dev/null +++ b/lib/view/src/metadata/convert_action.dart @@ -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, + }; + } +} diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index fe8e6e022..db0504ad1 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -5,6 +5,7 @@ import 'package:aves/model/device.dart'; import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/entry/extensions/favourites.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/favourites.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/common/image_op_events.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/themes.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/aves_confirmation_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/pick_dialogs/location_pick_page.dart'; import 'package:aves/widgets/map/map_page.dart'; @@ -366,9 +369,23 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware _browse(context); } - void _convert(BuildContext context) { + Future _convert(BuildContext context) async { final entries = _getTargetItems(context); - convert(context, entries); + + final options = await showDialog( + 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); } diff --git a/lib/widgets/common/action_mixins/entry_storage.dart b/lib/widgets/common/action_mixins/entry_storage.dart index 4c0b6f7d2..958b273ff 100644 --- a/lib/widgets/common/action_mixins/entry_storage.dart +++ b/lib/widgets/common/action_mixins/entry_storage.dart @@ -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/extensions/build_context.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/selection_dialogs/single_selection.dart'; import 'package:aves/widgets/viewer/controls/notifications.dart'; @@ -38,14 +37,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { - Future convert(BuildContext context, Set targetEntries) async { - final options = await showDialog( - context: context, - builder: (context) => ConvertEntryDialog(entries: targetEntries), - routeSettings: const RouteSettings(name: ConvertEntryDialog.routeName), - ); - if (options == null) return; - + Future doExport(BuildContext context, Set targetEntries, EntryConvertOptions options) async { final destinationAlbum = await pickAlbum(context: context, moveType: MoveType.export); if (destinationAlbum == null) return; if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return; diff --git a/lib/widgets/dialogs/convert_entry_dialog.dart b/lib/widgets/dialogs/convert_entry_dialog.dart index 5576aace9..947fa113e 100644 --- a/lib/widgets/dialogs/convert_entry_dialog.dart +++ b/lib/widgets/dialogs/convert_entry_dialog.dart @@ -1,5 +1,6 @@ import 'package:aves/model/app/support.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/ref/mime_types.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/themes.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/widgets/common/basic/list_tiles/slider.dart'; import 'package:aves/widgets/common/basic/text/change_highlight.dart'; @@ -34,6 +36,8 @@ class ConvertEntryDialog extends StatefulWidget { } class _ConvertEntryDialogState extends State { + late List _actionOptions; + EntryConvertAction _action = EntryConvertAction.convert; final TextEditingController _widthController = TextEditingController(), _heightController = TextEditingController(); final ValueNotifier _isValidNotifier = ValueNotifier(false); late ValueNotifier _mimeTypeNotifier; @@ -44,14 +48,16 @@ class _ConvertEntryDialogState extends State { Set get entries => widget.entries; - static const imageExportFormats = [ + EdgeInsets get contentHorizontalPadding => const EdgeInsets.symmetric(horizontal: AvesDialog.defaultHorizontalContentPadding); + + static const _imageExportFormats = [ MimeTypes.bmp, MimeTypes.jpeg, MimeTypes.png, MimeTypes.webp, ]; - static const qualityFormats = [ + static const _qualityFormats = [ MimeTypes.jpeg, MimeTypes.webp, ]; @@ -59,6 +65,10 @@ class _ConvertEntryDialogState extends State { @override void initState() { super.initState(); + _actionOptions = [ + EntryConvertAction.convert, + if (entries.any((entry) => entry.isMotionPhoto)) EntryConvertAction.convertMotionPhotoToStillImage, + ]; _mimeTypeNotifier = ValueNotifier(settings.convertMimeType); _quality = settings.convertQuality; _writeMetadata = settings.convertWriteMetadata; @@ -95,192 +105,41 @@ class _ConvertEntryDialogState extends State { @override 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( scrollableContent: [ const SizedBox(height: 16), - Padding( - padding: contentHorizontalPadding, - child: Row( + if (_actionOptions.length > 1) + Padding( + padding: contentHorizontalPadding, + child: TextDropdownButton( + 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().formTransition, + switchInCurve: Curves.easeInOutCubic, + switchOutCurve: Curves.easeInOutCubic, + transitionBuilder: AvesTransitions.formTransitionBuilder, + child: Column( + key: ValueKey(_action), mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text(l10n.exportEntryDialogFormat), - const SizedBox(width: AvesDialog.controlCaptionPadding), - TextDropdownButton( - values: imageExportFormats, - valueText: MimeUtils.displayType, - value: _mimeTypeNotifier.value, - onChanged: (selected) { - if (selected != null) { - setState(() => _mimeTypeNotifier.value = selected); - } - }, - ), + if (_action == EntryConvertAction.convert) ..._buildConvertContent(context), + if (_action == EntryConvertAction.convertMotionPhotoToStillImage) const SizedBox(height: 16), ], ), ), - 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( - 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( - 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().formTextStyleTransition, - ), - ); - } else { - child = const SizedBox(); - } - return AnimatedSwitcher( - duration: context.read().formTransition, - switchInCurve: Curves.easeInOutCubic, - switchOutCurve: Curves.easeInOutCubic, - transitionBuilder: AvesTransitions.formTransitionBuilder, - child: child, - ); - }, - ), - ValueListenableBuilder( - 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().formTransition, - switchInCurve: Curves.easeInOutCubic, - switchOutCurve: Curves.easeInOutCubic, - transitionBuilder: AvesTransitions.formTransitionBuilder, - child: child, - ); - }, - ), ], actions: [ const CancelButton(), @@ -294,6 +153,7 @@ class _ConvertEntryDialogState extends State { final height = int.tryParse(_heightController.text); final options = (width != null && height != null) ? EntryConvertOptions( + action: _action, mimeType: _mimeTypeNotifier.value, writeMetadata: _writeMetadata, lengthUnit: _lengthUnit, @@ -312,7 +172,7 @@ class _ConvertEntryDialogState extends State { Navigator.maybeOf(context)?.pop(options); } : null, - child: Text(l10n.applyButtonLabel), + child: Text(context.l10n.applyButtonLabel), ); }, ), @@ -320,6 +180,193 @@ class _ConvertEntryDialogState extends State { ); } + List _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( + 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( + 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( + 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().formTextStyleTransition, + ), + ); + } else { + child = const SizedBox(); + } + return AnimatedSwitcher( + duration: context.read().formTransition, + switchInCurve: Curves.easeInOutCubic, + switchOutCurve: Curves.easeInOutCubic, + transitionBuilder: AvesTransitions.formTransitionBuilder, + child: child, + ); + }, + ), + ValueListenableBuilder( + 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().formTransition, + switchInCurve: Curves.easeInOutCubic, + switchOutCurve: Curves.easeInOutCubic, + transitionBuilder: AvesTransitions.formTransitionBuilder, + child: child, + ); + }, + ), + ]; + } + Future _validate() async { final width = int.tryParse(_widthController.text); final height = int.tryParse(_heightController.text); diff --git a/lib/widgets/viewer/action/entry_action_delegate.dart b/lib/widgets/viewer/action/entry_action_delegate.dart index ad9553df3..5b6da5965 100644 --- a/lib/widgets/viewer/action/entry_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_action_delegate.dart @@ -15,6 +15,7 @@ import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/vaults/vaults.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/widgets/collection/collection_page.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/aves_confirmation_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/viewer/action/entry_info_action_delegate.dart'; import 'package:aves/widgets/viewer/action/printer.dart'; @@ -198,7 +200,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix case EntryAction.restore: _move(context, targetEntry, moveType: MoveType.fromBin); case EntryAction.convert: - convert(context, {targetEntry}); + _convert(context, targetEntry); case EntryAction.print: EntryPrinter(targetEntry).print(context); case EntryAction.rename: @@ -444,6 +446,22 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix entries: {targetEntry}, ); + Future _convert(BuildContext context, AvesEntry targetEntry) async { + final options = await showDialog( + 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 _rename(BuildContext context, AvesEntry targetEntry) async { final newName = await showDialog( context: context, diff --git a/lib/widgets/viewer/action/entry_info_action_delegate.dart b/lib/widgets/viewer/action/entry_info_action_delegate.dart index 5517d70cf..b63fd1dd2 100644 --- a/lib/widgets/viewer/action/entry_info_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_info_action_delegate.dart @@ -92,7 +92,7 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi } } - void onActionSelected(BuildContext context, AvesEntry targetEntry, CollectionLens? collection, EntryAction action) async { + Future onActionSelected(BuildContext context, AvesEntry targetEntry, CollectionLens? collection, EntryAction action) async { await reportService.log('$action'); _eventStreamController.add(ActionStartedEvent(action)); switch (action) { diff --git a/lib/widgets/viewer/info/info_app_bar.dart b/lib/widgets/viewer/info/info_app_bar.dart index a6e8195f0..866b8f4cf 100644 --- a/lib/widgets/viewer/info/info_app_bar.dart +++ b/lib/widgets/viewer/info/info_app_bar.dart @@ -94,7 +94,7 @@ class InfoAppBar extends StatelessWidget { onSelected: (action) async { // wait for the popup menu to hide before proceeding with the action await Future.delayed(animations.popUpAnimationDelay * timeDilation); - actionDelegate.onActionSelected(context, entry, collection, action); + await actionDelegate.onActionSelected(context, entry, collection, action); }, popUpAnimationStyle: animations.popUpAnimationStyle, ), diff --git a/plugins/aves_model/lib/src/metadata/enums.dart b/plugins/aves_model/lib/src/metadata/enums.dart index 70849fd55..061cf02b1 100644 --- a/plugins/aves_model/lib/src/metadata/enums.dart +++ b/plugins/aves_model/lib/src/metadata/enums.dart @@ -15,6 +15,8 @@ enum DateFieldSource { exifGpsDate, } +enum EntryConvertAction { convert, convertMotionPhotoToStillImage } + enum LengthUnit { px, percent } enum LocationEditAction {