diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt index 5c9d40242..68c1c5293 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt @@ -13,7 +13,7 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.signature.ObjectKey import deckers.thibault.aves.decoder.MultiTrackImage -import deckers.thibault.aves.decoder.SvgThumbnail +import deckers.thibault.aves.decoder.SvgImage import deckers.thibault.aves.decoder.TiffImage import deckers.thibault.aves.decoder.VideoThumbnail import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation @@ -128,7 +128,7 @@ class ThumbnailFetcher internal constructor( .submit(width, height) } else { val model: Any = when { - svgFetch -> SvgThumbnail(context, uri) + svgFetch -> SvgImage(context, uri) tiffFetch -> TiffImage(context, uri, pageId) multiTrackFetch -> MultiTrackImage(context, uri, pageId) else -> StorageUtils.getGlideSafeUri(uri, mimeType) 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 a305180b2..a574fb74b 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 @@ -134,8 +134,10 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments var destinationDir = arguments["destinationPath"] as String? val mimeType = arguments["mimeType"] as String? + val width = arguments["width"] as Int? + val height = arguments["height"] as Int? val nameConflictStrategy = NameConflictStrategy.get(arguments["nameConflictStrategy"] as String?) - if (destinationDir == null || mimeType == null || nameConflictStrategy == null) { + if (destinationDir == null || mimeType == null || width == null || height == null || nameConflictStrategy == null) { error("export-args", "failed because of missing arguments", null) return } @@ -150,7 +152,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir) val entries = entryMapList.map(::AvesEntry) - provider.exportMultiple(activity, mimeType, destinationDir, entries, nameConflictStrategy, object : ImageOpCallback { + provider.exportMultiple(activity, mimeType, destinationDir, entries, 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) }) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/decoder/SvgGlideModule.kt b/android/app/src/main/kotlin/deckers/thibault/aves/decoder/SvgGlideModule.kt index 4a7a048f6..d544ba203 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/decoder/SvgGlideModule.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/decoder/SvgGlideModule.kt @@ -25,27 +25,27 @@ import kotlin.math.ceil @GlideModule class SvgGlideModule : LibraryGlideModule() { override fun registerComponents(context: Context, glide: Glide, registry: Registry) { - registry.append(SvgThumbnail::class.java, Bitmap::class.java, SvgLoader.Factory()) + registry.append(SvgImage::class.java, Bitmap::class.java, SvgLoader.Factory()) } } -class SvgThumbnail(val context: Context, val uri: Uri) +class SvgImage(val context: Context, val uri: Uri) -internal class SvgLoader : ModelLoader { - override fun buildLoadData(model: SvgThumbnail, width: Int, height: Int, options: Options): ModelLoader.LoadData { +internal class SvgLoader : ModelLoader { + override fun buildLoadData(model: SvgImage, width: Int, height: Int, options: Options): ModelLoader.LoadData { return ModelLoader.LoadData(ObjectKey(model.uri), SvgFetcher(model, width, height)) } - override fun handles(model: SvgThumbnail): Boolean = true + override fun handles(model: SvgImage): Boolean = true - internal class Factory : ModelLoaderFactory { - override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader = SvgLoader() + internal class Factory : ModelLoaderFactory { + override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader = SvgLoader() override fun teardown() {} } } -internal class SvgFetcher(val model: SvgThumbnail, val width: Int, val height: Int) : DataFetcher { +internal class SvgFetcher(val model: SvgImage, val width: Int, val height: Int) : DataFetcher { override fun loadData(priority: Priority, callback: DataFetcher.DataCallback) { val context = model.context val uri = model.uri 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 9ef5a9112..ed667a711 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 @@ -16,6 +16,7 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.request.RequestOptions import com.commonsware.cwac.document.DocumentFileCompat import deckers.thibault.aves.decoder.MultiTrackImage +import deckers.thibault.aves.decoder.SvgImage import deckers.thibault.aves.decoder.TiffImage import deckers.thibault.aves.metadata.* import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis @@ -82,6 +83,8 @@ abstract class ImageProvider { imageExportMimeType: String, targetDir: String, entries: List, + width: Int, + height: Int, nameConflictStrategy: NameConflictStrategy, callback: ImageOpCallback, ) { @@ -120,6 +123,8 @@ abstract class ImageProvider { sourceEntry = entry, targetDir = targetDir, targetDirDocFile = targetDirDocFile, + width = width, + height = height, nameConflictStrategy = nameConflictStrategy, exportMimeType = exportMimeType, ) @@ -138,6 +143,8 @@ abstract class ImageProvider { sourceEntry: AvesEntry, targetDir: String, targetDirDocFile: DocumentFileCompat, + width: Int, + height: Int, nameConflictStrategy: NameConflictStrategy, exportMimeType: String, ): FieldMap { @@ -178,6 +185,8 @@ abstract class ImageProvider { MultiTrackImage(activity, sourceUri, pageId) } else if (sourceMimeType == MimeTypes.TIFF) { TiffImage(activity, sourceUri, pageId) + } else if (sourceMimeType == MimeTypes.SVG) { + SvgImage(activity, sourceUri) } else { StorageUtils.getGlideSafeUri(sourceUri, sourceMimeType) } @@ -192,7 +201,7 @@ abstract class ImageProvider { .asBitmap() .apply(glideOptions) .load(model) - .submit() + .submit(width, height) try { var bitmap = target.get() if (MimeTypes.needRotationAfterGlide(sourceMimeType)) { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 11f2dbfa7..d849f7017 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -291,6 +291,8 @@ }, "exportEntryDialogFormat": "Format:", + "exportEntryDialogWidth": "Width", + "exportEntryDialogHeight": "Height", "renameEntryDialogLabel": "New name", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 3fbdbfee0..1ea5d28c2 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -179,6 +179,8 @@ "deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{Voulez-vous vraiment supprimer ces albums et leur élément ?} other{Voulez-vous vraiment supprimer ces albums et leurs {count} éléments ?}}", "exportEntryDialogFormat": "Format :", + "exportEntryDialogWidth": "Largeur", + "exportEntryDialogHeight": "Hauteur", "renameEntryDialogLabel": "Nouveau nom", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 557e7c2d8..b9d346a84 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -179,6 +179,8 @@ "deleteMultiAlbumConfirmationDialogMessage": "{count, plural, other{이 앨범들의 항목 {count}개를 삭제하시겠습니까?}}", "exportEntryDialogFormat": "형식:", + "exportEntryDialogWidth": "가로", + "exportEntryDialogHeight": "세로", "renameEntryDialogLabel": "이름", diff --git a/lib/services/media/media_file_service.dart b/lib/services/media/media_file_service.dart index 8b7f9240d..a73e74319 100644 --- a/lib/services/media/media_file_service.dart +++ b/lib/services/media/media_file_service.dart @@ -10,6 +10,7 @@ import 'package:aves/services/common/output_buffer.dart'; import 'package:aves/services/common/service_policy.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/services/media/enums.dart'; +import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:streams_channel/streams_channel.dart'; @@ -87,7 +88,7 @@ abstract class MediaFileService { Stream export( Iterable entries, { - required String mimeType, + required EntryExportOptions options, required String destinationAlbum, required NameConflictStrategy nameConflictStrategy, }); @@ -368,7 +369,7 @@ class PlatformMediaFileService implements MediaFileService { @override Stream export( Iterable entries, { - required String mimeType, + required EntryExportOptions options, required String destinationAlbum, required NameConflictStrategy nameConflictStrategy, }) { @@ -377,7 +378,9 @@ class PlatformMediaFileService implements MediaFileService { .receiveBroadcastStream({ 'op': 'export', 'entries': entries.map(_toPlatformEntryMap).toList(), - 'mimeType': mimeType, + 'mimeType': options.mimeType, + 'width': options.width, + 'height': options.height, 'destinationPath': destinationAlbum, 'nameConflictStrategy': nameConflictStrategy.toPlatform(), }) @@ -434,3 +437,18 @@ class PlatformMediaFileService implements MediaFileService { return {}; } } + +@immutable +class EntryExportOptions extends Equatable { + final String mimeType; + final int width, height; + + @override + List get props => [mimeType, width, height]; + + const EntryExportOptions({ + required this.mimeType, + required this.width, + required this.height, + }); +} diff --git a/lib/widgets/common/map/geo_map.dart b/lib/widgets/common/map/geo_map.dart index 1dbdc21d2..5d52b8f29 100644 --- a/lib/widgets/common/map/geo_map.dart +++ b/lib/widgets/common/map/geo_map.dart @@ -293,7 +293,10 @@ class _GeoMapState extends State { // node size: 64 by default, higher means faster indexing but slower search nodeSize: nodeSize, points: markers, - createCluster: GeoEntry.createCluster, + // use lambda instead of tear-off because of runtime exception when using + // `T Function(BaseCluster, double, double)` for `T Function(BaseCluster?, double?, double?)` + // ignore: unnecessary_lambdas + createCluster: (base, lng, lat) => GeoEntry.createCluster(base, lng, lat), ); } diff --git a/lib/widgets/dialogs/export_entry_dialog.dart b/lib/widgets/dialogs/export_entry_dialog.dart index 9e528f0fe..1fc67ee6e 100644 --- a/lib/widgets/dialogs/export_entry_dialog.dart +++ b/lib/widgets/dialogs/export_entry_dialog.dart @@ -1,5 +1,6 @@ import 'package:aves/model/entry.dart'; import 'package:aves/ref/mime_types.dart'; +import 'package:aves/services/media/media_file_service.dart'; import 'package:aves/utils/mime_utils.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/material.dart'; @@ -19,6 +20,8 @@ class ExportEntryDialog extends StatefulWidget { } class _ExportEntryDialogState extends State { + final TextEditingController _widthController = TextEditingController(), _heightController = TextEditingController(); + final ValueNotifier _isValidNotifier = ValueNotifier(false); String _mimeType = MimeTypes.jpeg; AvesEntry get entry => widget.entry; @@ -30,27 +33,80 @@ class _ExportEntryDialogState extends State { MimeTypes.webp, ]; + @override + void initState() { + super.initState(); + _widthController.text = '${entry.isRotated ? entry.height : entry.width}'; + _heightController.text = '${entry.isRotated ? entry.width : entry.height}'; + _validate(); + } + + @override + void dispose() { + _widthController.dispose(); + _heightController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { + final l10n = context.l10n; return AvesDialog( - content: Row( + content: Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(context.l10n.exportEntryDialogFormat), - const SizedBox(width: AvesDialog.controlCaptionPadding), - DropdownButton( - items: imageExportFormats.map((mimeType) { - return DropdownMenuItem( - value: mimeType, - child: Text(MimeUtils.displayType(mimeType)), - ); - }).toList(), - value: _mimeType, - onChanged: (selected) { - if (selected != null) { - setState(() => _mimeType = selected); - } - }, + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(l10n.exportEntryDialogFormat), + const SizedBox(width: AvesDialog.controlCaptionPadding), + DropdownButton( + items: imageExportFormats.map((mimeType) { + return DropdownMenuItem( + value: mimeType, + child: Text(MimeUtils.displayType(mimeType)), + ); + }).toList(), + value: _mimeType, + onChanged: (selected) { + if (selected != null) { + setState(() => _mimeType = selected); + } + }, + ), + ], + ), + 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); + _heightController.text = width != null ? '${(width / entry.displayAspectRatio).round()}' : ''; + _validate(); + }, + ), + ), + const Text(AvesEntry.resolutionSeparator), + Expanded( + child: TextField( + controller: _heightController, + decoration: InputDecoration(labelText: l10n.exportEntryDialogHeight), + keyboardType: TextInputType.number, + onChanged: (value) { + final height = int.tryParse(value); + _widthController.text = height != null ? '${(height * entry.displayAspectRatio).round()}' : ''; + _validate(); + }, + ), + ), + ], ), ], ), @@ -59,11 +115,35 @@ class _ExportEntryDialogState extends State { onPressed: () => Navigator.pop(context), child: Text(MaterialLocalizations.of(context).cancelButtonLabel), ), - TextButton( - onPressed: () => Navigator.pop(context, _mimeType), - child: Text(context.l10n.applyButtonLabel), - ) + ValueListenableBuilder( + valueListenable: _isValidNotifier, + builder: (context, isValid, child) { + return TextButton( + onPressed: isValid + ? () { + final width = int.tryParse(_widthController.text); + final height = int.tryParse(_heightController.text); + final options = (width != null && height != null) + ? EntryExportOptions( + mimeType: _mimeType, + width: width, + height: height, + ) + : null; + Navigator.pop(context, options); + } + : null, + child: Text(l10n.applyButtonLabel), + ); + }, + ), ], ); } + + Future _validate() async { + final width = int.tryParse(_widthController.text); + final height = int.tryParse(_heightController.text); + _isValidNotifier.value = (width ?? 0) > 0 && (height ?? 0) > 0; + } } diff --git a/lib/widgets/viewer/action/entry_action_delegate.dart b/lib/widgets/viewer/action/entry_action_delegate.dart index 3c6cff31f..2611acc76 100644 --- a/lib/widgets/viewer/action/entry_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_action_delegate.dart @@ -14,6 +14,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_file_service.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; @@ -203,11 +204,11 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix if (!await checkFreeSpaceForMove(context, {entry}, destinationAlbum, MoveType.export)) return; - final mimeType = await showDialog( + final options = await showDialog( context: context, builder: (context) => ExportEntryDialog(entry: entry), ); - if (mimeType == null) return; + if (options == null) return; final selection = {}; if (entry.isMultiPage) { @@ -231,7 +232,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix // TODO TLAD [SVG] export separately from raster images (sending bytes, like frame captures) opStream: mediaFileService.export( selection, - mimeType: mimeType, + options: options, destinationAlbum: destinationAlbum, nameConflictStrategy: NameConflictStrategy.rename, ), diff --git a/untranslated.json b/untranslated.json index 2b6e80ddd..09080dbd1 100644 --- a/untranslated.json +++ b/untranslated.json @@ -1,5 +1,22 @@ { + "de": [ + "exportEntryDialogWidth", + "exportEntryDialogHeight" + ], + + "es": [ + "exportEntryDialogWidth", + "exportEntryDialogHeight" + ], + + "pt": [ + "exportEntryDialogWidth", + "exportEntryDialogHeight" + ], + "ru": [ + "exportEntryDialogWidth", + "exportEntryDialogHeight", "appExportCovers", "settingsThumbnailShowFavouriteIcon" ]