From 997005c4e5c09b74f889d5ad7ae9492a038871f8 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Fri, 4 Feb 2022 18:27:45 +0900 Subject: [PATCH] #155 #164 viewer: menu review, add copy/move, improved handling nomedia file content uri --- .../calls/fetchers/ThumbnailFetcher.kt | 2 +- .../channel/streams/ImageByteStreamHandler.kt | 2 +- .../aves/model/provider/ImageProvider.kt | 5 +- .../thibault/aves/utils/StorageUtils.kt | 81 +++++--- lib/l10n/app_de.arb | 9 +- lib/l10n/app_en.arb | 10 +- lib/l10n/app_es.arb | 9 +- lib/l10n/app_fr.arb | 9 +- lib/l10n/app_ko.arb | 9 +- lib/l10n/app_pt.arb | 9 +- lib/l10n/app_ru.arb | 9 +- lib/model/actions/entry_actions.dart | 59 +++--- lib/theme/icons.dart | 8 +- .../collection/entry_set_action_delegate.dart | 141 +------------- .../common/action_mixins/entry_storage.dart | 173 ++++++++++++++++++ .../common/action_mixins/feedback.dart | 4 +- lib/widgets/common/basic/menu.dart | 2 +- .../common/action_delegates/album_set.dart | 4 +- lib/widgets/settings/settings_page.dart | 4 +- .../viewer/viewer_actions_editor.dart | 11 +- .../viewer/action/entry_action_delegate.dart | 44 +++-- lib/widgets/viewer/entry_viewer_stack.dart | 7 +- lib/widgets/viewer/info/notifications.dart | 5 +- lib/widgets/viewer/overlay/top.dart | 94 +++++----- untranslated.json | 21 +++ 25 files changed, 424 insertions(+), 307 deletions(-) create mode 100644 lib/widgets/common/action_mixins/entry_storage.dart 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 68c1c5293..5e99cee7e 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 @@ -131,7 +131,7 @@ class ThumbnailFetcher internal constructor( svgFetch -> SvgImage(context, uri) tiffFetch -> TiffImage(context, uri, pageId) multiTrackFetch -> MultiTrackImage(context, uri, pageId) - else -> StorageUtils.getGlideSafeUri(uri, mimeType) + else -> StorageUtils.getGlideSafeUri(context, uri, mimeType) } Glide.with(context) .asBitmap() diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt index 5ff445aaa..be9b244e6 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt @@ -119,7 +119,7 @@ class ImageByteStreamHandler(private val context: Context, private val arguments } else if (mimeType == MimeTypes.TIFF) { TiffImage(context, uri, pageId) } else { - StorageUtils.getGlideSafeUri(uri, mimeType) + StorageUtils.getGlideSafeUri(context, uri, mimeType) } val target = Glide.with(context) 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 0466552bd..5659f8a5d 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 @@ -133,7 +133,6 @@ abstract class ImageProvider { } } - @Suppress("BlockingMethodInNonBlockingContext") private suspend fun exportSingle( activity: Activity, sourceEntry: AvesEntry, @@ -174,6 +173,7 @@ abstract class ImageProvider { targetMimeType = sourceMimeType write = { output -> val sourceDocFile = DocumentFileCompat.fromSingleUri(activity, sourceUri) + @Suppress("BlockingMethodInNonBlockingContext") sourceDocFile.copyTo(output) } } else { @@ -184,7 +184,7 @@ abstract class ImageProvider { } else if (sourceMimeType == MimeTypes.SVG) { SvgImage(activity, sourceUri) } else { - StorageUtils.getGlideSafeUri(sourceUri, sourceMimeType) + StorageUtils.getGlideSafeUri(activity, sourceUri, sourceMimeType) } // request a fresh image with the highest quality format @@ -198,6 +198,7 @@ abstract class ImageProvider { .apply(glideOptions) .load(model) .submit(width, height) + @Suppress("BlockingMethodInNonBlockingContext") var bitmap = target.get() if (MimeTypes.needRotationAfterGlide(sourceMimeType)) { bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt index bc9f217f2..1164ef228 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt @@ -329,7 +329,7 @@ object StorageUtils { // try to strip user info, if any if (mediaUri.userInfo != null) { - val genericMediaUri = Uri.parse(mediaUri.toString().replaceFirst("${mediaUri.userInfo}@", "")) + val genericMediaUri = stripMediaUriUserInfo(mediaUri) Log.d(LOG_TAG, "retry getDocumentFile for mediaUri=$mediaUri without userInfo: $genericMediaUri") return getDocumentFile(context, anyPath, genericMediaUri) } @@ -442,36 +442,71 @@ object StorageUtils { // As of Glide v4.12.0, a special loader `QMediaStoreUriLoader` is automatically used // to work around a bug from Android Q where metadata redaction corrupts HEIC images. // This loader relies on `MediaStore.setRequireOriginal` but this yields a `SecurityException` - // for some content URIs (e.g. `content://media/external_primary/downloads/...`) - // so we build a typical `images` or `videos` content URI from the original content ID. - fun getGlideSafeUri(uri: Uri, mimeType: String): Uri = normalizeMediaUri(uri, mimeType) - - // requesting access or writing to some MediaStore content URIs - // e.g. `content://0@media/...`, `content://media/external_primary/downloads/...` - // yields an exception with `All requested items must be referenced by specific ID` - fun getMediaStoreScopedStorageSafeUri(uri: Uri, mimeType: String): Uri = normalizeMediaUri(uri, mimeType) - - private fun normalizeMediaUri(uri: Uri, mimeType: String): Uri { + // for some non image/video content URIs (e.g. `downloads`, `file`) + fun getGlideSafeUri(context: Context, uri: Uri, mimeType: String): Uri { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isMediaStoreContentUri(uri)) { - // we cannot safely apply this to a file content URI, as it may point to a file not indexed - // by the Media Store (via `.nomedia`), and therefore has no matching image/video content URI - if (uri.path?.contains("/downloads/") == true) { - uri.tryParseId()?.let { id -> - return when { - isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id) - isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id) - else -> uri + val uriPath = uri.path + when { + uriPath?.contains("/downloads/") == true -> { + // e.g. `content://media/external_primary/downloads/...` + getMediaUriImageVideoUri(uri, mimeType)?.let { imageVideUri -> return imageVideUri } + } + uriPath?.contains("/file/") == true -> { + // e.g. `content://media/external/file/...` + // create an ad-hoc temporary file for decoding only + File.createTempFile("aves", null).apply { + deleteOnExit() + try { + outputStream().use { output -> + openInputStream(context, uri)?.use { input -> + input.copyTo(output) + } + } + return Uri.fromFile(this) + } catch (e: Exception) { + Log.e(LOG_TAG, "failed to create temporary file from uri=$uri", e) + } } } - } else if (uri.userInfo != null) { - // strip user info, if any - return Uri.parse(uri.toString().replaceFirst("${uri.userInfo}@", "")) + uri.userInfo != null -> return stripMediaUriUserInfo(uri) } - } return uri } + // requesting access or writing to some MediaStore content URIs + // yields an exception with `All requested items must be referenced by specific ID` + fun getMediaStoreScopedStorageSafeUri(uri: Uri, mimeType: String): Uri { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isMediaStoreContentUri(uri)) { + val uriPath = uri.path + when { + uriPath?.contains("/downloads/") == true -> { + // e.g. `content://media/external_primary/downloads/...` + getMediaUriImageVideoUri(uri, mimeType)?.let { imageVideUri -> return imageVideUri } + } + uri.userInfo != null -> return stripMediaUriUserInfo(uri) + } + } + return uri + } + + // Build a typical `images` or `videos` content URI from the original content ID. + // We cannot safely apply this to a `file` content URI, as it may point to a file not indexed + // by the Media Store (via `.nomedia`), and therefore has no matching image/video content URI. + private fun getMediaUriImageVideoUri(uri: Uri, mimeType: String): Uri? { + return uri.tryParseId()?.let { id -> + return when { + isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id) + isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id) + else -> uri + } + } + } + + // strip user info, if any + // e.g. `content://0@media/...` + private fun stripMediaUriUserInfo(uri: Uri) = Uri.parse(uri.toString().replaceFirst("${uri.userInfo}@", "")) + fun openInputStream(context: Context, uri: Uri): InputStream? { val effectiveUri = getOriginalUri(context, uri) return try { diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 438c05eca..a553538d9 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -47,7 +47,6 @@ "entryActionCopyToClipboard": "In die Zwischenablage kopieren", "entryActionDelete": "Löschen", "entryActionExport": "Exportieren", - "entryActionInfo": "Info", "entryActionRename": "Umbenennen", "entryActionRotateCCW": "Drehen gegen den Uhrzeigersinn", "entryActionRotateCW": "Drehen im Uhrzeigersinn", @@ -56,10 +55,10 @@ "entryActionShare": "Teilen", "entryActionViewSource": "Quelle anzeigen", "entryActionViewMotionPhotoVideo": "Bewegtes Foto öffnen", - "entryActionEdit": "Bearbeiten mit...", - "entryActionOpen": "Öffnen mit...", - "entryActionSetAs": "Einstellen als...", - "entryActionOpenMap": "In der Karten-App anzeigen...", + "entryActionEdit": "Bearbeiten", + "entryActionOpen": "Öffnen mit", + "entryActionSetAs": "Einstellen als", + "entryActionOpenMap": "In der Karten-App anzeigen", "entryActionRotateScreen": "Bildschirm rotieren", "entryActionAddFavourite": "Zu Favoriten hinzufügen ", "entryActionRemoveFavourite": "Aus Favoriten entfernen", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 0407e89cb..60a5d6341 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -69,8 +69,8 @@ "entryActionCopyToClipboard": "Copy to clipboard", "entryActionDelete": "Delete", + "entryActionConvert": "Convert", "entryActionExport": "Export", - "entryActionInfo": "Info", "entryActionRename": "Rename", "entryActionRotateCCW": "Rotate counterclockwise", "entryActionRotateCW": "Rotate clockwise", @@ -79,10 +79,10 @@ "entryActionShare": "Share", "entryActionViewSource": "View source", "entryActionViewMotionPhotoVideo": "Open Motion Photo", - "entryActionEdit": "Edit with…", - "entryActionOpen": "Open with…", - "entryActionSetAs": "Set as…", - "entryActionOpenMap": "Show in map app…", + "entryActionEdit": "Edit", + "entryActionOpen": "Open with", + "entryActionSetAs": "Set as", + "entryActionOpenMap": "Show in map app", "entryActionRotateScreen": "Rotate screen", "entryActionAddFavourite": "Add to favourites", "entryActionRemoveFavourite": "Remove from favourites", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 232abb8d3..61e2f6c05 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -47,7 +47,6 @@ "entryActionCopyToClipboard": "Copiar al portapapeles", "entryActionDelete": "Borrar", "entryActionExport": "Exportar", - "entryActionInfo": "Información", "entryActionRename": "Renombrar", "entryActionRotateCCW": "Rotar en sentido antihorario", "entryActionRotateCW": "Rotar en sentido horario", @@ -56,10 +55,10 @@ "entryActionShare": "Compartir", "entryActionViewSource": "Ver fuente", "entryActionViewMotionPhotoVideo": "Abrir foto en movimiento", - "entryActionEdit": "Editar con…", - "entryActionOpen": "Abrir con…", - "entryActionSetAs": "Establecer como…", - "entryActionOpenMap": "Mostrar en aplicación de mapa…", + "entryActionEdit": "Editar", + "entryActionOpen": "Abrir con", + "entryActionSetAs": "Establecer como", + "entryActionOpenMap": "Mostrar en aplicación de mapa", "entryActionRotateScreen": "Rotar pantalla", "entryActionAddFavourite": "Agregar a favoritos", "entryActionRemoveFavourite": "Quitar de favoritos", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 115812126..f9fb255e1 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -47,7 +47,6 @@ "entryActionCopyToClipboard": "Copier dans presse-papier", "entryActionDelete": "Supprimer", "entryActionExport": "Exporter", - "entryActionInfo": "Détails", "entryActionRename": "Renommer", "entryActionRotateCCW": "Pivoter à gauche", "entryActionRotateCW": "Pivoter à droite", @@ -56,10 +55,10 @@ "entryActionShare": "Partager", "entryActionViewSource": "Voir le code", "entryActionViewMotionPhotoVideo": "Ouvrir le clip vidéo", - "entryActionEdit": "Modifier avec…", - "entryActionOpen": "Ouvrir avec…", - "entryActionSetAs": "Utiliser comme…", - "entryActionOpenMap": "Localiser avec…", + "entryActionEdit": "Modifier", + "entryActionOpen": "Ouvrir avec", + "entryActionSetAs": "Utiliser comme", + "entryActionOpenMap": "Localiser avec", "entryActionRotateScreen": "Pivoter l’écran", "entryActionAddFavourite": "Ajouter aux favoris", "entryActionRemoveFavourite": "Retirer des favoris", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 2edf0589b..c82863abc 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -47,7 +47,6 @@ "entryActionCopyToClipboard": "클립보드에 복사", "entryActionDelete": "삭제", "entryActionExport": "내보내기", - "entryActionInfo": "상세정보", "entryActionRename": "이름 변경", "entryActionRotateCCW": "좌회전", "entryActionRotateCW": "우회전", @@ -56,10 +55,10 @@ "entryActionShare": "공유", "entryActionViewSource": "소스 코드 보기", "entryActionViewMotionPhotoVideo": "모션 포토 보기", - "entryActionEdit": "편집…", - "entryActionOpen": "다른 앱에서 열기…", - "entryActionSetAs": "다음 용도로 사용…", - "entryActionOpenMap": "지도 앱에서 보기…", + "entryActionEdit": "편집", + "entryActionOpen": "다른 앱에서 열기", + "entryActionSetAs": "다음 용도로 사용", + "entryActionOpenMap": "지도 앱에서 보기", "entryActionRotateScreen": "화면 회전", "entryActionAddFavourite": "즐겨찾기에 추가", "entryActionRemoveFavourite": "즐겨찾기에서 삭제", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index b4dfef910..f5d9f68f0 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -47,7 +47,6 @@ "entryActionCopyToClipboard": "Copiar para área de transferência", "entryActionDelete": "Excluir", "entryActionExport": "Exportar", - "entryActionInfo": "Informações", "entryActionRename": "Renomear", "entryActionRotateCCW": "Rotacionar para esquerda", "entryActionRotateCW": "Rotacionar para direita", @@ -56,10 +55,10 @@ "entryActionShare": "Compartilhado", "entryActionViewSource": "Ver fonte", "entryActionViewMotionPhotoVideo": "Abrir foto em movimento", - "entryActionEdit": "Editar com…", - "entryActionOpen": "Abrir com…", - "entryActionSetAs": "Definir como…", - "entryActionOpenMap": "Mostrar no aplicativo de mapa…", + "entryActionEdit": "Editar", + "entryActionOpen": "Abrir com", + "entryActionSetAs": "Definir como", + "entryActionOpenMap": "Mostrar no aplicativo de mapa", "entryActionRotateScreen": "Girar a tela", "entryActionAddFavourite": "Adicionar aos favoritos", "entryActionRemoveFavourite": "Remova dos favoritos", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 06d891c34..ff691cacd 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -47,7 +47,6 @@ "entryActionCopyToClipboard": "Скопировать в буфер обмена", "entryActionDelete": "Удалить", "entryActionExport": "Экспорт", - "entryActionInfo": "Информация", "entryActionRename": "Переименовать", "entryActionRotateCCW": "Повернуть против часовой стрелки", "entryActionRotateCW": "Повернуть по часовой стрелки", @@ -56,10 +55,10 @@ "entryActionShare": "Поделиться", "entryActionViewSource": "Посмотреть источник", "entryActionViewMotionPhotoVideo": "Открыть «Живые фото»", - "entryActionEdit": "Изменить с помощью…", - "entryActionOpen": "Открыть с помощью…", - "entryActionSetAs": "Установить как…", - "entryActionOpenMap": "Показать на карте…", + "entryActionEdit": "Изменить", + "entryActionOpen": "Открыть с помощью", + "entryActionSetAs": "Установить как", + "entryActionOpenMap": "Показать на карте", "entryActionRotateScreen": "Повернуть экран", "entryActionAddFavourite": "Добавить в избранное", "entryActionRemoveFavourite": "Удалить из избранного", diff --git a/lib/model/actions/entry_actions.dart b/lib/model/actions/entry_actions.dart index 4a8c98248..4c085b1f7 100644 --- a/lib/model/actions/entry_actions.dart +++ b/lib/model/actions/entry_actions.dart @@ -7,10 +7,11 @@ enum EntryAction { addShortcut, copyToClipboard, delete, - export, - info, + convert, print, rename, + copy, + move, share, toggleFavourite, // raster @@ -31,25 +32,32 @@ enum EntryAction { } class EntryActions { - static const inApp = [ - EntryAction.info, - EntryAction.toggleFavourite, + static const topLevel = [ EntryAction.share, - EntryAction.delete, + EntryAction.edit, EntryAction.rename, - EntryAction.export, - EntryAction.addShortcut, - EntryAction.copyToClipboard, - EntryAction.print, + EntryAction.delete, + EntryAction.copy, + EntryAction.move, + EntryAction.toggleFavourite, EntryAction.viewSource, EntryAction.rotateScreen, ]; - static const externalApp = [ - EntryAction.edit, + static const export = [ + EntryAction.convert, + EntryAction.addShortcut, + EntryAction.copyToClipboard, + EntryAction.print, EntryAction.open, - EntryAction.setAs, EntryAction.openMap, + EntryAction.setAs, + ]; + + static const exportExternal = [ + EntryAction.open, + EntryAction.openMap, + EntryAction.setAs, ]; static const pageActions = [ @@ -68,14 +76,16 @@ extension ExtraEntryAction on EntryAction { return context.l10n.entryActionCopyToClipboard; case EntryAction.delete: return context.l10n.entryActionDelete; - case EntryAction.export: - return context.l10n.entryActionExport; - case EntryAction.info: - return context.l10n.entryActionInfo; + case EntryAction.convert: + return context.l10n.entryActionConvert; case EntryAction.print: return context.l10n.entryActionPrint; case EntryAction.rename: return context.l10n.entryActionRename; + case EntryAction.copy: + return context.l10n.collectionActionCopy; + case EntryAction.move: + return context.l10n.collectionActionMove; case EntryAction.share: return context.l10n.entryActionShare; case EntryAction.toggleFavourite: @@ -133,14 +143,16 @@ extension ExtraEntryAction on EntryAction { return AIcons.clipboard; case EntryAction.delete: return AIcons.delete; - case EntryAction.export: - return AIcons.saveAs; - case EntryAction.info: - return AIcons.info; + case EntryAction.convert: + return AIcons.convert; case EntryAction.print: return AIcons.print; case EntryAction.rename: return AIcons.rename; + case EntryAction.copy: + return AIcons.copy; + case EntryAction.move: + return AIcons.move; case EntryAction.share: return AIcons.share; case EntryAction.toggleFavourite: @@ -158,10 +170,13 @@ extension ExtraEntryAction on EntryAction { return AIcons.vector; // external case EntryAction.edit: + return AIcons.edit; case EntryAction.open: + return AIcons.openOutside; case EntryAction.openMap: + return AIcons.map; case EntryAction.setAs: - return null; + return AIcons.setAs; // platform case EntryAction.rotateScreen: return AIcons.rotateScreen; diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index c91a307b7..8d5b07d82 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -50,13 +50,16 @@ class AIcons { static const IconData captureFrame = Icons.screenshot_outlined; static const IconData clear = Icons.clear_outlined; static const IconData clipboard = Icons.content_copy_outlined; + static const IconData convert = Icons.transform_outlined; static const IconData copy = Icons.file_copy_outlined; static const IconData debug = Icons.whatshot_outlined; static const IconData delete = Icons.delete_outlined; static const IconData edit = Icons.edit_outlined; static const IconData editRating = MdiIcons.starPlusOutline; static const IconData editTags = MdiIcons.tagPlusOutline; - static const IconData export = MdiIcons.fileExportOutline; + static const IconData export = Icons.open_with_outlined; + static const IconData fileExport = MdiIcons.fileExportOutline; + static const IconData fileImport = MdiIcons.fileImportOutline; static const IconData flip = Icons.flip_outlined; static const IconData favourite = Icons.favorite_border; static const IconData favouriteActive = Icons.favorite; @@ -65,7 +68,6 @@ class AIcons { static const IconData geoBounds = Icons.public_outlined; static const IconData goUp = Icons.arrow_upward_outlined; static const IconData hide = Icons.visibility_off_outlined; - static const IconData import = MdiIcons.fileImportOutline; static const IconData info = Icons.info_outlined; static const IconData layers = Icons.layers_outlined; static const IconData map = Icons.map_outlined; @@ -83,9 +85,9 @@ class AIcons { static const IconData rotateLeft = Icons.rotate_left_outlined; static const IconData rotateRight = Icons.rotate_right_outlined; static const IconData rotateScreen = Icons.screen_rotation_outlined; - static const IconData saveAs = Icons.save_alt_outlined; static const IconData search = Icons.search_outlined; static const IconData select = Icons.select_all_outlined; + static const IconData setAs = Icons.wallpaper_outlined; static const IconData setCover = MdiIcons.imageEditOutline; static const IconData share = Icons.share_outlined; static const IconData show = Icons.visibility_outlined; diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index db0e0d242..38be53935 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:io'; import 'package:aves/app_mode.dart'; import 'package:aves/model/actions/entry_set_actions.dart'; @@ -8,9 +7,7 @@ import 'package:aves/model/device.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/entry_metadata_edition.dart'; import 'package:aves/model/favourites.dart'; -import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; -import 'package:aves/model/highlight.dart'; import 'package:aves/model/query.dart'; import 'package:aves/model/selection.dart'; import 'package:aves/model/source/analysis_controller.dart'; @@ -18,10 +15,8 @@ import 'package:aves/model/source/collection_lens.dart'; 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/theme/durations.dart'; import 'package:aves/utils/mime_utils.dart'; -import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/common/action_mixins/entry_editor.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; @@ -29,8 +24,6 @@ import 'package:aves/widgets/common/action_mixins/size_aware.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; -import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; -import 'package:aves/widgets/filter_grids/album_pick.dart'; import 'package:aves/widgets/map/map_page.dart'; import 'package:aves/widgets/search/search_delegate.dart'; import 'package:aves/widgets/stats/stats_page.dart'; @@ -39,7 +32,9 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; -class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { +import '../common/action_mixins/entry_storage.dart'; + +class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin, EntryEditorMixin, EntryStorageMixin { bool isVisible( EntrySetAction action, { required AppMode appMode, @@ -268,7 +263,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa source.pauseMonitoring(); final opId = mediaFileService.newOpId; - showOpReport( + await showOpReport( context: context, opStream: mediaFileService.delete(opId: opId, entries: selectedItems), itemCount: todoCount, @@ -294,132 +289,10 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa } Future _move(BuildContext context, {required MoveType moveType}) async { - final l10n = context.l10n; final selection = context.read>(); final selectedItems = _getExpandedSelectedItems(selection); - final selectionDirs = selectedItems.map((e) => e.directory).whereNotNull().toSet(); - - final destinationAlbum = await pickAlbum(context: context, moveType: moveType); - if (destinationAlbum == null) return; - if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return; - - if (moveType == MoveType.move && !await checkStoragePermissionForAlbums(context, selectionDirs, entries: selectedItems)) return; - - if (!await checkFreeSpaceForMove(context, selectedItems, destinationAlbum, moveType)) return; - - // do not directly use selection when moving and post-processing items - // as source monitoring may remove obsolete items from the original selection - final todoItems = selectedItems.toSet(); - - final copy = moveType == MoveType.copy; - final todoCount = todoItems.length; - assert(todoCount > 0); - - final destinationDirectory = Directory(destinationAlbum); - final names = [ - ...todoItems.map((v) => '${v.filenameWithoutExtension}${v.extension}'), - // do not guard up front based on directory existence, - // as conflicts could be within moved entries scattered across multiple albums - if (await destinationDirectory.exists()) ...destinationDirectory.listSync().map((v) => pContext.basename(v.path)), - ]; - final uniqueNames = names.toSet(); - var nameConflictStrategy = NameConflictStrategy.rename; - if (uniqueNames.length < names.length) { - final value = await showDialog( - context: context, - builder: (context) { - return AvesSelectionDialog( - initialValue: nameConflictStrategy, - options: Map.fromEntries(NameConflictStrategy.values.map((v) => MapEntry(v, v.getName(context)))), - message: selectionDirs.length == 1 ? l10n.nameConflictDialogSingleSourceMessage : l10n.nameConflictDialogMultipleSourceMessage, - confirmationButtonLabel: l10n.continueButtonLabel, - ); - }, - ); - if (value == null) return; - nameConflictStrategy = value; - } - - final source = context.read(); - source.pauseMonitoring(); - final opId = mediaFileService.newOpId; - showOpReport( - context: context, - opStream: mediaFileService.move( - opId: opId, - entries: todoItems, - copy: copy, - destinationAlbum: destinationAlbum, - nameConflictStrategy: nameConflictStrategy, - ), - itemCount: todoCount, - onCancel: () => mediaFileService.cancelFileOp(opId), - onDone: (processed) async { - final successOps = processed.where((e) => e.success).toSet(); - final movedOps = successOps.where((e) => !e.skipped).toSet(); - await source.updateAfterMove( - todoEntries: todoItems, - copy: copy, - destinationAlbum: destinationAlbum, - movedOps: movedOps, - ); - selection.browse(); - source.resumeMonitoring(); - - // cleanup - if (moveType == MoveType.move) { - await storageService.deleteEmptyDirectories(selectionDirs); - } - - final successCount = successOps.length; - if (successCount < todoCount) { - final count = todoCount - successCount; - showFeedback(context, copy ? l10n.collectionCopyFailureFeedback(count) : l10n.collectionMoveFailureFeedback(count)); - } else { - final count = movedOps.length; - showFeedback( - context, - copy ? l10n.collectionCopySuccessFeedback(count) : l10n.collectionMoveSuccessFeedback(count), - count > 0 - ? SnackBarAction( - label: l10n.showButtonLabel, - onPressed: () async { - final highlightInfo = context.read(); - final collection = context.read(); - var targetCollection = collection; - if (collection.filters.any((f) => f is AlbumFilter)) { - final filter = AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum)); - // we could simply add the filter to the current collection - // but navigating makes the change less jarring - targetCollection = CollectionLens( - source: collection.source, - filters: collection.filters, - )..addFilter(filter); - unawaited(Navigator.pushReplacement( - context, - MaterialPageRoute( - settings: const RouteSettings(name: CollectionPage.routeName), - builder: (context) => CollectionPage( - collection: targetCollection, - ), - ), - )); - final delayDuration = context.read().staggeredAnimationPageTarget; - await Future.delayed(delayDuration); - } - await Future.delayed(Durations.highlightScrollInitDelay); - final newUris = movedOps.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); - } - }, - ) - : null, - ); - } - }, - ); + await move(context, moveType: moveType, selectedItems: selectedItems); + selection.browse(); } Future _edit( @@ -439,7 +312,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa final source = context.read(); source.pauseMonitoring(); var cancelled = false; - showOpReport( + await showOpReport( context: context, opStream: Stream.fromIterable(todoItems).asyncMap((entry) async { if (cancelled) { diff --git a/lib/widgets/common/action_mixins/entry_storage.dart b/lib/widgets/common/action_mixins/entry_storage.dart new file mode 100644 index 000000000..79385c0a4 --- /dev/null +++ b/lib/widgets/common/action_mixins/entry_storage.dart @@ -0,0 +1,173 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:aves/app_mode.dart'; +import 'package:aves/model/actions/move_type.dart'; +import 'package:aves/model/entry.dart'; +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/services/common/image_op_events.dart'; +import 'package:aves/services/common/services.dart'; +import 'package:aves/services/media/enums.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/widgets/collection/collection_page.dart'; +import 'package:aves/widgets/common/action_mixins/feedback.dart'; +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_selection_dialog.dart'; +import 'package:aves/widgets/filter_grids/album_pick.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { + Future move( + BuildContext context, { + required MoveType moveType, + required Set selectedItems, + VoidCallback? onSuccess, + }) async { + final source = context.read(); + if (!source.initialized) { + // source may be uninitialized in viewer mode + await source.init(); + unawaited(source.refresh()); + } + + final l10n = context.l10n; + final selectionDirs = selectedItems.map((e) => e.directory).whereNotNull().toSet(); + + final destinationAlbum = await pickAlbum(context: context, moveType: moveType); + if (destinationAlbum == null) return; + if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return; + + if (moveType == MoveType.move && !await checkStoragePermissionForAlbums(context, selectionDirs, entries: selectedItems)) return; + + if (!await checkFreeSpaceForMove(context, selectedItems, destinationAlbum, moveType)) return; + + // do not directly use selection when moving and post-processing items + // as source monitoring may remove obsolete items from the original selection + final todoItems = selectedItems.toSet(); + + final copy = moveType == MoveType.copy; + final todoCount = todoItems.length; + assert(todoCount > 0); + + final destinationDirectory = Directory(destinationAlbum); + final names = [ + ...todoItems.map((v) => '${v.filenameWithoutExtension}${v.extension}'), + // do not guard up front based on directory existence, + // as conflicts could be within moved entries scattered across multiple albums + if (await destinationDirectory.exists()) ...destinationDirectory.listSync().map((v) => pContext.basename(v.path)), + ]; + final uniqueNames = names.toSet(); + var nameConflictStrategy = NameConflictStrategy.rename; + if (uniqueNames.length < names.length) { + final value = await showDialog( + context: context, + builder: (context) { + return AvesSelectionDialog( + initialValue: nameConflictStrategy, + options: Map.fromEntries(NameConflictStrategy.values.map((v) => MapEntry(v, v.getName(context)))), + message: selectionDirs.length == 1 ? l10n.nameConflictDialogSingleSourceMessage : l10n.nameConflictDialogMultipleSourceMessage, + confirmationButtonLabel: l10n.continueButtonLabel, + ); + }, + ); + if (value == null) return; + nameConflictStrategy = value; + } + + source.pauseMonitoring(); + final opId = mediaFileService.newOpId; + await showOpReport( + context: context, + opStream: mediaFileService.move( + opId: opId, + entries: todoItems, + copy: copy, + destinationAlbum: destinationAlbum, + nameConflictStrategy: nameConflictStrategy, + ), + itemCount: todoCount, + onCancel: () => mediaFileService.cancelFileOp(opId), + onDone: (processed) async { + final successOps = processed.where((e) => e.success).toSet(); + final movedOps = successOps.where((e) => !e.skipped).toSet(); + await source.updateAfterMove( + todoEntries: todoItems, + copy: copy, + destinationAlbum: destinationAlbum, + movedOps: movedOps, + ); + source.resumeMonitoring(); + + // cleanup + if (moveType == MoveType.move) { + await storageService.deleteEmptyDirectories(selectionDirs); + } + + final successCount = successOps.length; + if (successCount < todoCount) { + final count = todoCount - successCount; + showFeedback(context, copy ? l10n.collectionCopyFailureFeedback(count) : l10n.collectionMoveFailureFeedback(count)); + } else { + final count = movedOps.length; + final appMode = context.read>().value; + + SnackBarAction? action; + if (count > 0 && appMode == AppMode.main) { + action = SnackBarAction( + label: l10n.showButtonLabel, + onPressed: () async { + late CollectionLens targetCollection; + + final highlightInfo = context.read(); + final collection = context.read(); + if (collection != null) { + targetCollection = collection; + } + if (collection == null || collection.filters.any((f) => f is AlbumFilter)) { + final filter = AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum)); + // we could simply add the filter to the current collection + // but navigating makes the change less jarring + targetCollection = CollectionLens( + source: source, + filters: collection?.filters, + )..addFilter(filter); + unawaited(Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute( + settings: const RouteSettings(name: CollectionPage.routeName), + builder: (context) => CollectionPage( + collection: targetCollection, + ), + ), + (route) => false, + )); + final delayDuration = context.read().staggeredAnimationPageTarget; + await Future.delayed(delayDuration); + } + await Future.delayed(Durations.highlightScrollInitDelay); + final newUris = movedOps.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); + } + }, + ); + } + showFeedback( + context, + copy ? l10n.collectionCopySuccessFeedback(count) : l10n.collectionMoveSuccessFeedback(count), + action, + ); + onSuccess?.call(); + } + }, + ); + } +} diff --git a/lib/widgets/common/action_mixins/feedback.dart b/lib/widgets/common/action_mixins/feedback.dart index 2429565fd..08b53b5cc 100644 --- a/lib/widgets/common/action_mixins/feedback.dart +++ b/lib/widgets/common/action_mixins/feedback.dart @@ -69,14 +69,14 @@ mixin FeedbackMixin { // report overlay for multiple operations - void showOpReport({ + Future showOpReport({ required BuildContext context, required Stream opStream, required int itemCount, VoidCallback? onCancel, void Function(Set processed)? onDone, }) { - showDialog( + return showDialog( context: context, barrierDismissible: false, builder: (context) => ReportOverlay( diff --git a/lib/widgets/common/basic/menu.dart b/lib/widgets/common/basic/menu.dart index 58a5dcc4f..8bb4db14a 100644 --- a/lib/widgets/common/basic/menu.dart +++ b/lib/widgets/common/basic/menu.dart @@ -52,7 +52,7 @@ class PopupMenuItemExpansionPanel extends StatefulWidget { final bool enabled; final IconData icon; final String title; - final List> items; + final List> items; const PopupMenuItemExpansionPanel({ Key? key, diff --git a/lib/widgets/filter_grids/common/action_delegates/album_set.dart b/lib/widgets/filter_grids/common/action_delegates/album_set.dart index 786d82e0c..8dd275552 100644 --- a/lib/widgets/filter_grids/common/action_delegates/album_set.dart +++ b/lib/widgets/filter_grids/common/action_delegates/album_set.dart @@ -217,7 +217,7 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate { source.pauseMonitoring(); final opId = mediaFileService.newOpId; - showOpReport( + await showOpReport( context: context, opStream: mediaFileService.delete(opId: opId, entries: todoEntries), itemCount: todoCount, @@ -281,7 +281,7 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate { source.pauseMonitoring(); final opId = mediaFileService.newOpId; - showOpReport( + await showOpReport( context: context, opStream: mediaFileService.move( opId: opId, diff --git a/lib/widgets/settings/settings_page.dart b/lib/widgets/settings/settings_page.dart index b55e3f81a..e5d1b610b 100644 --- a/lib/widgets/settings/settings_page.dart +++ b/lib/widgets/settings/settings_page.dart @@ -54,11 +54,11 @@ class _SettingsPageState extends State with FeedbackMixin { return [ PopupMenuItem( value: SettingsAction.export, - child: MenuRow(text: context.l10n.settingsActionExport, icon: const Icon(AIcons.export)), + child: MenuRow(text: context.l10n.settingsActionExport, icon: const Icon(AIcons.fileExport)), ), PopupMenuItem( value: SettingsAction.import, - child: MenuRow(text: context.l10n.settingsActionImport, icon: const Icon(AIcons.import)), + child: MenuRow(text: context.l10n.settingsActionImport, icon: const Icon(AIcons.fileImport)), ), ]; }, diff --git a/lib/widgets/settings/viewer/viewer_actions_editor.dart b/lib/widgets/settings/viewer/viewer_actions_editor.dart index 80e59c7f4..17018e0df 100644 --- a/lib/widgets/settings/viewer/viewer_actions_editor.dart +++ b/lib/widgets/settings/viewer/viewer_actions_editor.dart @@ -30,16 +30,7 @@ class ViewerActionEditorPage extends StatelessWidget { const ViewerActionEditorPage({Key? key}) : super(key: key); static const allAvailableActions = [ - EntryAction.info, - EntryAction.toggleFavourite, - EntryAction.share, - EntryAction.delete, - EntryAction.rename, - EntryAction.export, - EntryAction.addShortcut, - EntryAction.copyToClipboard, - EntryAction.print, - EntryAction.rotateScreen, + ...EntryActions.topLevel, EntryAction.rotateCCW, EntryAction.rotateCW, EntryAction.flip, diff --git a/lib/widgets/viewer/action/entry_action_delegate.dart b/lib/widgets/viewer/action/entry_action_delegate.dart index f0e1aab86..d90ba4284 100644 --- a/lib/widgets/viewer/action/entry_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_action_delegate.dart @@ -17,6 +17,7 @@ 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/entry_storage.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; import 'package:aves/widgets/common/action_mixins/size_aware.dart'; @@ -36,7 +37,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; -class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin, SingleEntryEditorMixin { +class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin, SingleEntryEditorMixin, EntryStorageMixin { @override final AvesEntry entry; @@ -55,11 +56,8 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix case EntryAction.delete: _delete(context); break; - case EntryAction.export: - _export(context); - break; - case EntryAction.info: - ShowInfoNotification().dispatch(context); + case EntryAction.convert: + _convert(context); break; case EntryAction.print: EntryPrinter(entry).print(context); @@ -67,6 +65,12 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix case EntryAction.rename: _rename(context); break; + case EntryAction.copy: + _move(context, moveType: MoveType.copy); + break; + case EntryAction.move: + _move(context, moveType: MoveType.move); + break; case EntryAction.share: androidAppService.shareEntries({entry}).then((success) { if (!success) showNoMatchingAppDialog(context); @@ -188,11 +192,17 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix if (source.initialized) { await source.removeEntries({entry.uri}); } - EntryDeletedNotification(entry).dispatch(context); + EntryRemovedNotification(entry).dispatch(context); } } - Future _export(BuildContext context) async { + Future _convert(BuildContext context) async { + final options = await showDialog( + context: context, + builder: (context) => ExportEntryDialog(entry: entry), + ); + if (options == null) return; + final source = context.read(); if (!source.initialized) { await source.init(); @@ -204,12 +214,6 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix if (!await checkFreeSpaceForMove(context, {entry}, destinationAlbum, MoveType.export)) return; - final options = await showDialog( - context: context, - builder: (context) => ExportEntryDialog(entry: entry), - ); - if (options == null) return; - final selection = {}; if (entry.isMultiPage) { final multiPageInfo = await entry.getMultiPageInfo(); @@ -227,9 +231,8 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix final selectionCount = selection.length; source.pauseMonitoring(); - showOpReport( + await showOpReport( context: context, - // TODO TLAD [SVG] export separately from raster images (sending bytes, like frame captures) opStream: mediaFileService.export( selection, options: options, @@ -293,6 +296,15 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix ); } + Future _move(BuildContext context, {required MoveType moveType}) async { + await move( + context, + moveType: moveType, + selectedItems: {entry}, + onSuccess: moveType == MoveType.move ? () => EntryRemovedNotification(entry).dispatch(context) : null, + ); + } + Future _rename(BuildContext context) async { final newName = await showDialog( context: context, diff --git a/lib/widgets/viewer/entry_viewer_stack.dart b/lib/widgets/viewer/entry_viewer_stack.dart index 299ea5bf2..67cd51461 100644 --- a/lib/widgets/viewer/entry_viewer_stack.dart +++ b/lib/widgets/viewer/entry_viewer_stack.dart @@ -195,8 +195,8 @@ class _EntryViewerStackState extends State with FeedbackMixin, onNotification: (dynamic notification) { if (notification is FilterSelectedNotification) { _goToCollection(notification.filter); - } else if (notification is EntryDeletedNotification) { - _onEntryDeleted(context, notification.entry); + } else if (notification is EntryRemovedNotification) { + _onEntryRemoved(context, notification.entry); } return false; }, @@ -453,7 +453,8 @@ class _EntryViewerStackState extends State with FeedbackMixin, _updateEntry(); } - void _onEntryDeleted(BuildContext context, AvesEntry entry) { + void _onEntryRemoved(BuildContext context, AvesEntry entry) { + // deleted or moved to another album if (hasCollection) { final entries = collection!.sortedEntries; entries.remove(entry); diff --git a/lib/widgets/viewer/info/notifications.dart b/lib/widgets/viewer/info/notifications.dart index 1e97fafa8..e46ced611 100644 --- a/lib/widgets/viewer/info/notifications.dart +++ b/lib/widgets/viewer/info/notifications.dart @@ -12,8 +12,9 @@ class FilterSelectedNotification extends Notification { const FilterSelectedNotification(this.filter); } -class EntryDeletedNotification extends Notification { +// deleted or moved to another album +class EntryRemovedNotification extends Notification { final AvesEntry entry; - const EntryDeletedNotification(this.entry); + const EntryRemovedNotification(this.entry); } diff --git a/lib/widgets/viewer/overlay/top.dart b/lib/widgets/viewer/overlay/top.dart index 846f258d5..7fb39b04a 100644 --- a/lib/widgets/viewer/overlay/top.dart +++ b/lib/widgets/viewer/overlay/top.dart @@ -3,8 +3,10 @@ import 'package:aves/model/device.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/durations.dart'; +import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/basic/menu.dart'; import 'package:aves/widgets/common/basic/popup_menu_button.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/favourite_toggler.dart'; import 'package:aves/widgets/viewer/action/entry_action_delegate.dart'; import 'package:aves/widgets/viewer/multipage/conductor.dart'; @@ -13,6 +15,7 @@ import 'package:aves/widgets/viewer/overlay/minimap.dart'; import 'package:aves/widgets/viewer/overlay/notifications.dart'; import 'package:aves/widgets/viewer/page_entry_builder.dart'; import 'package:aves/widgets/viewer/visual/conductor.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; @@ -70,12 +73,14 @@ class ViewerTopOverlay extends StatelessWidget { return canToggleFavourite; case EntryAction.delete: case EntryAction.rename: + case EntryAction.copy: + case EntryAction.move: return targetEntry.canEdit; case EntryAction.rotateCCW: case EntryAction.rotateCW: case EntryAction.flip: return targetEntry.canRotateAndFlip; - case EntryAction.export: + case EntryAction.convert: case EntryAction.print: return !targetEntry.isVideo && device.canPrint; case EntryAction.openMap: @@ -88,7 +93,6 @@ class ViewerTopOverlay extends StatelessWidget { return device.canPinShortcut; case EntryAction.copyToClipboard: case EntryAction.edit: - case EntryAction.info: case EntryAction.open: case EntryAction.setAs: case EntryAction.share: @@ -102,12 +106,12 @@ class ViewerTopOverlay extends StatelessWidget { selector: (context, s) => s.isRotationLocked, builder: (context, s, child) { final quickActions = settings.viewerQuickActions.where(_isVisible).take(availableCount - 1).toList(); - final inAppActions = EntryActions.inApp.where((action) => !quickActions.contains(action)).where(_isVisible).toList(); - final externalAppActions = EntryActions.externalApp.where(_isVisible).toList(); + final topLevelActions = EntryActions.topLevel.where((action) => !quickActions.contains(action)).where(_isVisible).toList(); + final exportActions = EntryActions.export.where((action) => !quickActions.contains(action)).where(_isVisible).toList(); return _TopOverlayRow( quickActions: quickActions, - inAppActions: inAppActions, - externalAppActions: externalAppActions, + topLevelActions: topLevelActions, + exportActions: exportActions, scale: scale, mainEntry: mainEntry, pageEntry: pageEntry!, @@ -138,7 +142,7 @@ class ViewerTopOverlay extends StatelessWidget { } class _TopOverlayRow extends StatelessWidget { - final List quickActions, inAppActions, externalAppActions; + final List quickActions, topLevelActions, exportActions; final Animation scale; final AvesEntry mainEntry, pageEntry; @@ -147,8 +151,8 @@ class _TopOverlayRow extends StatelessWidget { const _TopOverlayRow({ Key? key, required this.quickActions, - required this.inAppActions, - required this.externalAppActions, + required this.topLevelActions, + required this.exportActions, required this.scale, required this.mainEntry, required this.pageEntry, @@ -169,16 +173,30 @@ class _TopOverlayRow extends StatelessWidget { child: MenuIconTheme( child: AvesPopupMenuButton( key: const Key('entry-menu-button'), - itemBuilder: (context) => [ - ...inAppActions.map((action) => _buildPopupMenuItem(context, action)), - if (pageEntry.canRotateAndFlip) _buildRotateAndFlipMenuItems(context), - const PopupMenuDivider(), - ...externalAppActions.map((action) => _buildPopupMenuItem(context, action)), - if (!kReleaseMode) ...[ - const PopupMenuDivider(), - _buildPopupMenuItem(context, EntryAction.debug), - ] - ], + itemBuilder: (context) { + final exportInternalActions = exportActions.whereNot(EntryActions.exportExternal.contains).toList(); + final exportExternalActions = exportActions.where(EntryActions.exportExternal.contains).toList(); + return [ + if (pageEntry.canRotateAndFlip) _buildRotateAndFlipMenuItems(context), + ...topLevelActions.map((action) => _buildPopupMenuItem(context, action)), + PopupMenuItem( + padding: EdgeInsets.zero, + child: PopupMenuItemExpansionPanel( + icon: AIcons.export, + title: context.l10n.entryActionExport, + items: [ + ...exportInternalActions.map((action) => _buildPopupMenuItem(context, action)).toList(), + if (exportInternalActions.isNotEmpty && exportExternalActions.isNotEmpty) const PopupMenuDivider(height: 0), + ...exportExternalActions.map((action) => _buildPopupMenuItem(context, action)).toList(), + ], + ), + ), + if (!kReleaseMode) ...[ + const PopupMenuDivider(), + _buildPopupMenuItem(context, EntryAction.debug), + ] + ]; + }, onSelected: (action) { // wait for the popup menu to hide before proceeding with the action Future.delayed(Durations.popupMenuAnimation * timeDilation, () => _onActionSelected(context, action)); @@ -206,44 +224,24 @@ class _TopOverlayRow extends StatelessWidget { onPressed: onPressed, ); break; - case EntryAction.addShortcut: - case EntryAction.copyToClipboard: - case EntryAction.delete: - case EntryAction.export: - case EntryAction.flip: - case EntryAction.info: - case EntryAction.print: - case EntryAction.rename: - case EntryAction.rotateCCW: - case EntryAction.rotateCW: - case EntryAction.share: - case EntryAction.rotateScreen: - case EntryAction.viewSource: + default: child = IconButton( icon: action.getIcon() ?? const SizedBox(), onPressed: onPressed, tooltip: action.getText(context), ); break; - case EntryAction.openMap: - case EntryAction.open: - case EntryAction.edit: - case EntryAction.setAs: - case EntryAction.debug: - break; } - return child != null - ? Padding( - padding: const EdgeInsetsDirectional.only(end: ViewerTopOverlay.innerPadding), - child: OverlayButton( - scale: scale, - child: child, - ), - ) - : const SizedBox.shrink(); + return Padding( + padding: const EdgeInsetsDirectional.only(end: ViewerTopOverlay.innerPadding), + child: OverlayButton( + scale: scale, + child: child, + ), + ); } - PopupMenuEntry _buildPopupMenuItem(BuildContext context, EntryAction action) { + PopupMenuItem _buildPopupMenuItem(BuildContext context, EntryAction action) { Widget? child; switch (action) { // in app actions diff --git a/untranslated.json b/untranslated.json index 7a627567e..f3738f5d2 100644 --- a/untranslated.json +++ b/untranslated.json @@ -1,5 +1,10 @@ { + "de": [ + "entryActionConvert" + ], + "es": [ + "entryActionConvert", "entryInfoActionEditLocation", "exportEntryDialogWidth", "exportEntryDialogHeight", @@ -8,5 +13,21 @@ "editEntryLocationDialogLatitude", "editEntryLocationDialogLongitude", "locationPickerUseThisLocationButton" + ], + + "fr": [ + "entryActionConvert" + ], + + "ko": [ + "entryActionConvert" + ], + + "pt": [ + "entryActionConvert" + ], + + "ru": [ + "entryActionConvert" ] }