This commit is contained in:
parent
a1c3399afa
commit
997005c4e5
25 changed files with 424 additions and 307 deletions
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,35 +442,70 @@ 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)
|
||||
// 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)) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
uri.userInfo != null -> return stripMediaUriUserInfo(uri)
|
||||
}
|
||||
}
|
||||
return uri
|
||||
}
|
||||
|
||||
// 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 {
|
||||
fun getMediaStoreScopedStorageSafeUri(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 ->
|
||||
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
|
||||
}
|
||||
}
|
||||
} else if (uri.userInfo != null) {
|
||||
// strip user info, if any
|
||||
return Uri.parse(uri.toString().replaceFirst("${uri.userInfo}@", ""))
|
||||
}
|
||||
|
||||
}
|
||||
return 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)
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": "즐겨찾기에서 삭제",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": "Удалить из избранного",
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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<ImageOpEvent>(
|
||||
await showOpReport<ImageOpEvent>(
|
||||
context: context,
|
||||
opStream: mediaFileService.delete(opId: opId, entries: selectedItems),
|
||||
itemCount: todoCount,
|
||||
|
@ -294,132 +289,10 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
|||
}
|
||||
|
||||
Future<void> _move(BuildContext context, {required MoveType moveType}) async {
|
||||
final l10n = context.l10n;
|
||||
final selection = context.read<Selection<AvesEntry>>();
|
||||
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<NameConflictStrategy>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AvesSelectionDialog<NameConflictStrategy>(
|
||||
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<CollectionSource>();
|
||||
source.pauseMonitoring();
|
||||
final opId = mediaFileService.newOpId;
|
||||
showOpReport<MoveOpEvent>(
|
||||
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,
|
||||
);
|
||||
await move(context, moveType: moveType, selectedItems: selectedItems);
|
||||
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<HighlightInfo>();
|
||||
final collection = context.read<CollectionLens>();
|
||||
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<DurationsData>().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,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _edit(
|
||||
|
@ -439,7 +312,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
|||
final source = context.read<CollectionSource>();
|
||||
source.pauseMonitoring();
|
||||
var cancelled = false;
|
||||
showOpReport<ImageOpEvent>(
|
||||
await showOpReport<ImageOpEvent>(
|
||||
context: context,
|
||||
opStream: Stream.fromIterable(todoItems).asyncMap((entry) async {
|
||||
if (cancelled) {
|
||||
|
|
173
lib/widgets/common/action_mixins/entry_storage.dart
Normal file
173
lib/widgets/common/action_mixins/entry_storage.dart
Normal file
|
@ -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<void> move(
|
||||
BuildContext context, {
|
||||
required MoveType moveType,
|
||||
required Set<AvesEntry> selectedItems,
|
||||
VoidCallback? onSuccess,
|
||||
}) async {
|
||||
final source = context.read<CollectionSource>();
|
||||
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<NameConflictStrategy>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AvesSelectionDialog<NameConflictStrategy>(
|
||||
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<MoveOpEvent>(
|
||||
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<ValueNotifier<AppMode>>().value;
|
||||
|
||||
SnackBarAction? action;
|
||||
if (count > 0 && appMode == AppMode.main) {
|
||||
action = SnackBarAction(
|
||||
label: l10n.showButtonLabel,
|
||||
onPressed: () async {
|
||||
late CollectionLens targetCollection;
|
||||
|
||||
final highlightInfo = context.read<HighlightInfo>();
|
||||
final collection = context.read<CollectionLens?>();
|
||||
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<DurationsData>().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();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
|
@ -69,14 +69,14 @@ mixin FeedbackMixin {
|
|||
|
||||
// report overlay for multiple operations
|
||||
|
||||
void showOpReport<T>({
|
||||
Future<void> showOpReport<T>({
|
||||
required BuildContext context,
|
||||
required Stream<T> opStream,
|
||||
required int itemCount,
|
||||
VoidCallback? onCancel,
|
||||
void Function(Set<T> processed)? onDone,
|
||||
}) {
|
||||
showDialog(
|
||||
return showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => ReportOverlay<T>(
|
||||
|
|
|
@ -52,7 +52,7 @@ class PopupMenuItemExpansionPanel<T> extends StatefulWidget {
|
|||
final bool enabled;
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final List<PopupMenuItem<T>> items;
|
||||
final List<PopupMenuEntry<T>> items;
|
||||
|
||||
const PopupMenuItemExpansionPanel({
|
||||
Key? key,
|
||||
|
|
|
@ -217,7 +217,7 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> {
|
|||
|
||||
source.pauseMonitoring();
|
||||
final opId = mediaFileService.newOpId;
|
||||
showOpReport<ImageOpEvent>(
|
||||
await showOpReport<ImageOpEvent>(
|
||||
context: context,
|
||||
opStream: mediaFileService.delete(opId: opId, entries: todoEntries),
|
||||
itemCount: todoCount,
|
||||
|
@ -281,7 +281,7 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> {
|
|||
|
||||
source.pauseMonitoring();
|
||||
final opId = mediaFileService.newOpId;
|
||||
showOpReport<MoveOpEvent>(
|
||||
await showOpReport<MoveOpEvent>(
|
||||
context: context,
|
||||
opStream: mediaFileService.move(
|
||||
opId: opId,
|
||||
|
|
|
@ -54,11 +54,11 @@ class _SettingsPageState extends State<SettingsPage> 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)),
|
||||
),
|
||||
];
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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<void> _export(BuildContext context) async {
|
||||
Future<void> _convert(BuildContext context) async {
|
||||
final options = await showDialog<EntryExportOptions>(
|
||||
context: context,
|
||||
builder: (context) => ExportEntryDialog(entry: entry),
|
||||
);
|
||||
if (options == null) return;
|
||||
|
||||
final source = context.read<CollectionSource>();
|
||||
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<EntryExportOptions>(
|
||||
context: context,
|
||||
builder: (context) => ExportEntryDialog(entry: entry),
|
||||
);
|
||||
if (options == null) return;
|
||||
|
||||
final selection = <AvesEntry>{};
|
||||
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<ExportOpEvent>(
|
||||
await showOpReport<ExportOpEvent>(
|
||||
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<void> _move(BuildContext context, {required MoveType moveType}) async {
|
||||
await move(
|
||||
context,
|
||||
moveType: moveType,
|
||||
selectedItems: {entry},
|
||||
onSuccess: moveType == MoveType.move ? () => EntryRemovedNotification(entry).dispatch(context) : null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _rename(BuildContext context) async {
|
||||
final newName = await showDialog<String>(
|
||||
context: context,
|
||||
|
|
|
@ -195,8 +195,8 @@ class _EntryViewerStackState extends State<EntryViewerStack> 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<EntryViewerStack> 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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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<EntryAction> quickActions, inAppActions, externalAppActions;
|
||||
final List<EntryAction> quickActions, topLevelActions, exportActions;
|
||||
final Animation<double> 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<EntryAction>(
|
||||
key: const Key('entry-menu-button'),
|
||||
itemBuilder: (context) => [
|
||||
...inAppActions.map((action) => _buildPopupMenuItem(context, action)),
|
||||
itemBuilder: (context) {
|
||||
final exportInternalActions = exportActions.whereNot(EntryActions.exportExternal.contains).toList();
|
||||
final exportExternalActions = exportActions.where(EntryActions.exportExternal.contains).toList();
|
||||
return [
|
||||
if (pageEntry.canRotateAndFlip) _buildRotateAndFlipMenuItems(context),
|
||||
const PopupMenuDivider(),
|
||||
...externalAppActions.map((action) => _buildPopupMenuItem(context, action)),
|
||||
...topLevelActions.map((action) => _buildPopupMenuItem(context, action)),
|
||||
PopupMenuItem<EntryAction>(
|
||||
padding: EdgeInsets.zero,
|
||||
child: PopupMenuItemExpansionPanel<EntryAction>(
|
||||
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(
|
||||
return Padding(
|
||||
padding: const EdgeInsetsDirectional.only(end: ViewerTopOverlay.innerPadding),
|
||||
child: OverlayButton(
|
||||
scale: scale,
|
||||
child: child,
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink();
|
||||
);
|
||||
}
|
||||
|
||||
PopupMenuEntry<EntryAction> _buildPopupMenuItem(BuildContext context, EntryAction action) {
|
||||
PopupMenuItem<EntryAction> _buildPopupMenuItem(BuildContext context, EntryAction action) {
|
||||
Widget? child;
|
||||
switch (action) {
|
||||
// in app actions
|
||||
|
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue