From b84fde14af0384953194b863a561129b27578c45 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sat, 9 Oct 2021 19:07:09 +0900 Subject: [PATCH] export: format selection, clean up failed export --- .../aves/model/provider/ImageProvider.kt | 8 ++- lib/l10n/app_en.arb | 3 + lib/l10n/app_ko.arb | 2 + lib/model/source/collection_source.dart | 2 + lib/model/source/media_store_source.dart | 1 + lib/widgets/dialogs/aves_dialog.dart | 5 +- lib/widgets/dialogs/export_entry_dialog.dart | 70 +++++++++++++++++++ lib/widgets/viewer/entry_action_delegate.dart | 27 ++++--- 8 files changed, 107 insertions(+), 11 deletions(-) create mode 100644 lib/widgets/dialogs/export_entry_dialog.dart 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 e2e324866..394a22824 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 @@ -67,7 +67,7 @@ abstract class ImageProvider { callback: ImageOpCallback, ) { if (!supportedExportMimeTypes.contains(imageExportMimeType)) { - throw Exception("unsupported export MIME type=$imageExportMimeType") + callback.onFailure(Exception("unsupported export MIME type=$imageExportMimeType")) } val destinationDirDocFile = createDirectoryIfAbsent(activity, destinationDir) @@ -198,6 +198,12 @@ abstract class ImageProvider { bitmap.compress(format, quality, output) } } + } catch (e: Exception) { + // remove empty file + if (destinationDocFile.exists()) { + destinationDocFile.delete() + } + throw e } finally { Glide.with(activity).clear(target) } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 9abf30939..620738338 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -344,6 +344,9 @@ } }, + "exportEntryDialogFormat": "Format:", + "@exportEntryDialogFormat": {}, + "renameEntryDialogLabel": "New name", "@renameEntryDialogLabel": {}, diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index f02446993..9f91e2867 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -156,6 +156,8 @@ "deleteSingleAlbumConfirmationDialogMessage": "{count, plural, other{이 앨범의 항목 {count}개를 삭제하시겠습니까?}}", "deleteMultiAlbumConfirmationDialogMessage": "{count, plural, other{이 앨범들의 항목 {count}개를 삭제하시겠습니까?}}", + "exportEntryDialogFormat": "형식:", + "renameEntryDialogLabel": "이름", "editEntryDateDialogTitle": "날짜 및 시간", diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index 6fa4a30d7..9101dd24a 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -254,6 +254,8 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM Future refresh(); + Future> refreshUris(Set changedUris); + Future rescan(Set entries); Future refreshMetadata(Set entries) async { diff --git a/lib/model/source/media_store_source.dart b/lib/model/source/media_store_source.dart index d238846ab..775419f4b 100644 --- a/lib/model/source/media_store_source.dart +++ b/lib/model/source/media_store_source.dart @@ -127,6 +127,7 @@ class MediaStoreSource extends CollectionSource { // 2) registered in the Media Store but still being processed by their owner in a temporary location // For example, when taking a picture with a Galaxy S10e default camera app, querying the Media Store // sometimes yields an entry with its temporary path: `/data/sec/camera/!@#$%^..._temp.jpg` + @override Future> refreshUris(Set changedUris) async { if (!_initialized || !isMonitoring) return changedUris; diff --git a/lib/widgets/dialogs/aves_dialog.dart b/lib/widgets/dialogs/aves_dialog.dart index 7fbc0c919..c4a28c2a0 100644 --- a/lib/widgets/dialogs/aves_dialog.dart +++ b/lib/widgets/dialogs/aves_dialog.dart @@ -5,8 +5,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; class AvesDialog extends AlertDialog { - static const contentHorizontalPadding = EdgeInsets.symmetric(horizontal: 24); - static const borderWidth = 1.0; + static const EdgeInsets contentHorizontalPadding = EdgeInsets.symmetric(horizontal: 24); + static const double controlCaptionPadding = 16; + static const double borderWidth = 1.0; AvesDialog({ Key? key, diff --git a/lib/widgets/dialogs/export_entry_dialog.dart b/lib/widgets/dialogs/export_entry_dialog.dart new file mode 100644 index 000000000..c25bdf57e --- /dev/null +++ b/lib/widgets/dialogs/export_entry_dialog.dart @@ -0,0 +1,70 @@ +import 'package:aves/model/entry.dart'; +import 'package:aves/ref/mime_types.dart'; +import 'package:aves/utils/mime_utils.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:flutter/material.dart'; + +import 'aves_dialog.dart'; + +class ExportEntryDialog extends StatefulWidget { + final AvesEntry entry; + + const ExportEntryDialog({ + Key? key, + required this.entry, + }) : super(key: key); + + @override + _ExportEntryDialogState createState() => _ExportEntryDialogState(); +} + +class _ExportEntryDialogState extends State { + String _mimeType = MimeTypes.jpeg; + + AvesEntry get entry => widget.entry; + + static const imageExportFormats = [ + MimeTypes.bmp, + MimeTypes.jpeg, + MimeTypes.png, + MimeTypes.webp, + ]; + + @override + Widget build(BuildContext context) { + return AvesDialog( + context: context, + content: Row( + mainAxisSize: MainAxisSize.min, + 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); + } + }, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), + ), + TextButton( + onPressed: () => Navigator.pop(context, _mimeType), + child: Text(context.l10n.applyButtonLabel), + ) + ], + ); + } +} diff --git a/lib/widgets/viewer/entry_action_delegate.dart b/lib/widgets/viewer/entry_action_delegate.dart index 0b0ddbdd6..58a134383 100644 --- a/lib/widgets/viewer/entry_action_delegate.dart +++ b/lib/widgets/viewer/entry_action_delegate.dart @@ -9,7 +9,6 @@ import 'package:aves/model/filters/album.dart'; import 'package:aves/model/highlight.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; -import 'package:aves/ref/mime_types.dart'; import 'package:aves/services/common/image_op_events.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/services/media/enums.dart'; @@ -20,6 +19,7 @@ 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_dialog.dart'; +import 'package:aves/widgets/dialogs/export_entry_dialog.dart'; import 'package:aves/widgets/dialogs/rename_entry_dialog.dart'; import 'package:aves/widgets/filter_grids/album_pick.dart'; import 'package:aves/widgets/viewer/debug/debug_page.dart'; @@ -185,6 +185,12 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix if (!await checkFreeSpaceForMove(context, {entry}, destinationAlbum, MoveType.export)) return; + final mimeType = await showDialog( + context: context, + builder: (context) => ExportEntryDialog(entry: entry), + ); + if (mimeType == null) return; + final selection = {}; if (entry.isMultiPage) { final multiPageInfo = await entry.getMultiPageInfo(); @@ -201,21 +207,26 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix } final selectionCount = selection.length; + source.pauseMonitoring(); showOpReport( context: context, // TODO TLAD [SVG] export separately from raster images (sending bytes, like frame captures) opStream: mediaFileService.export( selection, - mimeType: MimeTypes.jpeg, + mimeType: mimeType, destinationAlbum: destinationAlbum, nameConflictStrategy: NameConflictStrategy.rename, ), itemCount: selectionCount, onDone: (processed) { - final movedOps = processed.where((e) => e.success); - final movedCount = movedOps.length; + final exportOps = processed.where((e) => e.success); + final exportCount = exportOps.length; final isMainMode = context.read>().value == AppMode.main; - final showAction = isMainMode && movedCount > 0 + + source.resumeMonitoring(); + source.refreshUris(exportOps.map((v) => v.newFields['uri'] as String?).whereNotNull().toSet()); + + final showAction = isMainMode && exportCount > 0 ? SnackBarAction( label: context.l10n.showButtonLabel, onPressed: () async { @@ -236,7 +247,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix )); final delayDuration = context.read().staggeredAnimationPageTarget; await Future.delayed(delayDuration + Durations.highlightScrollInitDelay); - final newUris = movedOps.map((v) => v.newFields['uri'] as String?).toSet(); + final newUris = exportOps.map((v) => v.newFields['uri'] as String?).toSet(); final targetEntry = targetCollection.sortedEntries.firstWhereOrNull((entry) => newUris.contains(entry.uri)); if (targetEntry != null) { highlightInfo.trackItem(targetEntry, highlightItem: targetEntry); @@ -244,8 +255,8 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix }, ) : null; - if (movedCount < selectionCount) { - final count = selectionCount - movedCount; + if (exportCount < selectionCount) { + final count = selectionCount - exportCount; showFeedback( context, context.l10n.collectionExportFailureFeedback(count),