diff --git a/CHANGELOG.md b/CHANGELOG.md index 8141fef8a..6b1153f46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. ### Added +- Collection: bulk converting - Places: page & navigation entry ### Fixed diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt index dd775eea7..6c03cec86 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt @@ -50,7 +50,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments when (op) { "delete" -> ioScope.launch { delete() } - "export" -> ioScope.launch { export() } + "convert" -> ioScope.launch { convert() } "move" -> ioScope.launch { move() } "rename" -> ioScope.launch { rename() } else -> endOfStream() @@ -121,7 +121,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments endOfStream() } - private suspend fun export() { + private suspend fun convert() { if (arguments !is Map<*, *> || entryMapList.isEmpty()) { endOfStream() return @@ -129,11 +129,12 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments var destinationDir = arguments["destinationPath"] as String? val mimeType = arguments["mimeType"] as String? + val lengthUnit = arguments["lengthUnit"] as String? val width = (arguments["width"] as Number?)?.toInt() val height = (arguments["height"] as Number?)?.toInt() val nameConflictStrategy = NameConflictStrategy.get(arguments["nameConflictStrategy"] as String?) - if (destinationDir == null || mimeType == null || width == null || height == null || nameConflictStrategy == null) { - error("export-args", "missing arguments", null) + if (destinationDir == null || mimeType == null || lengthUnit == null || width == null || height == null || nameConflictStrategy == null) { + error("convert-args", "missing arguments", null) return } @@ -141,15 +142,15 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments val firstEntry = entryMapList.first() val provider = (firstEntry["uri"] as String?)?.let { Uri.parse(it) }?.let { getProvider(it) } if (provider == null) { - error("export-provider", "failed to find provider for entry=$firstEntry", null) + error("convert-provider", "failed to find provider for entry=$firstEntry", null) return } destinationDir = ensureTrailingSeparator(destinationDir) val entries = entryMapList.map(::AvesEntry) - provider.exportMultiple(activity, mimeType, destinationDir, entries, width, height, nameConflictStrategy, object : ImageOpCallback { + provider.convertMultiple(activity, mimeType, destinationDir, entries, lengthUnit, width, height, nameConflictStrategy, object : ImageOpCallback { override fun onSuccess(fields: FieldMap) = success(fields) - override fun onFailure(throwable: Throwable) = error("export-failure", "failed to export entries", throwable) + override fun onFailure(throwable: Throwable) = error("convert-failure", "failed to convert entries", throwable) }) endOfStream() } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/AvesEntry.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/AvesEntry.kt index abb2afed5..8d41846ab 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/AvesEntry.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/AvesEntry.kt @@ -15,6 +15,15 @@ class AvesEntry(map: FieldMap) { val trashed = map["trashed"] as Boolean val trashPath = map["trashPath"] as String? + private val isRotated: Boolean + get() = rotationDegrees % 180 == 90 + + val displayWidth: Int + get() = if (isRotated) height else width + + val displayHeight: Int + get() = if (isRotated) width else height + companion object { // convenience method private fun toLong(o: Any?): Long? = when (o) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt index 9a5243318..7086f8d17 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt @@ -169,11 +169,12 @@ abstract class ImageProvider { throw UnsupportedOperationException("`scanPostMetadataEdit` is not supported by this image provider") } - suspend fun exportMultiple( + suspend fun convertMultiple( activity: Activity, imageExportMimeType: String, targetDir: String, entries: List, + lengthUnit: String, width: Int, height: Int, nameConflictStrategy: NameConflictStrategy, @@ -208,6 +209,7 @@ abstract class ImageProvider { sourceEntry = entry, targetDir = targetDir, targetDirDocFile = targetDirDocFile, + lengthUnit = lengthUnit, width = width, height = height, nameConflictStrategy = nameConflictStrategy, @@ -227,6 +229,7 @@ abstract class ImageProvider { sourceEntry: AvesEntry, targetDir: String, targetDirDocFile: DocumentFileCompat?, + lengthUnit: String, width: Int, height: Int, nameConflictStrategy: NameConflictStrategy, @@ -266,6 +269,19 @@ abstract class ImageProvider { sourceDocFile.copyTo(output) } } else { + val targetWidthPx: Int + val targetHeightPx: Int + when (lengthUnit) { + LENGTH_UNIT_PERCENT -> { + targetWidthPx = sourceEntry.displayWidth * width / 100 + targetHeightPx = sourceEntry.displayHeight * height / 100 + } + else -> { + targetWidthPx = width + targetHeightPx = height + } + } + val model: Any = if (MimeTypes.isHeic(sourceMimeType) && pageId != null) { MultiTrackImage(activity, sourceUri, pageId) } else if (sourceMimeType == MimeTypes.TIFF) { @@ -286,7 +302,7 @@ abstract class ImageProvider { .asBitmap() .apply(glideOptions) .load(model) - .submit(width, height) + .submit(targetWidthPx, targetHeightPx) @Suppress("BlockingMethodInNonBlockingContext") var bitmap = target.get() if (MimeTypes.needRotationAfterGlide(sourceMimeType)) { @@ -1209,6 +1225,8 @@ abstract class ImageProvider { companion object { private val LOG_TAG = LogUtils.createTag() + private const val LENGTH_UNIT_PERCENT = "percent" + val supportedExportMimeTypes = listOf(MimeTypes.BMP, MimeTypes.JPEG, MimeTypes.PNG, MimeTypes.WEBP) // used when skipping a move/creation op because the target file already exists diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index d13036067..a3765193b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -200,6 +200,9 @@ "keepScreenOnViewerOnly": "Viewer page only", "keepScreenOnAlways": "Always", + "lengthUnitPixel": "px", + "lengthUnitPercent": "%", + "mapStyleGoogleNormal": "Google Maps", "mapStyleGoogleHybrid": "Google Maps (Hybrid)", "mapStyleGoogleTerrain": "Google Maps (Terrain)", diff --git a/lib/model/actions/entry_set_actions.dart b/lib/model/actions/entry_set_actions.dart index 92af82d5c..422fd9840 100644 --- a/lib/model/actions/entry_set_actions.dart +++ b/lib/model/actions/entry_set_actions.dart @@ -25,6 +25,7 @@ enum EntrySetAction { copy, move, rename, + convert, toggleFavourite, rotateCCW, rotateCW, @@ -45,13 +46,16 @@ class EntrySetActions { EntrySetAction.selectNone, ]; + // `null` items are converted to dividers static const pageBrowsing = [ EntrySetAction.searchCollection, EntrySetAction.toggleTitleSearch, EntrySetAction.addShortcut, + null, EntrySetAction.map, EntrySetAction.slideshow, EntrySetAction.stats, + null, EntrySetAction.rescan, EntrySetAction.emptyBin, ]; @@ -67,6 +71,7 @@ class EntrySetActions { EntrySetAction.rescan, ]; + // `null` items are converted to dividers static const pageSelection = [ EntrySetAction.share, EntrySetAction.delete, @@ -74,10 +79,13 @@ class EntrySetActions { EntrySetAction.copy, EntrySetAction.move, EntrySetAction.rename, + EntrySetAction.convert, EntrySetAction.toggleFavourite, + null, EntrySetAction.map, EntrySetAction.slideshow, EntrySetAction.stats, + null, EntrySetAction.rescan, // editing actions are in their subsection ]; @@ -89,6 +97,7 @@ class EntrySetActions { EntrySetAction.copy, EntrySetAction.move, EntrySetAction.rename, + EntrySetAction.convert, EntrySetAction.toggleFavourite, EntrySetAction.map, EntrySetAction.slideshow, @@ -163,6 +172,8 @@ extension ExtraEntrySetAction on EntrySetAction { return context.l10n.collectionActionMove; case EntrySetAction.rename: return context.l10n.entryActionRename; + case EntrySetAction.convert: + return context.l10n.entryActionConvert; case EntrySetAction.toggleFavourite: // different data depending on toggle state return context.l10n.entryActionAddFavourite; @@ -232,6 +243,8 @@ extension ExtraEntrySetAction on EntrySetAction { return AIcons.move; case EntrySetAction.rename: return AIcons.name; + case EntrySetAction.convert: + return AIcons.convert; case EntrySetAction.toggleFavourite: // different data depending on toggle state return AIcons.favourite; diff --git a/lib/model/metadata/enums/enums.dart b/lib/model/metadata/enums/enums.dart index 7d8a54ec6..ad1641fae 100644 --- a/lib/model/metadata/enums/enums.dart +++ b/lib/model/metadata/enums/enums.dart @@ -15,6 +15,8 @@ enum DateFieldSource { exifGpsDate, } +enum LengthUnit { px, percent } + enum LocationEditAction { chooseOnMap, copyItem, diff --git a/lib/model/metadata/enums/length_unit.dart b/lib/model/metadata/enums/length_unit.dart new file mode 100644 index 000000000..0da3eb3f0 --- /dev/null +++ b/lib/model/metadata/enums/length_unit.dart @@ -0,0 +1,14 @@ +import 'package:aves/model/metadata/enums/enums.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:flutter/widgets.dart'; + +extension ExtraLengthUnit on LengthUnit { + String getText(BuildContext context) { + switch (this) { + case LengthUnit.px: + return context.l10n.lengthUnitPixel; + case LengthUnit.percent: + return context.l10n.lengthUnitPercent; + } + } +} diff --git a/lib/services/media/media_edit_service.dart b/lib/services/media/media_edit_service.dart index a775c827d..bc1fdd331 100644 --- a/lib/services/media/media_edit_service.dart +++ b/lib/services/media/media_edit_service.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:aves/model/entry.dart'; +import 'package:aves/model/metadata/enums/enums.dart'; import 'package:aves/services/common/image_op_events.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/services/media/enums.dart'; @@ -28,7 +29,7 @@ abstract class MediaEditService { Stream export( Iterable entries, { - required EntryExportOptions options, + required EntryConvertOptions options, required String destinationAlbum, required NameConflictStrategy nameConflictStrategy, }); @@ -113,16 +114,17 @@ class PlatformMediaEditService implements MediaEditService { @override Stream export( Iterable entries, { - required EntryExportOptions options, + required EntryConvertOptions options, required String destinationAlbum, required NameConflictStrategy nameConflictStrategy, }) { try { return _opStream .receiveBroadcastStream({ - 'op': 'export', + 'op': 'convert', 'entries': entries.map((entry) => entry.toPlatformEntryMap()).toList(), 'mimeType': options.mimeType, + 'lengthUnit': options.lengthUnit.name, 'width': options.width, 'height': options.height, 'destinationPath': destinationAlbum, @@ -183,15 +185,17 @@ class PlatformMediaEditService implements MediaEditService { } @immutable -class EntryExportOptions extends Equatable { +class EntryConvertOptions extends Equatable { final String mimeType; + final LengthUnit lengthUnit; final int width, height; @override - List get props => [mimeType, width, height]; + List get props => [mimeType, lengthUnit, width, height]; - const EntryExportOptions({ + const EntryConvertOptions({ required this.mimeType, + required this.lengthUnit, required this.width, required this.height, }); diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index 6991d4ed9..684a5629b 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -32,6 +32,7 @@ import 'package:aves/widgets/common/tile_extent_controller.dart'; import 'package:aves/widgets/dialogs/tile_view_dialog.dart'; import 'package:aves/widgets/filter_grids/common/action_delegates/chip.dart'; import 'package:aves/widgets/search/search_delegate.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:provider/provider.dart'; @@ -342,7 +343,7 @@ class _CollectionAppBarState extends State with SingleTickerPr return [ ...EntrySetActions.general, ...isSelecting ? EntrySetActions.pageSelection : EntrySetActions.pageBrowsing, - ].where(isVisible).map((action) { + ].whereNotNull().where(isVisible).map((action) { final enabled = canApply(action); return CaptionedButton( iconButtonBuilder: (context, focusNode) => _buildButtonIcon( @@ -388,10 +389,21 @@ class _CollectionAppBarState extends State with SingleTickerPr final browsingMenuActions = EntrySetActions.pageBrowsing.where((v) => !browsingQuickActions.contains(v)); final selectionMenuActions = EntrySetActions.pageSelection.where((v) => !selectionQuickActions.contains(v)); - final contextualMenuItems = [ - ...(isSelecting ? selectionMenuActions : browsingMenuActions).where(isVisible).map( - (action) => _toMenuItem(action, enabled: canApply(action), selection: selection), - ), + final contextualMenuActions = (isSelecting ? selectionMenuActions : browsingMenuActions).where((v) => v == null || isVisible(v)).fold([], (prev, v) { + if (v == null && (prev.isEmpty || prev.last == null)) return prev; + return [...prev, v]; + }); + if (contextualMenuActions.isNotEmpty && contextualMenuActions.last == null) { + contextualMenuActions.removeLast(); + } + + final contextualMenuItems = >[ + ...contextualMenuActions.map( + (action) { + if (action == null) return const PopupMenuDivider(); + return _toMenuItem(action, enabled: canApply(action), selection: selection); + }, + ), if (isSelecting && !settings.isReadOnly && appMode == AppMode.main && !isTrash) PopupMenuItem( enabled: hasSelection, @@ -630,6 +642,7 @@ class _CollectionAppBarState extends State with SingleTickerPr case EntrySetAction.copy: case EntrySetAction.move: case EntrySetAction.rename: + case EntrySetAction.convert: case EntrySetAction.toggleFavourite: case EntrySetAction.rotateCCW: case EntrySetAction.rotateCW: diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index 1fe1ad605..f6f0a6545 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -94,6 +94,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware case EntrySetAction.copy: case EntrySetAction.move: case EntrySetAction.rename: + case EntrySetAction.convert: case EntrySetAction.rotateCCW: case EntrySetAction.rotateCW: case EntrySetAction.flip: @@ -145,6 +146,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware case EntrySetAction.copy: case EntrySetAction.move: case EntrySetAction.rename: + case EntrySetAction.convert: case EntrySetAction.toggleFavourite: case EntrySetAction.rotateCCW: case EntrySetAction.rotateCW: @@ -211,6 +213,9 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware case EntrySetAction.rename: _rename(context); break; + case EntrySetAction.convert: + _convert(context); + break; case EntrySetAction.toggleFavourite: _toggleFavourite(context); break; @@ -379,6 +384,13 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware _browse(context); } + void _convert(BuildContext context) { + final entries = _getTargetItems(context); + convert(context, entries); + + _browse(context); + } + Future _toggleFavourite(BuildContext context) async { final entries = _getTargetItems(context); if (entries.every((entry) => entry.isFavourite)) { diff --git a/lib/widgets/common/action_mixins/entry_storage.dart b/lib/widgets/common/action_mixins/entry_storage.dart index 1d31c5add..ef1bcdea9 100644 --- a/lib/widgets/common/action_mixins/entry_storage.dart +++ b/lib/widgets/common/action_mixins/entry_storage.dart @@ -16,6 +16,7 @@ import 'package:aves/model/source/collection_source.dart'; import 'package:aves/services/common/image_op_events.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/services/media/enums.dart'; +import 'package:aves/services/media/media_edit_service.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/aves_app.dart'; @@ -27,6 +28,7 @@ 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/aves_selection_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/viewer/notifications.dart'; import 'package:collection/collection.dart'; @@ -34,6 +36,100 @@ 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; + + final destinationAlbum = await pickAlbum(context: context, moveType: MoveType.export); + if (destinationAlbum == null) return; + if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return; + + if (!await checkFreeSpaceForMove(context, targetEntries, destinationAlbum, MoveType.export)) return; + + final selection = {}; + await Future.forEach(targetEntries, (targetEntry) async { + if (targetEntry.isMultiPage) { + final multiPageInfo = await targetEntry.getMultiPageInfo(); + if (multiPageInfo != null) { + if (targetEntry.isMotionPhoto) { + await multiPageInfo.extractMotionPhotoVideo(); + } + if (multiPageInfo.pageCount > 1) { + selection.addAll(multiPageInfo.exportEntries); + } + } + } else { + selection.add(targetEntry); + } + }); + + final selectionCount = selection.length; + final source = context.read(); + source.pauseMonitoring(); + await showOpReport( + context: context, + opStream: mediaEditService.export( + selection, + options: options, + destinationAlbum: destinationAlbum, + nameConflictStrategy: NameConflictStrategy.rename, + ), + itemCount: selectionCount, + onDone: (processed) async { + final successOps = processed.where((e) => e.success).toSet(); + final exportedOps = successOps.where((e) => !e.skipped).toSet(); + final newUris = exportedOps.map((v) => v.newFields['uri'] as String?).whereNotNull().toSet(); + final isMainMode = context.read>().value == AppMode.main; + + source.resumeMonitoring(); + unawaited(source.refreshUris(newUris)); + + final l10n = context.l10n; + final showAction = isMainMode && newUris.isNotEmpty + ? SnackBarAction( + label: l10n.showButtonLabel, + onPressed: () { + // local context may be deactivated when action is triggered after navigation + final context = AvesApp.navigatorKey.currentContext; + if (context != null) { + Navigator.maybeOf(context)?.pushAndRemoveUntil( + MaterialPageRoute( + settings: const RouteSettings(name: CollectionPage.routeName), + builder: (context) => CollectionPage( + source: source, + filters: {AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum))}, + highlightTest: (entry) => newUris.contains(entry.uri), + ), + ), + (route) => false, + ); + } + }, + ) + : null; + final successCount = successOps.length; + if (successCount < selectionCount) { + final count = selectionCount - successCount; + showFeedback( + context, + l10n.collectionExportFailureFeedback(count), + showAction, + ); + } else { + showFeedback( + context, + l10n.genericSuccessFeedback, + showAction, + ); + } + }, + ); + } + Future doQuickMove( BuildContext context, { required MoveType moveType, diff --git a/lib/widgets/dialogs/export_entry_dialog.dart b/lib/widgets/dialogs/convert_entry_dialog.dart similarity index 52% rename from lib/widgets/dialogs/export_entry_dialog.dart rename to lib/widgets/dialogs/convert_entry_dialog.dart index 6cce70cde..12ca0f6f6 100644 --- a/lib/widgets/dialogs/export_entry_dialog.dart +++ b/lib/widgets/dialogs/convert_entry_dialog.dart @@ -1,6 +1,9 @@ import 'package:aves/model/entry.dart'; +import 'package:aves/model/metadata/enums/enums.dart'; +import 'package:aves/model/metadata/enums/length_unit.dart'; import 'package:aves/ref/mime_types.dart'; import 'package:aves/services/media/media_edit_service.dart'; +import 'package:aves/theme/themes.dart'; import 'package:aves/utils/mime_utils.dart'; import 'package:aves/widgets/common/basic/text_dropdown_button.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; @@ -8,26 +11,29 @@ import 'package:flutter/material.dart'; import 'aves_dialog.dart'; -class ExportEntryDialog extends StatefulWidget { - static const routeName = '/dialog/export_entry'; +class ConvertEntryDialog extends StatefulWidget { + static const routeName = '/dialog/convert_entry'; - final AvesEntry entry; + final Set entries; - const ExportEntryDialog({ + const ConvertEntryDialog({ super.key, - required this.entry, + required this.entries, }); @override - State createState() => _ExportEntryDialogState(); + State createState() => _ConvertEntryDialogState(); } -class _ExportEntryDialogState extends State { +class _ConvertEntryDialogState extends State { final TextEditingController _widthController = TextEditingController(), _heightController = TextEditingController(); final ValueNotifier _isValidNotifier = ValueNotifier(false); - String _mimeType = MimeTypes.jpeg; + late String _mimeType; + late bool _sameSized; + late List _lengthUnitOptions; + late LengthUnit _lengthUnit; - AvesEntry get entry => widget.entry; + Set get entries => widget.entries; static const imageExportFormats = [ MimeTypes.bmp, @@ -39,11 +45,31 @@ class _ExportEntryDialogState extends State { @override void initState() { super.initState(); - _widthController.text = '${entry.isRotated ? entry.height : entry.width}'; - _heightController.text = '${entry.isRotated ? entry.width : entry.height}'; + _mimeType = MimeTypes.jpeg; + _sameSized = entries.map((entry) => entry.displaySize).toSet().length == 1; + _lengthUnitOptions = [ + if (_sameSized) LengthUnit.px, + LengthUnit.percent, + ]; + _lengthUnit = _lengthUnitOptions.first; + _initDimensions(); _validate(); } + void _initDimensions() { + switch (_lengthUnit) { + case LengthUnit.px: + final displaySize = entries.first.displaySize; + _widthController.text = '${displaySize.width.round()}'; + _heightController.text = '${displaySize.height.round()}'; + break; + case LengthUnit.percent: + _widthController.text = '100'; + _heightController.text = '100'; + break; + } + } + @override void dispose() { _widthController.dispose(); @@ -56,6 +82,14 @@ class _ExportEntryDialogState extends State { final l10n = context.l10n; const contentHorizontalPadding = EdgeInsets.symmetric(horizontal: AvesDialog.defaultHorizontalContentPadding); + // used by the drop down to match input decoration + final textFieldDecorationBorder = Border( + bottom: BorderSide( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.38), //Color(0xFFBDBDBD), + width: 1.0, + ), + ); + return AvesDialog( scrollableContent: [ const SizedBox(height: 16), @@ -92,12 +126,25 @@ class _ExportEntryDialogState extends State { keyboardType: TextInputType.number, onChanged: (value) { final width = int.tryParse(value); - _heightController.text = width != null ? '${(width / entry.displayAspectRatio).round()}' : ''; + if (width != null) { + switch (_lengthUnit) { + case LengthUnit.px: + _heightController.text = '${(width / entries.first.displayAspectRatio).round()}'; + break; + case LengthUnit.percent: + _heightController.text = '$width'; + break; + } + } else { + _heightController.text = ''; + } _validate(); }, ), ), + const SizedBox(width: 8), const Text(AvesEntry.resolutionSeparator), + const SizedBox(width: 8), Expanded( child: TextField( controller: _heightController, @@ -105,11 +152,46 @@ class _ExportEntryDialogState extends State { keyboardType: TextInputType.number, onChanged: (value) { final height = int.tryParse(value); - _widthController.text = height != null ? '${(height * entry.displayAspectRatio).round()}' : ''; + if (height != null) { + switch (_lengthUnit) { + case LengthUnit.px: + _widthController.text = '${(height * entries.first.displayAspectRatio).round()}'; + break; + case LengthUnit.percent: + _widthController.text = '$height'; + break; + } + } 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), + ), ], ), ), @@ -126,8 +208,9 @@ class _ExportEntryDialogState extends State { final width = int.tryParse(_widthController.text); final height = int.tryParse(_heightController.text); final options = (width != null && height != null) - ? EntryExportOptions( + ? EntryConvertOptions( mimeType: _mimeType, + lengthUnit: _lengthUnit, width: width, height: height, ) diff --git a/lib/widgets/filter_grids/common/app_bar.dart b/lib/widgets/filter_grids/common/app_bar.dart index bf4c56959..8312628c4 100644 --- a/lib/widgets/filter_grids/common/app_bar.dart +++ b/lib/widgets/filter_grids/common/app_bar.dart @@ -327,9 +327,13 @@ class _FilterGridAppBarState !browsingQuickActions.contains(v)); final selectionMenuActions = ChipSetActions.selection.where((v) => !selectionQuickActions.contains(v)); - final contextualMenuActions = (isSelecting ? selectionMenuActions : browsingMenuActions).where((v) => v == null || isVisible(v)).toList(); - if (contextualMenuActions.isNotEmpty && contextualMenuActions.first == null) contextualMenuActions.removeAt(0); - if (contextualMenuActions.isNotEmpty && contextualMenuActions.last == null) contextualMenuActions.removeLast(); + final contextualMenuActions = (isSelecting ? selectionMenuActions : browsingMenuActions).where((v) => v == null || isVisible(v)).fold([], (prev, v) { + if (v == null && (prev.isEmpty || prev.last == null)) return prev; + return [...prev, v]; + }); + if (contextualMenuActions.isNotEmpty && contextualMenuActions.last == null) { + contextualMenuActions.removeLast(); + } return [ ...generalMenuItems, diff --git a/lib/widgets/viewer/action/entry_action_delegate.dart b/lib/widgets/viewer/action/entry_action_delegate.dart index 0003dc347..e984f6e32 100644 --- a/lib/widgets/viewer/action/entry_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_action_delegate.dart @@ -8,20 +8,14 @@ import 'package:aves/model/actions/share_actions.dart'; import 'package:aves/model/device.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/entry_metadata_edition.dart'; -import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/settings/enums/enums.dart'; import 'package:aves/model/settings/settings.dart'; 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/image_op_events.dart'; import 'package:aves/services/common/services.dart'; -import 'package:aves/services/media/enums.dart'; -import 'package:aves/services/media/media_edit_service.dart'; import 'package:aves/theme/durations.dart'; -import 'package:aves/widgets/aves_app.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/feedback.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; @@ -32,8 +26,6 @@ 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/entry_editors/rename_entry_dialog.dart'; -import 'package:aves/widgets/dialogs/export_entry_dialog.dart'; -import 'package:aves/widgets/dialogs/pick_dialogs/album_pick_page.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/single_entry_editor.dart'; @@ -42,7 +34,6 @@ import 'package:aves/widgets/viewer/multipage/conductor.dart'; import 'package:aves/widgets/viewer/notifications.dart'; import 'package:aves/widgets/viewer/source_viewer_page.dart'; import 'package:aves/widgets/viewer/video/conductor.dart'; -import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; @@ -197,7 +188,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix _move(context, targetEntry, moveType: MoveType.fromBin); break; case EntryAction.convert: - _convert(context, targetEntry); + convert(context, {targetEntry}); break; case EntryAction.print: EntryPrinter(targetEntry).print(context); @@ -412,98 +403,6 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix } } - Future _convert(BuildContext context, AvesEntry targetEntry) async { - final options = await showDialog( - context: context, - builder: (context) => ExportEntryDialog(entry: targetEntry), - routeSettings: const RouteSettings(name: ExportEntryDialog.routeName), - ); - if (options == null) return; - - final destinationAlbum = await pickAlbum(context: context, moveType: MoveType.export); - if (destinationAlbum == null) return; - if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return; - - if (!await checkFreeSpaceForMove(context, {targetEntry}, destinationAlbum, MoveType.export)) return; - - final selection = {}; - if (targetEntry.isMultiPage) { - final multiPageInfo = await targetEntry.getMultiPageInfo(); - if (multiPageInfo != null) { - if (targetEntry.isMotionPhoto) { - await multiPageInfo.extractMotionPhotoVideo(); - } - if (multiPageInfo.pageCount > 1) { - selection.addAll(multiPageInfo.exportEntries); - } - } - } else { - selection.add(targetEntry); - } - - final selectionCount = selection.length; - final source = context.read(); - source.pauseMonitoring(); - await showOpReport( - context: context, - opStream: mediaEditService.export( - selection, - options: options, - destinationAlbum: destinationAlbum, - nameConflictStrategy: NameConflictStrategy.rename, - ), - itemCount: selectionCount, - onDone: (processed) async { - final successOps = processed.where((e) => e.success).toSet(); - final exportedOps = successOps.where((e) => !e.skipped).toSet(); - final newUris = exportedOps.map((v) => v.newFields['uri'] as String?).whereNotNull().toSet(); - final isMainMode = context.read>().value == AppMode.main; - - source.resumeMonitoring(); - unawaited(source.refreshUris(newUris)); - - final l10n = context.l10n; - final showAction = isMainMode && newUris.isNotEmpty - ? SnackBarAction( - label: l10n.showButtonLabel, - onPressed: () { - // local context may be deactivated when action is triggered after navigation - final context = AvesApp.navigatorKey.currentContext; - if (context != null) { - Navigator.maybeOf(context)?.pushAndRemoveUntil( - MaterialPageRoute( - settings: const RouteSettings(name: CollectionPage.routeName), - builder: (context) => CollectionPage( - source: source, - filters: {AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum))}, - highlightTest: (entry) => newUris.contains(entry.uri), - ), - ), - (route) => false, - ); - } - }, - ) - : null; - final successCount = successOps.length; - if (successCount < selectionCount) { - final count = selectionCount - successCount; - showFeedback( - context, - l10n.collectionExportFailureFeedback(count), - showAction, - ); - } else { - showFeedback( - context, - l10n.genericSuccessFeedback, - showAction, - ); - } - }, - ); - } - Future _move(BuildContext context, AvesEntry targetEntry, {required MoveType moveType}) => doMove( context, moveType: moveType, diff --git a/untranslated.json b/untranslated.json index 93b33381b..0ffa54496 100644 --- a/untranslated.json +++ b/untranslated.json @@ -116,6 +116,8 @@ "keepScreenOnVideoPlayback", "keepScreenOnViewerOnly", "keepScreenOnAlways", + "lengthUnitPixel", + "lengthUnitPercent", "mapStyleGoogleNormal", "mapStyleGoogleHybrid", "mapStyleGoogleTerrain", @@ -600,6 +602,8 @@ "chipActionCreateVault", "chipActionConfigureVault", "albumTierVaults", + "lengthUnitPixel", + "lengthUnitPercent", "vaultLockTypePin", "vaultLockTypePassword", "newVaultWarningDialogMessage", @@ -627,6 +631,8 @@ "chipActionCreateVault", "chipActionConfigureVault", "albumTierVaults", + "lengthUnitPixel", + "lengthUnitPercent", "vaultLockTypePin", "vaultLockTypePassword", "newVaultWarningDialogMessage", @@ -650,6 +656,8 @@ "el": [ "chipActionGoToPlacePage", + "lengthUnitPixel", + "lengthUnitPercent", "drawerPlacePage", "placePageTitle", "placeEmpty" @@ -657,6 +665,8 @@ "es": [ "chipActionGoToPlacePage", + "lengthUnitPixel", + "lengthUnitPercent", "drawerPlacePage", "placePageTitle", "placeEmpty" @@ -668,6 +678,8 @@ "chipActionCreateVault", "chipActionConfigureVault", "albumTierVaults", + "lengthUnitPixel", + "lengthUnitPercent", "vaultLockTypePin", "vaultLockTypePassword", "newVaultWarningDialogMessage", @@ -725,6 +737,8 @@ "keepScreenOnVideoPlayback", "keepScreenOnViewerOnly", "keepScreenOnAlways", + "lengthUnitPixel", + "lengthUnitPercent", "nameConflictStrategySkip", "vaultLockTypePin", "vaultLockTypePassword", @@ -1156,6 +1170,8 @@ "fr": [ "chipActionGoToPlacePage", + "lengthUnitPixel", + "lengthUnitPercent", "drawerPlacePage", "placePageTitle", "placeEmpty" @@ -1187,6 +1203,8 @@ "displayRefreshRatePreferHighest", "displayRefreshRatePreferLowest", "keepScreenOnVideoPlayback", + "lengthUnitPixel", + "lengthUnitPercent", "subtitlePositionTop", "subtitlePositionBottom", "themeBrightnessLight", @@ -1793,6 +1811,8 @@ "keepScreenOnVideoPlayback", "keepScreenOnViewerOnly", "keepScreenOnAlways", + "lengthUnitPixel", + "lengthUnitPercent", "mapStyleGoogleNormal", "mapStyleGoogleHybrid", "mapStyleGoogleTerrain", @@ -2286,6 +2306,8 @@ "id": [ "chipActionGoToPlacePage", + "lengthUnitPixel", + "lengthUnitPercent", "drawerPlacePage", "placePageTitle", "placeEmpty" @@ -2297,6 +2319,8 @@ "chipActionCreateVault", "chipActionConfigureVault", "albumTierVaults", + "lengthUnitPixel", + "lengthUnitPercent", "vaultLockTypePin", "vaultLockTypePassword", "newVaultWarningDialogMessage", @@ -2332,6 +2356,8 @@ "filterTaggedLabel", "albumTierVaults", "keepScreenOnVideoPlayback", + "lengthUnitPixel", + "lengthUnitPercent", "subtitlePositionTop", "subtitlePositionBottom", "vaultLockTypePin", @@ -2364,6 +2390,8 @@ "ko": [ "chipActionGoToPlacePage", + "lengthUnitPixel", + "lengthUnitPercent", "drawerPlacePage", "placePageTitle", "placeEmpty" @@ -2379,6 +2407,8 @@ "filterTaggedLabel", "albumTierVaults", "keepScreenOnVideoPlayback", + "lengthUnitPixel", + "lengthUnitPercent", "vaultLockTypePin", "vaultLockTypePassword", "newVaultWarningDialogMessage", @@ -2412,6 +2442,8 @@ "chipActionCreateVault", "chipActionConfigureVault", "albumTierVaults", + "lengthUnitPixel", + "lengthUnitPercent", "vaultLockTypePin", "vaultLockTypePassword", "newVaultWarningDialogMessage", @@ -2450,6 +2482,8 @@ "filterTaggedLabel", "albumTierVaults", "keepScreenOnVideoPlayback", + "lengthUnitPixel", + "lengthUnitPercent", "subtitlePositionTop", "subtitlePositionBottom", "vaultLockTypePin", @@ -2500,6 +2534,8 @@ "albumTierVaults", "displayRefreshRatePreferHighest", "displayRefreshRatePreferLowest", + "lengthUnitPixel", + "lengthUnitPercent", "vaultLockTypePin", "vaultLockTypePassword", "wallpaperTargetHome", @@ -2798,6 +2834,8 @@ "pl": [ "chipActionGoToPlacePage", + "lengthUnitPixel", + "lengthUnitPercent", "drawerPlacePage", "placePageTitle", "placeEmpty" @@ -2810,6 +2848,8 @@ "chipActionCreateVault", "chipActionConfigureVault", "albumTierVaults", + "lengthUnitPixel", + "lengthUnitPercent", "vaultLockTypePin", "vaultLockTypePassword", "newVaultWarningDialogMessage", @@ -2835,6 +2875,8 @@ "ro": [ "chipActionGoToPlacePage", + "lengthUnitPixel", + "lengthUnitPercent", "drawerPlacePage", "placePageTitle", "placeEmpty" @@ -2848,6 +2890,8 @@ "filterLocatedLabel", "filterTaggedLabel", "albumTierVaults", + "lengthUnitPixel", + "lengthUnitPercent", "vaultLockTypePin", "vaultLockTypePassword", "newVaultWarningDialogMessage", @@ -2885,6 +2929,8 @@ "filterNoLocationLabel", "albumTierVaults", "coordinateDms", + "lengthUnitPixel", + "lengthUnitPercent", "vaultLockTypePin", "vaultLockTypePassword", "otherDirectoryDescription", @@ -3300,6 +3346,8 @@ "chipActionCreateVault", "chipActionConfigureVault", "albumTierVaults", + "lengthUnitPixel", + "lengthUnitPercent", "vaultLockTypePin", "vaultLockTypePassword", "newVaultWarningDialogMessage", @@ -3642,6 +3690,8 @@ "chipActionCreateVault", "chipActionConfigureVault", "albumTierVaults", + "lengthUnitPixel", + "lengthUnitPercent", "vaultLockTypePin", "vaultLockTypePassword", "newVaultWarningDialogMessage", @@ -3665,6 +3715,8 @@ "uk": [ "chipActionGoToPlacePage", + "lengthUnitPixel", + "lengthUnitPercent", "drawerPlacePage", "placePageTitle", "placeEmpty" @@ -3678,6 +3730,8 @@ "filterLocatedLabel", "filterTaggedLabel", "albumTierVaults", + "lengthUnitPixel", + "lengthUnitPercent", "vaultLockTypePin", "vaultLockTypePassword", "newVaultWarningDialogMessage", @@ -3714,6 +3768,8 @@ "filterLocatedLabel", "filterTaggedLabel", "albumTierVaults", + "lengthUnitPixel", + "lengthUnitPercent", "vaultLockTypePin", "vaultLockTypePassword", "newVaultWarningDialogMessage",