diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/AnalysisService.kt b/android/app/src/main/kotlin/deckers/thibault/aves/AnalysisService.kt index 409f361bd..01752c888 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/AnalysisService.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/AnalysisService.kt @@ -159,10 +159,10 @@ class AnalysisService : MethodChannel.MethodCallHandler, Service() { COMMAND_START -> { runBlocking { FlutterUtils.runOnUiThread { - val contentIds = data.get(KEY_CONTENT_IDS)?.takeIf { it is IntArray }?.let { (it as IntArray).toList() } + val entryIds = data.get(KEY_ENTRY_IDS)?.takeIf { it is IntArray }?.let { (it as IntArray).toList() } backgroundChannel?.invokeMethod( "start", hashMapOf( - "contentIds" to contentIds, + "entryIds" to entryIds, "force" to data.getBoolean(KEY_FORCE), ) ) @@ -197,7 +197,7 @@ class AnalysisService : MethodChannel.MethodCallHandler, Service() { const val KEY_COMMAND = "command" const val COMMAND_START = "start" const val COMMAND_STOP = "stop" - const val KEY_CONTENT_IDS = "content_ids" + const val KEY_ENTRY_IDS = "entry_ids" const val KEY_FORCE = "force" } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AnalysisHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AnalysisHandler.kt index b0d144906..b32eda29b 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AnalysisHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AnalysisHandler.kt @@ -52,12 +52,12 @@ class AnalysisHandler(private val activity: Activity, private val onAnalysisComp } // can be null or empty - val contentIds = call.argument>("contentIds") + val entryIds = call.argument>("entryIds") if (!activity.isMyServiceRunning(AnalysisService::class.java)) { val intent = Intent(activity, AnalysisService::class.java) intent.putExtra(AnalysisService.KEY_COMMAND, AnalysisService.COMMAND_START) - intent.putExtra(AnalysisService.KEY_CONTENT_IDS, contentIds?.toIntArray()) + intent.putExtra(AnalysisService.KEY_ENTRY_IDS, entryIds?.toIntArray()) intent.putExtra(AnalysisService.KEY_FORCE, force) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { activity.startForegroundService(intent) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt index 29f30d522..03a492a12 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt @@ -69,6 +69,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler { "filesDir" to context.filesDir, "obbDir" to context.obbDir, "externalCacheDir" to context.externalCacheDir, + "externalFilesDir" to context.getExternalFilesDir(null), ).apply { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { putAll( @@ -82,6 +83,8 @@ class DebugHandler(private val context: Context) : MethodCallHandler { put("dataDir", context.dataDir) } }.mapValues { it.value?.path }.toMutableMap() + dirs["externalCacheDirs"] = context.externalCacheDirs.joinToString { it.path } + dirs["externalFilesDirs"] = context.getExternalFilesDirs(null).joinToString { it.path } // used by flutter plugin `path_provider` dirs.putAll( diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaStoreHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaStoreHandler.kt index b20b6b802..a8761be8f 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaStoreHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaStoreHandler.kt @@ -8,22 +8,25 @@ import deckers.thibault.aves.model.provider.MediaStoreImageProvider import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch class MediaStoreHandler(private val context: Context) : MethodCallHandler { + private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { - "checkObsoleteContentIds" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::checkObsoleteContentIds) } - "checkObsoletePaths" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::checkObsoletePaths) } - "scanFile" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::scanFile) } + "checkObsoleteContentIds" -> ioScope.launch { safe(call, result, ::checkObsoleteContentIds) } + "checkObsoletePaths" -> ioScope.launch { safe(call, result, ::checkObsoletePaths) } + "scanFile" -> ioScope.launch { safe(call, result, ::scanFile) } else -> result.notImplemented() } } private fun checkObsoleteContentIds(call: MethodCall, result: MethodChannel.Result) { - val knownContentIds = call.argument>("knownContentIds") + val knownContentIds = call.argument>("knownContentIds") if (knownContentIds == null) { result.error("checkObsoleteContentIds-args", "failed because of missing arguments", null) return @@ -32,7 +35,7 @@ class MediaStoreHandler(private val context: Context) : MethodCallHandler { } private fun checkObsoletePaths(call: MethodCall, result: MethodChannel.Result) { - val knownPathById = call.argument>("knownPathById") + val knownPathById = call.argument>("knownPathById") if (knownPathById == null) { result.error("checkObsoletePaths-args", "failed because of missing arguments", null) return diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt index c8bb7a1db..9fc6adbc6 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt @@ -13,22 +13,24 @@ import deckers.thibault.aves.utils.StorageUtils.getVolumePaths import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import java.io.File -import java.util.* class StorageHandler(private val context: Context) : MethodCallHandler { + private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { - "getStorageVolumes" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getStorageVolumes) } - "getFreeSpace" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getFreeSpace) } - "getGrantedDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getGrantedDirectories) } - "getInaccessibleDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getInaccessibleDirectories) } - "getRestrictedDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getRestrictedDirectories) } + "getStorageVolumes" -> ioScope.launch { safe(call, result, ::getStorageVolumes) } + "getFreeSpace" -> ioScope.launch { safe(call, result, ::getFreeSpace) } + "getGrantedDirectories" -> ioScope.launch { safe(call, result, ::getGrantedDirectories) } + "getInaccessibleDirectories" -> ioScope.launch { safe(call, result, ::getInaccessibleDirectories) } + "getRestrictedDirectories" -> ioScope.launch { safe(call, result, ::getRestrictedDirectories) } "revokeDirectoryAccess" -> safe(call, result, ::revokeDirectoryAccess) - "deleteEmptyDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::deleteEmptyDirectories) } + "deleteEmptyDirectories" -> ioScope.launch { safe(call, result, ::deleteEmptyDirectories) } "canRequestMediaFileBulkAccess" -> safe(call, result, ::canRequestMediaFileBulkAccess) "canInsertMedia" -> safe(call, result, ::canInsertMedia) else -> result.notImplemented() 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 be9b244e6..7e58f4ed0 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 @@ -23,12 +23,11 @@ import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide import deckers.thibault.aves.utils.StorageUtils import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel.EventSink -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch +import kotlinx.coroutines.* import java.io.InputStream class ImageByteStreamHandler(private val context: Context, private val arguments: Any?) : EventChannel.StreamHandler { + private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private lateinit var eventSink: EventSink private lateinit var handler: Handler @@ -36,7 +35,7 @@ class ImageByteStreamHandler(private val context: Context, private val arguments this.eventSink = eventSink handler = Handler(Looper.getMainLooper()) - GlobalScope.launch(Dispatchers.IO) { streamImage() } + ioScope.launch { streamImage() } } override fun onCancel(o: Any) {} diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt index a574fb74b..6d8b1a176 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt @@ -11,16 +11,19 @@ import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.NameConflictStrategy import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider +import deckers.thibault.aves.model.provider.MediaStoreImageProvider import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.StorageUtils import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel.EventSink +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch -import java.util.* +import java.io.File class ImageOpStreamHandler(private val activity: Activity, private val arguments: Any?) : EventChannel.StreamHandler { + private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private lateinit var eventSink: EventSink private lateinit var handler: Handler @@ -45,10 +48,10 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments handler = Handler(Looper.getMainLooper()) when (op) { - "delete" -> GlobalScope.launch(Dispatchers.IO) { delete() } - "export" -> GlobalScope.launch(Dispatchers.IO) { export() } - "move" -> GlobalScope.launch(Dispatchers.IO) { move() } - "rename" -> GlobalScope.launch(Dispatchers.IO) { rename() } + "delete" -> ioScope.launch { delete() } + "export" -> ioScope.launch { export() } + "move" -> ioScope.launch { move() } + "rename" -> ioScope.launch { rename() } else -> endOfStream() } } @@ -103,12 +106,16 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments val entries = entryMapList.map(::AvesEntry) for (entry in entries) { - val uri = entry.uri - val path = entry.path val mimeType = entry.mimeType + val trashed = entry.trashed + + val uri = if (trashed) Uri.fromFile(File(entry.trashPath!!)) else entry.uri + val path = if (trashed) entry.trashPath else entry.path val result: FieldMap = hashMapOf( - "uri" to uri.toString(), + // `uri` should reference original content URI, + // so it is different with `sourceUri` when deleting trashed entries + "uri" to entry.uri.toString(), ) if (isCancelledOp()) { result["skipped"] = true @@ -160,30 +167,34 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments } private suspend fun move() { - if (arguments !is Map<*, *> || entryMapList.isEmpty()) { + if (arguments !is Map<*, *>) { endOfStream() return } val copy = arguments["copy"] as Boolean? - var destinationDir = arguments["destinationPath"] as String? val nameConflictStrategy = NameConflictStrategy.get(arguments["nameConflictStrategy"] as String?) - if (copy == null || destinationDir == null || nameConflictStrategy == null) { + val rawEntryMap = arguments["entriesByDestination"] as Map<*, *>? + if (copy == null || nameConflictStrategy == null || rawEntryMap == null || rawEntryMap.isEmpty()) { error("move-args", "failed because of missing arguments", null) return } - // assume same provider for all entries - val firstEntry = entryMapList.first() - val provider = (firstEntry["uri"] as String?)?.let { Uri.parse(it) }?.let { getProvider(it) } - if (provider == null) { - error("move-provider", "failed to find provider for entry=$firstEntry", null) - return + val entriesByTargetDir = HashMap>() + rawEntryMap.forEach { + var destinationDir = it.key as String + if (destinationDir != StorageUtils.TRASH_PATH_PLACEHOLDER) { + destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir) + } + @Suppress("unchecked_cast") + val rawEntries = it.value as List + entriesByTargetDir[destinationDir] = rawEntries.map(::AvesEntry) } - destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir) - val entries = entryMapList.map(::AvesEntry) - provider.moveMultiple(activity, copy, destinationDir, nameConflictStrategy, entries, ::isCancelledOp, object : ImageOpCallback { + // always use Media Store (as we move from or to it) + val provider = MediaStoreImageProvider() + + provider.moveMultiple(activity, copy, nameConflictStrategy, entriesByTargetDir, ::isCancelledOp, object : ImageOpCallback { override fun onSuccess(fields: FieldMap) = success(fields) override fun onFailure(throwable: Throwable) = error("move-failure", "failed to move entries", throwable) }) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.kt index f90e5971b..8edd93670 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.kt @@ -9,20 +9,22 @@ import deckers.thibault.aves.model.provider.MediaStoreImageProvider import deckers.thibault.aves.utils.LogUtils import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel.EventSink +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : EventChannel.StreamHandler { + private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private lateinit var eventSink: EventSink private lateinit var handler: Handler - private var knownEntries: Map? = null + private var knownEntries: Map? = null init { if (arguments is Map<*, *>) { @Suppress("unchecked_cast") - knownEntries = arguments["knownEntries"] as Map? + knownEntries = arguments["knownEntries"] as Map? } } @@ -30,7 +32,7 @@ class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : E this.eventSink = eventSink handler = Handler(Looper.getMainLooper()) - GlobalScope.launch(Dispatchers.IO) { fetchAll() } + ioScope.launch { fetchAll() } } override fun onCancel(arguments: Any?) {} diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt index 6d134fae4..083f7b926 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt @@ -15,14 +15,13 @@ import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.PermissionManager import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel.EventSink -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch +import kotlinx.coroutines.* import java.io.FileOutputStream // starting activity to give access with the native dialog // breaks the regular `MethodChannel` so we use a stream channel instead class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?) : EventChannel.StreamHandler { + private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private lateinit var eventSink: EventSink private lateinit var handler: Handler @@ -41,10 +40,10 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any? handler = Handler(Looper.getMainLooper()) when (op) { - "requestDirectoryAccess" -> GlobalScope.launch(Dispatchers.IO) { requestDirectoryAccess() } - "requestMediaFileAccess" -> GlobalScope.launch(Dispatchers.IO) { requestMediaFileAccess() } - "createFile" -> GlobalScope.launch(Dispatchers.IO) { createFile() } - "openFile" -> GlobalScope.launch(Dispatchers.IO) { openFile() } + "requestDirectoryAccess" -> ioScope.launch { requestDirectoryAccess() } + "requestMediaFileAccess" -> ioScope.launch { requestMediaFileAccess() } + "createFile" -> ioScope.launch { createFile() } + "openFile" -> ioScope.launch { openFile() } else -> endOfStream() } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/AvesEntry.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/AvesEntry.kt index ab6f58ce9..54aded97e 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/AvesEntry.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/AvesEntry.kt @@ -11,4 +11,6 @@ class AvesEntry(map: FieldMap) { val height = map["height"] as Int val rotationDegrees = map["rotationDegrees"] as Int val isFlipped = map["isFlipped"] as Boolean + val trashed = map["trashed"] as Boolean + val trashPath = map["trashPath"] as String? } \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/FileImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/FileImageProvider.kt index eb0eb3138..7a6cfae94 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/FileImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/FileImageProvider.kt @@ -1,8 +1,11 @@ package deckers.thibault.aves.model.provider +import android.app.Activity import android.content.Context import android.net.Uri +import android.util.Log import deckers.thibault.aves.model.SourceEntry +import deckers.thibault.aves.utils.LogUtils import java.io.File internal class FileImageProvider : ImageProvider() { @@ -33,4 +36,18 @@ internal class FileImageProvider : ImageProvider() { callback.onFailure(Exception("entry has no size")) } } + + override suspend fun delete(activity: Activity, uri: Uri, path: String?, mimeType: String) { + val file = File(File(uri.path!!).path) + if (!file.exists()) return + + Log.d(LOG_TAG, "delete file at uri=$uri") + if (file.delete()) return + + throw Exception("failed to delete entry with uri=$uri path=$path") + } + + companion object { + private val LOG_TAG = LogUtils.createTag() + } } \ No newline at end of file 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 5659f8a5d..43e648b34 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 @@ -39,7 +39,6 @@ import java.io.File import java.io.IOException import java.io.OutputStream import java.util.* -import kotlin.collections.HashMap abstract class ImageProvider { open fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) { @@ -53,9 +52,8 @@ abstract class ImageProvider { open suspend fun moveMultiple( activity: Activity, copy: Boolean, - targetDir: String, nameConflictStrategy: NameConflictStrategy, - entries: List, + entriesByTargetDir: Map>, isCancelledOp: CancelCheck, callback: ImageOpCallback, ) { @@ -245,7 +243,6 @@ abstract class ImageProvider { // clearing Glide target should happen after effectively writing the bitmap Glide.with(activity).clear(target) } - } @Suppress("BlockingMethodInNonBlockingContext") diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt index 4ada34d6f..322672824 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt @@ -3,6 +3,7 @@ package deckers.thibault.aves.model.provider import android.annotation.SuppressLint import android.app.Activity import android.app.RecoverableSecurityException +import android.content.ContentResolver import android.content.ContentUris import android.content.ContentValues import android.content.Context @@ -31,13 +32,12 @@ import java.io.File import java.io.OutputStream import java.util.* import java.util.concurrent.CompletableFuture -import kotlin.collections.ArrayList import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine class MediaStoreImageProvider : ImageProvider() { - fun fetchAll(context: Context, knownEntries: Map, handleNewEntry: NewEntryHandler) { + fun fetchAll(context: Context, knownEntries: Map, handleNewEntry: NewEntryHandler) { val isModified = fun(contentId: Int, dateModifiedSecs: Int): Boolean { val knownDate = knownEntries[contentId] return knownDate == null || knownDate < dateModifiedSecs @@ -83,7 +83,7 @@ class MediaStoreImageProvider : ImageProvider() { } } - fun checkObsoleteContentIds(context: Context, knownContentIds: List): List { + fun checkObsoleteContentIds(context: Context, knownContentIds: List): List { val foundContentIds = HashSet() fun check(context: Context, contentUri: Uri) { val projection = arrayOf(MediaStore.MediaColumns._ID) @@ -102,10 +102,10 @@ class MediaStoreImageProvider : ImageProvider() { } check(context, IMAGE_CONTENT_URI) check(context, VIDEO_CONTENT_URI) - return knownContentIds.subtract(foundContentIds).toList() + return knownContentIds.subtract(foundContentIds).filterNotNull().toList() } - fun checkObsoletePaths(context: Context, knownPathById: Map): List { + fun checkObsoletePaths(context: Context, knownPathById: Map): List { val obsoleteIds = ArrayList() fun check(context: Context, contentUri: Uri) { val projection = arrayOf(MediaStore.MediaColumns._ID, MediaColumns.PATH) @@ -291,6 +291,10 @@ class MediaStoreImageProvider : ImageProvider() { } throw Exception("failed to delete document with df=$df") } + } else if (uri.scheme?.lowercase(Locale.ROOT) == ContentResolver.SCHEME_FILE) { + val uriFilePath = File(uri.path!!).path + // URI and path both point to the same non existent path + if (uriFilePath == path) return } try { @@ -329,84 +333,119 @@ class MediaStoreImageProvider : ImageProvider() { override suspend fun moveMultiple( activity: Activity, copy: Boolean, - targetDir: String, nameConflictStrategy: NameConflictStrategy, - entries: List, + entriesByTargetDir: Map>, isCancelledOp: CancelCheck, callback: ImageOpCallback, ) { - val targetDirDocFile = StorageUtils.createDirectoryDocIfAbsent(activity, targetDir) - if (!File(targetDir).exists()) { - callback.onFailure(Exception("failed to create directory at path=$targetDir")) - return - } + entriesByTargetDir.forEach { kv -> + val targetDir = kv.key + val entries = kv.value - for (entry in entries) { - val sourceUri = entry.uri - val sourcePath = entry.path - val mimeType = entry.mimeType + val toBin = targetDir == StorageUtils.TRASH_PATH_PLACEHOLDER - val result: FieldMap = hashMapOf( - "uri" to sourceUri.toString(), - "success" to false, - ) - - if (sourcePath != null) { - // on API 30 we cannot get access granted directly to a volume root from its document tree, - // but it is still less constraining to use tree document files than to rely on the Media Store - // - // Relying on `DocumentFile`, we can create an item via `DocumentFile.createFile()`, but: - // - we need to scan the file to get the Media Store content URI - // - the underlying document provider controls the new file name - // - // Relying on the Media Store, we can create an item via `ContentResolver.insert()` - // with a path, and retrieve its content URI, but: - // - the Media Store isolates content by storage volume (e.g. `MediaStore.Images.Media.getContentUri(volumeName)`) - // - the volume name should be lower case, not exactly as the `StorageVolume` UUID - // cf new method in API 30 `StorageVolume.getMediaStoreVolumeName()` - // - inserting on a removable volume works on API 29, but not on API 25 nor 26 (on which API/devices does it work?) - // - there is no documentation regarding support for usage with removable storage - // - the Media Store only allows inserting in specific primary directories ("DCIM", "Pictures") when using scoped storage - try { - val newFields = if (isCancelledOp()) skippedFieldMap else moveSingle( - activity = activity, - sourcePath = sourcePath, - sourceUri = sourceUri, - targetDir = targetDir, - targetDirDocFile = targetDirDocFile, - nameConflictStrategy = nameConflictStrategy, - mimeType = mimeType, - copy = copy, - ) - result["newFields"] = newFields - result["success"] = true - } catch (e: Exception) { - Log.w(LOG_TAG, "failed to move to targetDir=$targetDir entry with sourcePath=$sourcePath", e) + var effectiveTargetDir: String? = null + var targetDirDocFile: DocumentFileCompat? = null + if (!toBin) { + effectiveTargetDir = targetDir + targetDirDocFile = StorageUtils.createDirectoryDocIfAbsent(activity, targetDir) + if (!File(targetDir).exists()) { + callback.onFailure(Exception("failed to create directory at path=$targetDir")) + return } } - callback.onSuccess(result) + + for (entry in entries) { + val mimeType = entry.mimeType + val trashed = entry.trashed + + val sourceUri = if (trashed) Uri.fromFile(File(entry.trashPath!!)) else entry.uri + val sourcePath = if (trashed) entry.trashPath else entry.path + + var desiredName: String? = null + if (trashed) { + entry.path?.let { desiredName = File(it).name } + } + + val result: FieldMap = hashMapOf( + // `uri` should reference original content URI, + // so it is different with `sourceUri` when recycling trashed entries + "uri" to entry.uri.toString(), + "success" to false, + ) + + if (sourcePath != null) { + // on API 30 we cannot get access granted directly to a volume root from its document tree, + // but it is still less constraining to use tree document files than to rely on the Media Store + // + // Relying on `DocumentFile`, we can create an item via `DocumentFile.createFile()`, but: + // - we need to scan the file to get the Media Store content URI + // - the underlying document provider controls the new file name + // + // Relying on the Media Store, we can create an item via `ContentResolver.insert()` + // with a path, and retrieve its content URI, but: + // - the Media Store isolates content by storage volume (e.g. `MediaStore.Images.Media.getContentUri(volumeName)`) + // - the volume name should be lower case, not exactly as the `StorageVolume` UUID + // cf new method in API 30 `StorageVolume.getMediaStoreVolumeName()` + // - inserting on a removable volume works on API 29, but not on API 25 nor 26 (on which API/devices does it work?) + // - there is no documentation regarding support for usage with removable storage + // - the Media Store only allows inserting in specific primary directories ("DCIM", "Pictures") when using scoped storage + try { + if (toBin) { + val trashDir = StorageUtils.trashDirFor(activity, sourcePath) + if (trashDir != null) { + effectiveTargetDir = StorageUtils.ensureTrailingSeparator(trashDir.path) + targetDirDocFile = DocumentFileCompat.fromFile(trashDir) + } + } + if (effectiveTargetDir != null) { + val newFields = if (isCancelledOp()) skippedFieldMap else { + val sourceFile = File(sourcePath) + moveSingle( + activity = activity, + sourceFile = sourceFile, + sourceUri = sourceUri, + targetDir = effectiveTargetDir, + targetDirDocFile = targetDirDocFile, + desiredName = desiredName ?: sourceFile.name, + nameConflictStrategy = nameConflictStrategy, + mimeType = mimeType, + copy = copy, + toBin = toBin, + ) + } + result["newFields"] = newFields + result["success"] = true + } + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to move to targetDir=$targetDir entry with sourcePath=$sourcePath", e) + } + } + callback.onSuccess(result) + } } } private suspend fun moveSingle( activity: Activity, - sourcePath: String, + sourceFile: File, sourceUri: Uri, targetDir: String, targetDirDocFile: DocumentFileCompat?, + desiredName: String, nameConflictStrategy: NameConflictStrategy, mimeType: String, copy: Boolean, + toBin: Boolean, ): FieldMap { - val sourceFile = File(sourcePath) + val sourcePath = sourceFile.path val sourceDir = sourceFile.parent?.let { StorageUtils.ensureTrailingSeparator(it) } if (sourceDir == targetDir && !(copy && nameConflictStrategy == NameConflictStrategy.RENAME)) { // nothing to do unless it's a renamed copy return skippedFieldMap } - val sourceFileName = sourceFile.name - val desiredNameWithoutExtension = sourceFileName.replaceFirst(FILE_EXTENSION_PATTERN, "") + val desiredNameWithoutExtension = desiredName.replaceFirst(FILE_EXTENSION_PATTERN, "") val targetNameWithoutExtension = resolveTargetFileNameWithoutExtension( activity = activity, dir = targetDir, @@ -432,7 +471,12 @@ class MediaStoreImageProvider : ImageProvider() { Log.w(LOG_TAG, "failed to delete entry with path=$sourcePath", e) } } - + if (toBin) { + return hashMapOf( + "trashed" to true, + "trashPath" to targetPath, + ) + } return scanNewPath(activity, targetPath, mimeType) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt index 02eeaf485..1e1a70203 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt @@ -18,9 +18,7 @@ import deckers.thibault.aves.PendingStorageAccessResultHandler import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.utils.StorageUtils.PathSegments import java.io.File -import java.util.* import java.util.concurrent.CompletableFuture -import kotlin.collections.ArrayList object PermissionManager { private val LOG_TAG = LogUtils.createTag() @@ -94,11 +92,12 @@ object PermissionManager { } fun getInaccessibleDirectories(context: Context, dirPaths: List): List> { + val concreteDirPaths = dirPaths.filter { it != StorageUtils.TRASH_PATH_PLACEHOLDER } val accessibleDirs = getAccessibleDirs(context) // find set of inaccessible directories for each volume val dirsPerVolume = HashMap>() - for (dirPath in dirPaths.map { if (it.endsWith(File.separator)) it else it + File.separator }) { + for (dirPath in concreteDirPaths.map { if (it.endsWith(File.separator)) it else it + File.separator }) { if (accessibleDirs.none { dirPath.startsWith(it) }) { // inaccessible dirs val segments = PathSegments(context, dirPath) @@ -211,7 +210,8 @@ object PermissionManager { ) }) } else if (Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT - || Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT_WATCH) { + || Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT_WATCH + ) { // removable storage requires access permission, at the file level // without directory access, we consider the whole volume restricted val primaryVolume = StorageUtils.getPrimaryVolumePath(context) 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 1164ef228..4ddb4ef08 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 @@ -33,6 +33,32 @@ object StorageUtils { private const val TREE_URI_ROOT = "content://com.android.externalstorage.documents/tree/" private val TREE_URI_PATH_PATTERN = Pattern.compile("(.*?):(.*)") + const val TRASH_PATH_PLACEHOLDER = "#trash" + + private fun isAppFile(context: Context, path: String): Boolean { + return context.getExternalFilesDirs(null).any { filesDir -> path.startsWith(filesDir.path) } + } + + private fun appExternalFilesDirFor(context: Context, path: String): File? { + val filesDirs = context.getExternalFilesDirs(null) + val volumePath = getVolumePath(context, path) + return volumePath?.let { filesDirs.firstOrNull { it.startsWith(volumePath) } } ?: filesDirs.first() + } + + fun trashDirFor(context: Context, path: String): File? { + val filesDir = appExternalFilesDirFor(context, path) + if (filesDir == null) { + Log.e(LOG_TAG, "failed to find external files dir for path=$path") + return null + } + val trashDir = File(filesDir, "trash") + if (!trashDir.exists() && !trashDir.mkdirs()) { + Log.e(LOG_TAG, "failed to create directories at path=$trashDir") + return null + } + return trashDir + } + /** * Volume paths */ @@ -408,10 +434,11 @@ object StorageUtils { fun canEditByFile(context: Context, path: String) = !requireAccessPermission(context, path) fun requireAccessPermission(context: Context, anyPath: String): Boolean { + if (isAppFile(context, anyPath)) return false + // on Android R, we should always require access permission, even on primary volume - if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) { - return true - } + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) return true + val onPrimaryVolume = anyPath.startsWith(getPrimaryVolumePath(context)) return !onPrimaryVolume } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 60a5d6341..8399fc495 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -22,6 +22,12 @@ "minutes": {} } }, + "timeDays": "{days, plural, =1{1 day} other{{days} days}}", + "@timeDays": { + "placeholders": { + "days": {} + } + }, "focalLength": "{length} mm", "@focalLength": { "placeholders": { @@ -72,6 +78,7 @@ "entryActionConvert": "Convert", "entryActionExport": "Export", "entryActionRename": "Rename", + "entryActionRestore": "Restore", "entryActionRotateCCW": "Rotate counterclockwise", "entryActionRotateCW": "Rotate clockwise", "entryActionFlip": "Flip horizontally", @@ -254,6 +261,12 @@ "noMatchingAppDialogTitle": "No Matching App", "noMatchingAppDialogMessage": "There are no apps that can handle this.", + "binEntriesConfirmationDialogMessage": "{count, plural, =1{Are you sure you want to move this item to the recycle bin?} other{Are you sure you want to move these {count} items to the recycle bin?}}", + "@binEntriesConfirmationDialogMessage": { + "placeholders": { + "count": {} + } + }, "deleteEntriesConfirmationDialogMessage": "{count, plural, =1{Are you sure you want to delete this item?} other{Are you sure you want to delete these {count} items?}}", "@deleteEntriesConfirmationDialogMessage": { "placeholders": { @@ -409,6 +422,7 @@ "collectionActionShowTitleSearch": "Show title filter", "collectionActionHideTitleSearch": "Hide title filter", "collectionActionAddShortcut": "Add shortcut", + "collectionActionEmptyBin": "Empty bin", "collectionActionCopy": "Copy to album", "collectionActionMove": "Move to album", "collectionActionRescan": "Rescan", @@ -527,6 +541,8 @@ "tagPageTitle": "Tags", "tagEmpty": "No tags", + "binPageTitle": "Recycle Bin", + "searchCollectionFieldHint": "Search collection", "searchSectionRecent": "Recent", "searchSectionAlbums": "Albums", @@ -627,6 +643,8 @@ "settingsAllowInstalledAppAccessSubtitle": "Used to improve album display", "settingsAllowErrorReporting": "Allow anonymous error reporting", "settingsSaveSearchHistory": "Save search history", + "settingsEnableBin": "Use recycle bin", + "settingsEnableBinSubtitle": "Keep deleted items for 30 days", "settingsHiddenItemsTile": "Hidden items", "settingsHiddenItemsTitle": "Hidden Items", diff --git a/lib/model/actions/entry_actions.dart b/lib/model/actions/entry_actions.dart index 4c085b1f7..e55748510 100644 --- a/lib/model/actions/entry_actions.dart +++ b/lib/model/actions/entry_actions.dart @@ -7,6 +7,7 @@ enum EntryAction { addShortcut, copyToClipboard, delete, + restore, convert, print, rename, @@ -65,6 +66,12 @@ class EntryActions { EntryAction.rotateCW, EntryAction.flip, ]; + + static const trashed = [ + EntryAction.delete, + EntryAction.restore, + EntryAction.debug, + ]; } extension ExtraEntryAction on EntryAction { @@ -76,6 +83,8 @@ extension ExtraEntryAction on EntryAction { return context.l10n.entryActionCopyToClipboard; case EntryAction.delete: return context.l10n.entryActionDelete; + case EntryAction.restore: + return context.l10n.entryActionRestore; case EntryAction.convert: return context.l10n.entryActionConvert; case EntryAction.print: @@ -119,11 +128,8 @@ extension ExtraEntryAction on EntryAction { } } - Widget? getIcon() { - final icon = getIconData(); - if (icon == null) return null; - - final child = Icon(icon); + Widget getIcon() { + final child = Icon(getIconData()); switch (this) { case EntryAction.debug: return ShaderMask( @@ -135,7 +141,7 @@ extension ExtraEntryAction on EntryAction { } } - IconData? getIconData() { + IconData getIconData() { switch (this) { case EntryAction.addShortcut: return AIcons.addShortcut; @@ -143,6 +149,8 @@ extension ExtraEntryAction on EntryAction { return AIcons.clipboard; case EntryAction.delete: return AIcons.delete; + case EntryAction.restore: + return AIcons.restore; case EntryAction.convert: return AIcons.convert; case EntryAction.print: diff --git a/lib/model/actions/entry_info_actions.dart b/lib/model/actions/entry_info_actions.dart index d23c65e02..0105d2ce4 100644 --- a/lib/model/actions/entry_info_actions.dart +++ b/lib/model/actions/entry_info_actions.dart @@ -1,4 +1,5 @@ import 'package:aves/theme/icons.dart'; +import 'package:aves/theme/themes.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/widgets.dart'; @@ -11,6 +12,8 @@ enum EntryInfoAction { removeMetadata, // motion photo viewMotionPhotoVideo, + // debug + debug, } class EntryInfoActions { @@ -41,11 +44,23 @@ extension ExtraEntryInfoAction on EntryInfoAction { // motion photo case EntryInfoAction.viewMotionPhotoVideo: return context.l10n.entryActionViewMotionPhotoVideo; + // debug + case EntryInfoAction.debug: + return 'Debug'; } } Widget getIcon() { - return Icon(_getIconData()); + final child = Icon(_getIconData()); + switch (this) { + case EntryInfoAction.debug: + return ShaderMask( + shaderCallback: Themes.debugGradient.createShader, + child: child, + ); + default: + return child; + } } IconData _getIconData() { @@ -64,6 +79,9 @@ extension ExtraEntryInfoAction on EntryInfoAction { // motion photo case EntryInfoAction.viewMotionPhotoVideo: return AIcons.motionPhoto; + // debug + case EntryInfoAction.debug: + return AIcons.debug; } } } diff --git a/lib/model/actions/entry_set_actions.dart b/lib/model/actions/entry_set_actions.dart index 5ef1ac427..8ef89c1cf 100644 --- a/lib/model/actions/entry_set_actions.dart +++ b/lib/model/actions/entry_set_actions.dart @@ -12,6 +12,7 @@ enum EntrySetAction { searchCollection, toggleTitleSearch, addShortcut, + emptyBin, // browsing or selecting map, stats, @@ -19,6 +20,7 @@ enum EntrySetAction { // selecting share, delete, + restore, copy, move, toggleFavourite, @@ -47,11 +49,13 @@ class EntrySetActions { EntrySetAction.map, EntrySetAction.stats, EntrySetAction.rescan, + EntrySetAction.emptyBin, ]; static const selection = [ EntrySetAction.share, EntrySetAction.delete, + EntrySetAction.restore, EntrySetAction.copy, EntrySetAction.move, EntrySetAction.toggleFavourite, @@ -60,6 +64,14 @@ class EntrySetActions { EntrySetAction.rescan, // editing actions are in their subsection ]; + + static const edit = [ + EntrySetAction.editDate, + EntrySetAction.editLocation, + EntrySetAction.editRating, + EntrySetAction.editTags, + EntrySetAction.removeMetadata, + ]; } extension ExtraEntrySetAction on EntrySetAction { @@ -82,6 +94,8 @@ extension ExtraEntrySetAction on EntrySetAction { return context.l10n.collectionActionShowTitleSearch; case EntrySetAction.addShortcut: return context.l10n.collectionActionAddShortcut; + case EntrySetAction.emptyBin: + return context.l10n.collectionActionEmptyBin; // browsing or selecting case EntrySetAction.map: return context.l10n.menuActionMap; @@ -94,6 +108,8 @@ extension ExtraEntrySetAction on EntrySetAction { return context.l10n.entryActionShare; case EntrySetAction.delete: return context.l10n.entryActionDelete; + case EntrySetAction.restore: + return context.l10n.entryActionRestore; case EntrySetAction.copy: return context.l10n.collectionActionCopy; case EntrySetAction.move: @@ -143,6 +159,8 @@ extension ExtraEntrySetAction on EntrySetAction { return AIcons.filter; case EntrySetAction.addShortcut: return AIcons.addShortcut; + case EntrySetAction.emptyBin: + return AIcons.emptyBin; // browsing or selecting case EntrySetAction.map: return AIcons.map; @@ -155,6 +173,8 @@ extension ExtraEntrySetAction on EntrySetAction { return AIcons.share; case EntrySetAction.delete: return AIcons.delete; + case EntrySetAction.restore: + return AIcons.restore; case EntrySetAction.copy: return AIcons.copy; case EntrySetAction.move: diff --git a/lib/model/actions/move_type.dart b/lib/model/actions/move_type.dart index 71b326b70..cc7ff0c6b 100644 --- a/lib/model/actions/move_type.dart +++ b/lib/model/actions/move_type.dart @@ -1 +1 @@ -enum MoveType { copy, move, export } +enum MoveType { copy, move, export, toBin, fromBin } diff --git a/lib/model/covers.dart b/lib/model/covers.dart index 2b339889c..3a1acb3b2 100644 --- a/lib/model/covers.dart +++ b/lib/model/covers.dart @@ -23,19 +23,19 @@ class Covers with ChangeNotifier { Set get all => Set.unmodifiable(_rows); - int? coverContentId(CollectionFilter filter) => _rows.firstWhereOrNull((row) => row.filter == filter)?.contentId; + int? coverEntryId(CollectionFilter filter) => _rows.firstWhereOrNull((row) => row.filter == filter)?.entryId; - Future set(CollectionFilter filter, int? contentId) async { + Future set(CollectionFilter filter, int? entryId) async { // erase contextual properties from filters before saving them if (filter is AlbumFilter) { filter = AlbumFilter(filter.album, null); } _rows.removeWhere((row) => row.filter == filter); - if (contentId == null) { + if (entryId == null) { await metadataDb.removeCovers({filter}); } else { - final row = CoverRow(filter: filter, contentId: contentId); + final row = CoverRow(filter: filter, entryId: entryId); _rows.add(row); await metadataDb.addCovers({row}); } @@ -43,28 +43,26 @@ class Covers with ChangeNotifier { notifyListeners(); } - Future moveEntry(int oldContentId, AvesEntry entry) async { - final oldRows = _rows.where((row) => row.contentId == oldContentId).toSet(); - if (oldRows.isEmpty) return; - - for (final oldRow in oldRows) { - final filter = oldRow.filter; - _rows.remove(oldRow); - if (filter.test(entry)) { - final newRow = CoverRow(filter: filter, contentId: entry.contentId!); - await metadataDb.updateCoverEntryId(oldRow.contentId, newRow); - _rows.add(newRow); - } else { - await metadataDb.removeCovers({filter}); + Future moveEntry(AvesEntry entry, {required bool persist}) async { + final entryId = entry.id; + final rows = _rows.where((row) => row.entryId == entryId).toSet(); + for (final row in rows) { + final filter = row.filter; + if (!filter.test(entry)) { + _rows.remove(row); + if (persist) { + await metadataDb.removeCovers({filter}); + } } } notifyListeners(); } - Future removeEntries(Set entries) async { - final contentIds = entries.map((entry) => entry.contentId).toSet(); - final removedRows = _rows.where((row) => contentIds.contains(row.contentId)).toSet(); + Future removeEntries(Set entries) => removeIds(entries.map((entry) => entry.id).toSet()); + + Future removeIds(Set entryIds) async { + final removedRows = _rows.where((row) => entryIds.contains(row.entryId)).toSet(); await metadataDb.removeCovers(removedRows.map((row) => row.filter).toSet()); _rows.removeAll(removedRows); @@ -85,8 +83,8 @@ class Covers with ChangeNotifier { final visibleEntries = source.visibleEntries; final jsonList = covers.all .map((row) { - final id = row.contentId; - final path = visibleEntries.firstWhereOrNull((entry) => id == entry.contentId)?.path; + final entryId = row.entryId; + final path = visibleEntries.firstWhereOrNull((entry) => entryId == entry.id)?.path; if (path == null) return null; final volume = androidFileUtils.getStorageVolume(path)?.path; @@ -124,7 +122,7 @@ class Covers with ChangeNotifier { final path = pContext.join(volume, relativePath); final entry = visibleEntries.firstWhereOrNull((entry) => entry.path == path && filter.test(entry)); if (entry != null) { - covers.set(filter, entry.contentId); + covers.set(filter, entry.id); } else { debugPrint('failed to import cover for path=$path, filter=$filter'); } @@ -138,14 +136,14 @@ class Covers with ChangeNotifier { @immutable class CoverRow extends Equatable { final CollectionFilter filter; - final int contentId; + final int entryId; @override - List get props => [filter, contentId]; + List get props => [filter, entryId]; const CoverRow({ required this.filter, - required this.contentId, + required this.entryId, }); static CoverRow? fromMap(Map map) { @@ -153,12 +151,12 @@ class CoverRow extends Equatable { if (filter == null) return null; return CoverRow( filter: filter, - contentId: map['contentId'], + entryId: map['entryId'], ); } Map toMap() => { 'filter': filter.toJson(), - 'contentId': contentId, + 'entryId': entryId, }; } diff --git a/lib/model/db/db_metadata.dart b/lib/model/db/db_metadata.dart index e6a1a4726..db200ef32 100644 --- a/lib/model/db/db_metadata.dart +++ b/lib/model/db/db_metadata.dart @@ -4,16 +4,19 @@ import 'package:aves/model/favourites.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/metadata/address.dart'; import 'package:aves/model/metadata/catalog.dart'; +import 'package:aves/model/metadata/trash.dart'; import 'package:aves/model/video_playback.dart'; abstract class MetadataDb { + int get nextId; + Future init(); Future dbFileSize(); Future reset(); - Future removeIds(Set contentIds, {Set? dataTypes}); + Future removeIds(Set ids, {Set? dataTypes}); // entries @@ -23,7 +26,7 @@ abstract class MetadataDb { Future saveEntries(Iterable entries); - Future updateEntryId(int oldId, AvesEntry entry); + Future updateEntry(int id, AvesEntry entry); Future> searchEntries(String query, {int? limit}); @@ -43,17 +46,25 @@ abstract class MetadataDb { Future saveMetadata(Set metadataEntries); - Future updateMetadataId(int oldId, CatalogMetadata? metadata); + Future updateMetadata(int id, CatalogMetadata? metadata); // address Future clearAddresses(); - Future> loadAllAddresses(); + Future> loadAllAddresses(); Future saveAddresses(Set addresses); - Future updateAddressId(int oldId, AddressDetails? address); + Future updateAddress(int id, AddressDetails? address); + + // trash + + Future clearTrashDetails(); + + Future> loadAllTrashDetails(); + + Future updateTrash(int id, TrashDetails? details); // favourites @@ -63,7 +74,7 @@ abstract class MetadataDb { Future addFavourites(Iterable rows); - Future updateFavouriteId(int oldId, FavouriteRow row); + Future updateFavouriteId(int id, FavouriteRow row); Future removeFavourites(Iterable rows); @@ -75,7 +86,7 @@ abstract class MetadataDb { Future addCovers(Iterable rows); - Future updateCoverEntryId(int oldId, CoverRow row); + Future updateCoverEntryId(int id, CoverRow row); Future removeCovers(Set filters); @@ -85,11 +96,9 @@ abstract class MetadataDb { Future> loadAllVideoPlayback(); - Future loadVideoPlayback(int? contentId); + Future loadVideoPlayback(int? id); Future addVideoPlayback(Set rows); - Future updateVideoPlaybackId(int oldId, int? newId); - - Future removeVideoPlayback(Set contentIds); + Future removeVideoPlayback(Set ids); } diff --git a/lib/model/db/db_metadata_sqflite.dart b/lib/model/db/db_metadata_sqflite.dart index 8f728ca88..1e2fdec33 100644 --- a/lib/model/db/db_metadata_sqflite.dart +++ b/lib/model/db/db_metadata_sqflite.dart @@ -8,6 +8,7 @@ import 'package:aves/model/favourites.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/metadata/address.dart'; import 'package:aves/model/metadata/catalog.dart'; +import 'package:aves/model/metadata/trash.dart'; import 'package:aves/model/video_playback.dart'; import 'package:aves/services/common/services.dart'; import 'package:collection/collection.dart'; @@ -15,7 +16,7 @@ import 'package:flutter/foundation.dart'; import 'package:sqflite/sqflite.dart'; class SqfliteMetadataDb implements MetadataDb { - late Future _database; + late Database _db; Future get path async => pContext.join(await getDatabasesPath(), 'metadata.db'); @@ -25,15 +26,22 @@ class SqfliteMetadataDb implements MetadataDb { static const addressTable = 'address'; static const favouriteTable = 'favourites'; static const coverTable = 'covers'; + static const trashTable = 'trash'; static const videoPlaybackTable = 'videoPlayback'; + static int _lastId = 0; + + @override + int get nextId => ++_lastId; + @override Future init() async { - _database = openDatabase( + _db = await openDatabase( await path, onCreate: (db, version) async { await db.execute('CREATE TABLE $entryTable(' - 'contentId INTEGER PRIMARY KEY' + 'id INTEGER PRIMARY KEY' + ', contentId INTEGER' ', uri TEXT' ', path TEXT' ', sourceMimeType TEXT' @@ -45,13 +53,14 @@ class SqfliteMetadataDb implements MetadataDb { ', dateModifiedSecs INTEGER' ', sourceDateTakenMillis INTEGER' ', durationMillis INTEGER' + ', trashed INTEGER DEFAULT 0' ')'); await db.execute('CREATE TABLE $dateTakenTable(' - 'contentId INTEGER PRIMARY KEY' + 'id INTEGER PRIMARY KEY' ', dateMillis INTEGER' ')'); await db.execute('CREATE TABLE $metadataTable(' - 'contentId INTEGER PRIMARY KEY' + 'id INTEGER PRIMARY KEY' ', mimeType TEXT' ', dateMillis INTEGER' ', flags INTEGER' @@ -63,7 +72,7 @@ class SqfliteMetadataDb implements MetadataDb { ', rating INTEGER' ')'); await db.execute('CREATE TABLE $addressTable(' - 'contentId INTEGER PRIMARY KEY' + 'id INTEGER PRIMARY KEY' ', addressLine TEXT' ', countryCode TEXT' ', countryName TEXT' @@ -71,21 +80,28 @@ class SqfliteMetadataDb implements MetadataDb { ', locality TEXT' ')'); await db.execute('CREATE TABLE $favouriteTable(' - 'contentId INTEGER PRIMARY KEY' - ', path TEXT' + 'id INTEGER PRIMARY KEY' ')'); await db.execute('CREATE TABLE $coverTable(' 'filter TEXT PRIMARY KEY' - ', contentId INTEGER' + ', entryId INTEGER' + ')'); + await db.execute('CREATE TABLE $trashTable(' + 'id INTEGER PRIMARY KEY' + ', path TEXT' + ', dateMillis INTEGER' ')'); await db.execute('CREATE TABLE $videoPlaybackTable(' - 'contentId INTEGER PRIMARY KEY' + 'id INTEGER PRIMARY KEY' ', resumeTimeMillis INTEGER' ')'); }, onUpgrade: MetadataDbUpgrader.upgradeDb, - version: 6, + version: 7, ); + + final maxIdRows = await _db.rawQuery('SELECT max(id) AS maxId FROM $entryTable'); + _lastId = (maxIdRows.firstOrNull?['maxId'] as int?) ?? 0; } @override @@ -97,22 +113,22 @@ class SqfliteMetadataDb implements MetadataDb { @override Future reset() async { debugPrint('$runtimeType reset'); - await (await _database).close(); + await _db.close(); await deleteDatabase(await path); await init(); } @override - Future removeIds(Set contentIds, {Set? dataTypes}) async { - if (contentIds.isEmpty) return; + Future removeIds(Set ids, {Set? dataTypes}) async { + if (ids.isEmpty) return; final _dataTypes = dataTypes ?? EntryDataType.values.toSet(); - final db = await _database; - // using array in `whereArgs` and using it with `where contentId IN ?` is a pain, so we prefer `batch` instead - final batch = db.batch(); - const where = 'contentId = ?'; - contentIds.forEach((id) { + // using array in `whereArgs` and using it with `where id IN ?` is a pain, so we prefer `batch` instead + final batch = _db.batch(); + const where = 'id = ?'; + const coverWhere = 'entryId = ?'; + ids.forEach((id) { final whereArgs = [id]; if (_dataTypes.contains(EntryDataType.basic)) { batch.delete(entryTable, where: where, whereArgs: whereArgs); @@ -126,7 +142,8 @@ class SqfliteMetadataDb implements MetadataDb { } if (_dataTypes.contains(EntryDataType.references)) { batch.delete(favouriteTable, where: where, whereArgs: whereArgs); - batch.delete(coverTable, where: where, whereArgs: whereArgs); + batch.delete(coverTable, where: coverWhere, whereArgs: whereArgs); + batch.delete(trashTable, where: where, whereArgs: whereArgs); batch.delete(videoPlaybackTable, where: where, whereArgs: whereArgs); } }); @@ -137,32 +154,28 @@ class SqfliteMetadataDb implements MetadataDb { @override Future clearEntries() async { - final db = await _database; - final count = await db.delete(entryTable, where: '1'); + final count = await _db.delete(entryTable, where: '1'); debugPrint('$runtimeType clearEntries deleted $count rows'); } @override Future> loadAllEntries() async { - final db = await _database; - final maps = await db.query(entryTable); - final entries = maps.map(AvesEntry.fromMap).toSet(); - return entries; + final rows = await _db.query(entryTable); + return rows.map(AvesEntry.fromMap).toSet(); } @override Future> loadEntries(List ids) async { if (ids.isEmpty) return {}; - final db = await _database; final entries = {}; await Future.forEach(ids, (id) async { - final maps = await db.query( + final rows = await _db.query( entryTable, - where: 'contentId = ?', + where: 'id = ?', whereArgs: [id], ); - if (maps.isNotEmpty) { - entries.add(AvesEntry.fromMap(maps.first)); + if (rows.isNotEmpty) { + entries.add(AvesEntry.fromMap(rows.first)); } }); return entries; @@ -172,18 +185,16 @@ class SqfliteMetadataDb implements MetadataDb { Future saveEntries(Iterable entries) async { if (entries.isEmpty) return; final stopwatch = Stopwatch()..start(); - final db = await _database; - final batch = db.batch(); + final batch = _db.batch(); entries.forEach((entry) => _batchInsertEntry(batch, entry)); await batch.commit(noResult: true); debugPrint('$runtimeType saveEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries'); } @override - Future updateEntryId(int oldId, AvesEntry entry) async { - final db = await _database; - final batch = db.batch(); - batch.delete(entryTable, where: 'contentId = ?', whereArgs: [oldId]); + Future updateEntry(int id, AvesEntry entry) async { + final batch = _db.batch(); + batch.delete(entryTable, where: 'id = ?', whereArgs: [id]); _batchInsertEntry(batch, entry); await batch.commit(noResult: true); } @@ -198,49 +209,42 @@ class SqfliteMetadataDb implements MetadataDb { @override Future> searchEntries(String query, {int? limit}) async { - final db = await _database; - final maps = await db.query( + final rows = await _db.query( entryTable, where: 'title LIKE ?', whereArgs: ['%$query%'], orderBy: 'sourceDateTakenMillis DESC', limit: limit, ); - return maps.map(AvesEntry.fromMap).toSet(); + return rows.map(AvesEntry.fromMap).toSet(); } // date taken @override Future clearDates() async { - final db = await _database; - final count = await db.delete(dateTakenTable, where: '1'); + final count = await _db.delete(dateTakenTable, where: '1'); debugPrint('$runtimeType clearDates deleted $count rows'); } @override Future> loadDates() async { - final db = await _database; - final maps = await db.query(dateTakenTable); - final metadataEntries = Map.fromEntries(maps.map((map) => MapEntry(map['contentId'] as int, (map['dateMillis'] ?? 0) as int))); - return metadataEntries; + final rows = await _db.query(dateTakenTable); + return Map.fromEntries(rows.map((map) => MapEntry(map['id'] as int, (map['dateMillis'] ?? 0) as int))); } // catalog metadata @override Future clearMetadataEntries() async { - final db = await _database; - final count = await db.delete(metadataTable, where: '1'); + final count = await _db.delete(metadataTable, where: '1'); debugPrint('$runtimeType clearMetadataEntries deleted $count rows'); } @override Future> loadAllMetadataEntries() async { - final db = await _database; - final maps = await db.query(metadataTable); - final metadataEntries = maps.map(CatalogMetadata.fromMap).toList(); - return metadataEntries; + final rows = await _db.query(metadataTable); + return rows.map(CatalogMetadata.fromMap).toList(); } @override @@ -248,8 +252,7 @@ class SqfliteMetadataDb implements MetadataDb { if (metadataEntries.isEmpty) return; final stopwatch = Stopwatch()..start(); try { - final db = await _database; - final batch = db.batch(); + final batch = _db.batch(); metadataEntries.forEach((metadata) => _batchInsertMetadata(batch, metadata)); await batch.commit(noResult: true); debugPrint('$runtimeType saveMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries'); @@ -259,11 +262,10 @@ class SqfliteMetadataDb implements MetadataDb { } @override - Future updateMetadataId(int oldId, CatalogMetadata? metadata) async { - final db = await _database; - final batch = db.batch(); - batch.delete(dateTakenTable, where: 'contentId = ?', whereArgs: [oldId]); - batch.delete(metadataTable, where: 'contentId = ?', whereArgs: [oldId]); + Future updateMetadata(int id, CatalogMetadata? metadata) async { + final batch = _db.batch(); + batch.delete(dateTakenTable, where: 'id = ?', whereArgs: [id]); + batch.delete(metadataTable, where: 'id = ?', whereArgs: [id]); _batchInsertMetadata(batch, metadata); await batch.commit(noResult: true); } @@ -274,7 +276,7 @@ class SqfliteMetadataDb implements MetadataDb { batch.insert( dateTakenTable, { - 'contentId': metadata.contentId, + 'id': metadata.id, 'dateMillis': metadata.dateMillis, }, conflictAlgorithm: ConflictAlgorithm.replace, @@ -291,35 +293,30 @@ class SqfliteMetadataDb implements MetadataDb { @override Future clearAddresses() async { - final db = await _database; - final count = await db.delete(addressTable, where: '1'); + final count = await _db.delete(addressTable, where: '1'); debugPrint('$runtimeType clearAddresses deleted $count rows'); } @override - Future> loadAllAddresses() async { - final db = await _database; - final maps = await db.query(addressTable); - final addresses = maps.map(AddressDetails.fromMap).toList(); - return addresses; + Future> loadAllAddresses() async { + final rows = await _db.query(addressTable); + return rows.map(AddressDetails.fromMap).toSet(); } @override Future saveAddresses(Set addresses) async { if (addresses.isEmpty) return; final stopwatch = Stopwatch()..start(); - final db = await _database; - final batch = db.batch(); + final batch = _db.batch(); addresses.forEach((address) => _batchInsertAddress(batch, address)); await batch.commit(noResult: true); debugPrint('$runtimeType saveAddresses complete in ${stopwatch.elapsed.inMilliseconds}ms for ${addresses.length} entries'); } @override - Future updateAddressId(int oldId, AddressDetails? address) async { - final db = await _database; - final batch = db.batch(); - batch.delete(addressTable, where: 'contentId = ?', whereArgs: [oldId]); + Future updateAddress(int id, AddressDetails? address) async { + final batch = _db.batch(); + batch.delete(addressTable, where: 'id = ?', whereArgs: [id]); _batchInsertAddress(batch, address); await batch.commit(noResult: true); } @@ -333,37 +330,63 @@ class SqfliteMetadataDb implements MetadataDb { ); } + // trash + + @override + Future clearTrashDetails() async { + final count = await _db.delete(trashTable, where: '1'); + debugPrint('$runtimeType clearTrashDetails deleted $count rows'); + } + + @override + Future> loadAllTrashDetails() async { + final rows = await _db.query(trashTable); + return rows.map(TrashDetails.fromMap).toSet(); + } + + @override + Future updateTrash(int id, TrashDetails? details) async { + final batch = _db.batch(); + batch.delete(trashTable, where: 'id = ?', whereArgs: [id]); + _batchInsertTrashDetails(batch, details); + await batch.commit(noResult: true); + } + + void _batchInsertTrashDetails(Batch batch, TrashDetails? details) { + if (details == null) return; + batch.insert( + trashTable, + details.toMap(), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + // favourites @override Future clearFavourites() async { - final db = await _database; - final count = await db.delete(favouriteTable, where: '1'); + final count = await _db.delete(favouriteTable, where: '1'); debugPrint('$runtimeType clearFavourites deleted $count rows'); } @override Future> loadAllFavourites() async { - final db = await _database; - final maps = await db.query(favouriteTable); - final rows = maps.map(FavouriteRow.fromMap).toSet(); - return rows; + final rows = await _db.query(favouriteTable); + return rows.map(FavouriteRow.fromMap).toSet(); } @override Future addFavourites(Iterable rows) async { if (rows.isEmpty) return; - final db = await _database; - final batch = db.batch(); + final batch = _db.batch(); rows.forEach((row) => _batchInsertFavourite(batch, row)); await batch.commit(noResult: true); } @override - Future updateFavouriteId(int oldId, FavouriteRow row) async { - final db = await _database; - final batch = db.batch(); - batch.delete(favouriteTable, where: 'contentId = ?', whereArgs: [oldId]); + Future updateFavouriteId(int id, FavouriteRow row) async { + final batch = _db.batch(); + batch.delete(favouriteTable, where: 'id = ?', whereArgs: [id]); _batchInsertFavourite(batch, row); await batch.commit(noResult: true); } @@ -379,13 +402,12 @@ class SqfliteMetadataDb implements MetadataDb { @override Future removeFavourites(Iterable rows) async { if (rows.isEmpty) return; - final ids = rows.map((row) => row.contentId); + final ids = rows.map((row) => row.entryId); if (ids.isEmpty) return; - final db = await _database; - // using array in `whereArgs` and using it with `where contentId IN ?` is a pain, so we prefer `batch` instead - final batch = db.batch(); - ids.forEach((id) => batch.delete(favouriteTable, where: 'contentId = ?', whereArgs: [id])); + // using array in `whereArgs` and using it with `where id IN ?` is a pain, so we prefer `batch` instead + final batch = _db.batch(); + ids.forEach((id) => batch.delete(favouriteTable, where: 'id = ?', whereArgs: [id])); await batch.commit(noResult: true); } @@ -393,34 +415,29 @@ class SqfliteMetadataDb implements MetadataDb { @override Future clearCovers() async { - final db = await _database; - final count = await db.delete(coverTable, where: '1'); + final count = await _db.delete(coverTable, where: '1'); debugPrint('$runtimeType clearCovers deleted $count rows'); } @override Future> loadAllCovers() async { - final db = await _database; - final maps = await db.query(coverTable); - final rows = maps.map(CoverRow.fromMap).whereNotNull().toSet(); - return rows; + final rows = await _db.query(coverTable); + return rows.map(CoverRow.fromMap).whereNotNull().toSet(); } @override Future addCovers(Iterable rows) async { if (rows.isEmpty) return; - final db = await _database; - final batch = db.batch(); + final batch = _db.batch(); rows.forEach((row) => _batchInsertCover(batch, row)); await batch.commit(noResult: true); } @override - Future updateCoverEntryId(int oldId, CoverRow row) async { - final db = await _database; - final batch = db.batch(); - batch.delete(coverTable, where: 'contentId = ?', whereArgs: [oldId]); + Future updateCoverEntryId(int id, CoverRow row) async { + final batch = _db.batch(); + batch.delete(coverTable, where: 'entryId = ?', whereArgs: [id]); _batchInsertCover(batch, row); await batch.commit(noResult: true); } @@ -437,9 +454,8 @@ class SqfliteMetadataDb implements MetadataDb { Future removeCovers(Set filters) async { if (filters.isEmpty) return; - final db = await _database; // using array in `whereArgs` and using it with `where filter IN ?` is a pain, so we prefer `batch` instead - final batch = db.batch(); + final batch = _db.batch(); filters.forEach((filter) => batch.delete(coverTable, where: 'filter = ?', whereArgs: [filter.toJson()])); await batch.commit(noResult: true); } @@ -448,36 +464,31 @@ class SqfliteMetadataDb implements MetadataDb { @override Future clearVideoPlayback() async { - final db = await _database; - final count = await db.delete(videoPlaybackTable, where: '1'); + final count = await _db.delete(videoPlaybackTable, where: '1'); debugPrint('$runtimeType clearVideoPlayback deleted $count rows'); } @override Future> loadAllVideoPlayback() async { - final db = await _database; - final maps = await db.query(videoPlaybackTable); - final rows = maps.map(VideoPlaybackRow.fromMap).whereNotNull().toSet(); - return rows; + final rows = await _db.query(videoPlaybackTable); + return rows.map(VideoPlaybackRow.fromMap).whereNotNull().toSet(); } @override - Future loadVideoPlayback(int? contentId) async { - if (contentId == null) return null; + Future loadVideoPlayback(int? id) async { + if (id == null) return null; - final db = await _database; - final maps = await db.query(videoPlaybackTable, where: 'contentId = ?', whereArgs: [contentId]); - if (maps.isEmpty) return null; + final rows = await _db.query(videoPlaybackTable, where: 'id = ?', whereArgs: [id]); + if (rows.isEmpty) return null; - return VideoPlaybackRow.fromMap(maps.first); + return VideoPlaybackRow.fromMap(rows.first); } @override Future addVideoPlayback(Set rows) async { if (rows.isEmpty) return; - final db = await _database; - final batch = db.batch(); + final batch = _db.batch(); rows.forEach((row) => _batchInsertVideoPlayback(batch, row)); await batch.commit(noResult: true); } @@ -491,23 +502,12 @@ class SqfliteMetadataDb implements MetadataDb { } @override - Future updateVideoPlaybackId(int oldId, int? newId) async { - if (newId != null) { - final db = await _database; - await db.update(videoPlaybackTable, {'contentId': newId}, where: 'contentId = ?', whereArgs: [oldId]); - } else { - await removeVideoPlayback({oldId}); - } - } + Future removeVideoPlayback(Set ids) async { + if (ids.isEmpty) return; - @override - Future removeVideoPlayback(Set contentIds) async { - if (contentIds.isEmpty) return; - - final db = await _database; // using array in `whereArgs` and using it with `where filter IN ?` is a pain, so we prefer `batch` instead - final batch = db.batch(); - contentIds.forEach((id) => batch.delete(videoPlaybackTable, where: 'contentId = ?', whereArgs: [id])); + final batch = _db.batch(); + ids.forEach((id) => batch.delete(videoPlaybackTable, where: 'id = ?', whereArgs: [id])); await batch.commit(noResult: true); } } diff --git a/lib/model/db/db_metadata_sqflite_upgrade.dart b/lib/model/db/db_metadata_sqflite_upgrade.dart index f611e0294..c818e6c18 100644 --- a/lib/model/db/db_metadata_sqflite_upgrade.dart +++ b/lib/model/db/db_metadata_sqflite_upgrade.dart @@ -4,8 +4,12 @@ import 'package:sqflite/sqflite.dart'; class MetadataDbUpgrader { static const entryTable = SqfliteMetadataDb.entryTable; + static const dateTakenTable = SqfliteMetadataDb.dateTakenTable; static const metadataTable = SqfliteMetadataDb.metadataTable; + static const addressTable = SqfliteMetadataDb.addressTable; + static const favouriteTable = SqfliteMetadataDb.favouriteTable; static const coverTable = SqfliteMetadataDb.coverTable; + static const trashTable = SqfliteMetadataDb.trashTable; static const videoPlaybackTable = SqfliteMetadataDb.videoPlaybackTable; // warning: "ALTER TABLE ... RENAME COLUMN ..." is not supported @@ -28,6 +32,9 @@ class MetadataDbUpgrader { case 5: await _upgradeFrom5(db); break; + case 6: + await _upgradeFrom6(db); + break; } oldVersion++; } @@ -129,4 +136,137 @@ class MetadataDbUpgrader { debugPrint('upgrading DB from v5'); await db.execute('ALTER TABLE $metadataTable ADD COLUMN rating INTEGER;'); } + + static Future _upgradeFrom6(Database db) async { + debugPrint('upgrading DB from v6'); + // new primary key column `id` instead of `contentId` + // new column `trashed` + await db.transaction((txn) async { + const newEntryTable = '${entryTable}TEMP'; + await db.execute('CREATE TABLE $newEntryTable(' + 'id INTEGER PRIMARY KEY' + ', contentId INTEGER' + ', uri TEXT' + ', path TEXT' + ', sourceMimeType TEXT' + ', width INTEGER' + ', height INTEGER' + ', sourceRotationDegrees INTEGER' + ', sizeBytes INTEGER' + ', title TEXT' + ', dateModifiedSecs INTEGER' + ', sourceDateTakenMillis INTEGER' + ', durationMillis INTEGER' + ', trashed INTEGER DEFAULT 0' + ')'); + await db.rawInsert('INSERT INTO $newEntryTable(id,contentId,uri,path,sourceMimeType,width,height,sourceRotationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis)' + ' SELECT contentId,contentId,uri,path,sourceMimeType,width,height,sourceRotationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis' + ' FROM $entryTable;'); + await db.execute('DROP TABLE $entryTable;'); + await db.execute('ALTER TABLE $newEntryTable RENAME TO $entryTable;'); + }); + + // rename column `contentId` to `id` + await db.transaction((txn) async { + const newDateTakenTable = '${dateTakenTable}TEMP'; + await db.execute('CREATE TABLE $newDateTakenTable(' + 'id INTEGER PRIMARY KEY' + ', dateMillis INTEGER' + ')'); + await db.rawInsert('INSERT INTO $newDateTakenTable(id,dateMillis)' + ' SELECT contentId,dateMillis' + ' FROM $dateTakenTable;'); + await db.execute('DROP TABLE $dateTakenTable;'); + await db.execute('ALTER TABLE $newDateTakenTable RENAME TO $dateTakenTable;'); + }); + + // rename column `contentId` to `id` + await db.transaction((txn) async { + const newMetadataTable = '${metadataTable}TEMP'; + await db.execute('CREATE TABLE $newMetadataTable(' + 'id INTEGER PRIMARY KEY' + ', mimeType TEXT' + ', dateMillis INTEGER' + ', flags INTEGER' + ', rotationDegrees INTEGER' + ', xmpSubjects TEXT' + ', xmpTitleDescription TEXT' + ', latitude REAL' + ', longitude REAL' + ', rating INTEGER' + ')'); + await db.rawInsert('INSERT INTO $newMetadataTable(id,mimeType,dateMillis,flags,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude,rating)' + ' SELECT contentId,mimeType,dateMillis,flags,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude,rating' + ' FROM $metadataTable;'); + await db.execute('DROP TABLE $metadataTable;'); + await db.execute('ALTER TABLE $newMetadataTable RENAME TO $metadataTable;'); + }); + + // rename column `contentId` to `id` + await db.transaction((txn) async { + const newAddressTable = '${addressTable}TEMP'; + await db.execute('CREATE TABLE $newAddressTable(' + 'id INTEGER PRIMARY KEY' + ', addressLine TEXT' + ', countryCode TEXT' + ', countryName TEXT' + ', adminArea TEXT' + ', locality TEXT' + ')'); + await db.rawInsert('INSERT INTO $newAddressTable(id,addressLine,countryCode,countryName,adminArea,locality)' + ' SELECT contentId,addressLine,countryCode,countryName,adminArea,locality' + ' FROM $addressTable;'); + await db.execute('DROP TABLE $addressTable;'); + await db.execute('ALTER TABLE $newAddressTable RENAME TO $addressTable;'); + }); + + // rename column `contentId` to `id` + await db.transaction((txn) async { + const newVideoPlaybackTable = '${videoPlaybackTable}TEMP'; + await db.execute('CREATE TABLE $newVideoPlaybackTable(' + 'id INTEGER PRIMARY KEY' + ', resumeTimeMillis INTEGER' + ')'); + await db.rawInsert('INSERT INTO $newVideoPlaybackTable(id,resumeTimeMillis)' + ' SELECT contentId,resumeTimeMillis' + ' FROM $videoPlaybackTable;'); + await db.execute('DROP TABLE $videoPlaybackTable;'); + await db.execute('ALTER TABLE $newVideoPlaybackTable RENAME TO $videoPlaybackTable;'); + }); + + // rename column `contentId` to `id` + // remove column `path` + await db.transaction((txn) async { + const newFavouriteTable = '${favouriteTable}TEMP'; + await db.execute('CREATE TABLE $newFavouriteTable(' + 'id INTEGER PRIMARY KEY' + ')'); + await db.rawInsert('INSERT INTO $newFavouriteTable(id)' + ' SELECT contentId' + ' FROM $favouriteTable;'); + await db.execute('DROP TABLE $favouriteTable;'); + await db.execute('ALTER TABLE $newFavouriteTable RENAME TO $favouriteTable;'); + }); + + // rename column `contentId` to `entryId` + await db.transaction((txn) async { + const newCoverTable = '${coverTable}TEMP'; + await db.execute('CREATE TABLE $newCoverTable(' + 'filter TEXT PRIMARY KEY' + ', entryId INTEGER' + ')'); + await db.rawInsert('INSERT INTO $newCoverTable(filter,entryId)' + ' SELECT filter,contentId' + ' FROM $coverTable;'); + await db.execute('DROP TABLE $coverTable;'); + await db.execute('ALTER TABLE $newCoverTable RENAME TO $coverTable;'); + }); + + // new table + await db.execute('CREATE TABLE $trashTable(' + 'id INTEGER PRIMARY KEY' + ', path TEXT' + ', dateMillis INTEGER' + ')'); + } } diff --git a/lib/model/entry.dart b/lib/model/entry.dart index 20ee4573b..f0d2b9070 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -7,7 +7,9 @@ import 'package:aves/model/entry_cache.dart'; import 'package:aves/model/favourites.dart'; import 'package:aves/model/metadata/address.dart'; import 'package:aves/model/metadata/catalog.dart'; +import 'package:aves/model/metadata/trash.dart'; import 'package:aves/model/multipage.dart'; +import 'package:aves/model/source/trash.dart'; import 'package:aves/model/video/metadata.dart'; import 'package:aves/ref/mime_types.dart'; import 'package:aves/services/common/service_policy.dart'; @@ -25,29 +27,27 @@ import 'package:latlong2/latlong.dart'; enum EntryDataType { basic, catalog, address, references } class AvesEntry { + // `sizeBytes`, `dateModifiedSecs` can be missing in viewer mode + int id; String uri; - String? _path, _directory, _filename, _extension; + String? _path, _directory, _filename, _extension, _sourceTitle; int? pageId, contentId; final String sourceMimeType; - int width; - int height; - int sourceRotationDegrees; - int? sizeBytes; - String? _sourceTitle; + int width, height, sourceRotationDegrees; + int? sizeBytes, _dateModifiedSecs, sourceDateTakenMillis, _durationMillis; + bool trashed; - // `dateModifiedSecs` can be missing in viewer mode - int? _dateModifiedSecs; - int? sourceDateTakenMillis; - int? _durationMillis; int? _catalogDateMillis; CatalogMetadata? _catalogMetadata; AddressDetails? _addressDetails; + TrashDetails? trashDetails; List? burstEntries; final AChangeNotifier imageChangeNotifier = AChangeNotifier(), metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier(); AvesEntry({ + required int? id, required this.uri, required String? path, required this.contentId, @@ -61,8 +61,9 @@ class AvesEntry { required int? dateModifiedSecs, required this.sourceDateTakenMillis, required int? durationMillis, + required this.trashed, this.burstEntries, - }) { + }) : id = id ?? 0 { this.path = path; this.sourceTitle = sourceTitle; this.dateModifiedSecs = dateModifiedSecs; @@ -74,6 +75,7 @@ class AvesEntry { bool get canHaveAlpha => MimeTypes.alphaImages.contains(mimeType); AvesEntry copyWith({ + int? id, String? uri, String? path, int? contentId, @@ -81,11 +83,12 @@ class AvesEntry { int? dateModifiedSecs, List? burstEntries, }) { - final copyContentId = contentId ?? this.contentId; + final copyEntryId = id ?? this.id; final copied = AvesEntry( + id: copyEntryId, uri: uri ?? this.uri, path: path ?? this.path, - contentId: copyContentId, + contentId: contentId ?? this.contentId, pageId: null, sourceMimeType: sourceMimeType, width: width, @@ -96,10 +99,12 @@ class AvesEntry { dateModifiedSecs: dateModifiedSecs ?? this.dateModifiedSecs, sourceDateTakenMillis: sourceDateTakenMillis, durationMillis: durationMillis, + trashed: trashed, burstEntries: burstEntries ?? this.burstEntries, ) - ..catalogMetadata = _catalogMetadata?.copyWith(contentId: copyContentId) - ..addressDetails = _addressDetails?.copyWith(contentId: copyContentId); + ..catalogMetadata = _catalogMetadata?.copyWith(id: copyEntryId) + ..addressDetails = _addressDetails?.copyWith(id: copyEntryId) + ..trashDetails = trashDetails?.copyWith(id: copyEntryId); return copied; } @@ -107,6 +112,7 @@ class AvesEntry { // from DB or platform source entry factory AvesEntry.fromMap(Map map) { return AvesEntry( + id: map['id'] as int?, uri: map['uri'] as String, path: map['path'] as String?, pageId: null, @@ -120,12 +126,14 @@ class AvesEntry { dateModifiedSecs: map['dateModifiedSecs'] as int?, sourceDateTakenMillis: map['sourceDateTakenMillis'] as int?, durationMillis: map['durationMillis'] as int?, + trashed: (map['trashed'] as int? ?? 0) != 0, ); } // for DB only Map toMap() { return { + 'id': id, 'uri': uri, 'path': path, 'contentId': contentId, @@ -138,6 +146,7 @@ class AvesEntry { 'dateModifiedSecs': dateModifiedSecs, 'sourceDateTakenMillis': sourceDateTakenMillis, 'durationMillis': durationMillis, + 'trashed': trashed ? 1 : 0, }; } @@ -151,7 +160,7 @@ class AvesEntry { // so that we can reliably use instances in a `Set`, which requires consistent hash codes over time @override - String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, path=$path, pageId=$pageId}'; + String toString() => '$runtimeType#${shortHash(this)}{id=$id, uri=$uri, path=$path, pageId=$pageId}'; set path(String? path) { _path = path; @@ -179,7 +188,10 @@ class AvesEntry { return _extension; } - bool get isMissingAtPath => path != null && !File(path!).existsSync(); + bool get isMissingAtPath { + final effectivePath = trashed ? trashDetails?.path : path; + return effectivePath != null && !File(effectivePath).existsSync(); + } // the MIME type reported by the Media Store is unreliable // so we use the one found during cataloguing if possible @@ -233,7 +245,7 @@ class AvesEntry { bool get is360 => _catalogMetadata?.is360 ?? false; - bool get canEdit => path != null; + bool get canEdit => path != null && !trashed; bool get canEditDate => canEdit && (canEditExif || canEditXmp); @@ -408,6 +420,18 @@ class AvesEntry { return _durationText!; } + bool get isExpiredTrash { + final dateMillis = trashDetails?.dateMillis; + if (dateMillis == null) return false; + return DateTime.fromMillisecondsSinceEpoch(dateMillis).add(TrashMixin.binKeepDuration).isBefore(DateTime.now()); + } + + int? get trashDaysLeft { + final dateMillis = trashDetails?.dateMillis; + if (dateMillis == null) return null; + return DateTime.fromMillisecondsSinceEpoch(dateMillis).add(TrashMixin.binKeepDuration).difference(DateTime.now()).inDays; + } + // returns whether this entry has GPS coordinates // (0, 0) coordinates are considered invalid, as it is likely a default value bool get hasGps => (_catalogMetadata?.latitude ?? 0) != 0 || (_catalogMetadata?.longitude ?? 0) != 0; @@ -476,7 +500,7 @@ class AvesEntry { }; await applyNewFields(fields, persist: persist); } - catalogMetadata = CatalogMetadata(contentId: contentId); + catalogMetadata = CatalogMetadata(id: id); } else { if (isVideo && (!isSized || durationMillis == 0)) { // exotic video that is not sized during loading @@ -519,7 +543,7 @@ class AvesEntry { void setCountry(CountryCode? countryCode) { if (hasFineAddress || countryCode == null) return; addressDetails = AddressDetails( - contentId: contentId, + id: id, countryCode: countryCode.alpha2, countryName: countryCode.alpha3, ); @@ -542,7 +566,7 @@ class AvesEntry { final cn = address.countryName; final aa = address.adminArea; addressDetails = AddressDetails( - contentId: contentId, + id: id, countryCode: cc, countryName: cn, adminArea: aa, @@ -638,7 +662,7 @@ class AvesEntry { _tags = null; if (persist) { - await metadataDb.removeIds({contentId!}, dataTypes: dataTypes); + await metadataDb.removeIds({id}, dataTypes: dataTypes); } final updatedEntry = await mediaFileService.getEntry(uri, mimeType); @@ -689,7 +713,7 @@ class AvesEntry { Future removeFromFavourites() async { if (isFavourite) { - await favourites.remove({this}); + await favourites.removeEntries({this}); } } @@ -720,7 +744,7 @@ class AvesEntry { pages: burstEntries! .mapIndexed((index, entry) => SinglePageInfo( index: index, - pageId: entry.contentId!, + pageId: entry.id, isDefault: index == 0, uri: entry.uri, mimeType: entry.mimeType, diff --git a/lib/model/favourites.dart b/lib/model/favourites.dart index cc8768f1e..edfb2406e 100644 --- a/lib/model/favourites.dart +++ b/lib/model/favourites.dart @@ -19,11 +19,11 @@ class Favourites with ChangeNotifier { int get count => _rows.length; - Set get all => Set.unmodifiable(_rows.map((v) => v.contentId)); + Set get all => Set.unmodifiable(_rows.map((v) => v.entryId)); - bool isFavourite(AvesEntry entry) => _rows.any((row) => row.contentId == entry.contentId); + bool isFavourite(AvesEntry entry) => _rows.any((row) => row.entryId == entry.id); - FavouriteRow _entryToRow(AvesEntry entry) => FavouriteRow(contentId: entry.contentId!, path: entry.path!); + FavouriteRow _entryToRow(AvesEntry entry) => FavouriteRow(entryId: entry.id); Future add(Set entries) async { final newRows = entries.map(_entryToRow); @@ -34,9 +34,10 @@ class Favourites with ChangeNotifier { notifyListeners(); } - Future remove(Set entries) async { - final contentIds = entries.map((entry) => entry.contentId).toSet(); - final removedRows = _rows.where((row) => contentIds.contains(row.contentId)).toSet(); + Future removeEntries(Set entries) => removeIds(entries.map((entry) => entry.id).toSet()); + + Future removeIds(Set entryIds) async { + final removedRows = _rows.where((row) => entryIds.contains(row.entryId)).toSet(); await metadataDb.removeFavourites(removedRows); removedRows.forEach(_rows.remove); @@ -44,19 +45,6 @@ class Favourites with ChangeNotifier { notifyListeners(); } - Future moveEntry(int oldContentId, AvesEntry entry) async { - final oldRow = _rows.firstWhereOrNull((row) => row.contentId == oldContentId); - if (oldRow == null) return; - - final newRow = _entryToRow(entry); - - await metadataDb.updateFavouriteId(oldContentId, newRow); - _rows.remove(oldRow); - _rows.add(newRow); - - notifyListeners(); - } - Future clear() async { await metadataDb.clearFavourites(); _rows.clear(); @@ -69,7 +57,7 @@ class Favourites with ChangeNotifier { Map>? export(CollectionSource source) { final visibleEntries = source.visibleEntries; final ids = favourites.all; - final paths = visibleEntries.where((entry) => ids.contains(entry.contentId)).map((entry) => entry.path).whereNotNull().toSet(); + final paths = visibleEntries.where((entry) => ids.contains(entry.id)).map((entry) => entry.path).whereNotNull().toSet(); final byVolume = groupBy(paths, androidFileUtils.getStorageVolume); final jsonMap = Map.fromEntries(byVolume.entries.map((kv) { final volume = kv.key?.path; @@ -117,26 +105,22 @@ class Favourites with ChangeNotifier { @immutable class FavouriteRow extends Equatable { - final int contentId; - final String path; + final int entryId; @override - List get props => [contentId, path]; + List get props => [entryId]; const FavouriteRow({ - required this.contentId, - required this.path, + required this.entryId, }); factory FavouriteRow.fromMap(Map map) { return FavouriteRow( - contentId: map['contentId'] ?? 0, - path: map['path'] ?? '', + entryId: map['id'] as int, ); } Map toMap() => { - 'contentId': contentId, - 'path': path, + 'id': entryId, }; } diff --git a/lib/model/filters/filters.dart b/lib/model/filters/filters.dart index 058715629..2c9da7615 100644 --- a/lib/model/filters/filters.dart +++ b/lib/model/filters/filters.dart @@ -10,6 +10,7 @@ import 'package:aves/model/filters/path.dart'; import 'package:aves/model/filters/query.dart'; import 'package:aves/model/filters/rating.dart'; import 'package:aves/model/filters/tag.dart'; +import 'package:aves/model/filters/trash.dart'; import 'package:aves/model/filters/type.dart'; import 'package:aves/utils/color_utils.dart'; import 'package:collection/collection.dart'; @@ -20,6 +21,7 @@ import 'package:flutter/widgets.dart'; @immutable abstract class CollectionFilter extends Equatable implements Comparable { static const List categoryOrder = [ + TrashFilter.type, QueryFilter.type, MimeFilter.type, AlbumFilter.type, @@ -64,6 +66,8 @@ abstract class CollectionFilter extends Equatable implements Comparable entry.contentId == id; + _test = (entry) => entry.id == id; return; } diff --git a/lib/model/filters/trash.dart b/lib/model/filters/trash.dart new file mode 100644 index 000000000..b2e9da0c8 --- /dev/null +++ b/lib/model/filters/trash.dart @@ -0,0 +1,38 @@ +import 'package:aves/model/filters/filters.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:flutter/material.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; + +class TrashFilter extends CollectionFilter { + static const type = 'trash'; + + static const instance = TrashFilter._private(); + + @override + List get props => []; + + const TrashFilter._private(); + + @override + Map toMap() => { + 'type': type, + }; + + @override + EntryFilter get test => (entry) => entry.trashed; + + @override + String get universalLabel => type; + + @override + String getLabel(BuildContext context) => context.l10n.binPageTitle; + + @override + Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => Icon(AIcons.bin, size: size); + + @override + String get category => type; + + @override + String get key => type; +} diff --git a/lib/model/metadata/address.dart b/lib/model/metadata/address.dart index d7e5f232e..b05ecd988 100644 --- a/lib/model/metadata/address.dart +++ b/lib/model/metadata/address.dart @@ -4,16 +4,16 @@ import 'package:flutter/widgets.dart'; @immutable class AddressDetails extends Equatable { - final int? contentId; + final int id; final String? countryCode, countryName, adminArea, locality; String? get place => locality != null && locality!.isNotEmpty ? locality : adminArea; @override - List get props => [contentId, countryCode, countryName, adminArea, locality]; + List get props => [id, countryCode, countryName, adminArea, locality]; const AddressDetails({ - this.contentId, + required this.id, this.countryCode, this.countryName, this.adminArea, @@ -21,10 +21,10 @@ class AddressDetails extends Equatable { }); AddressDetails copyWith({ - int? contentId, + int? id, }) { return AddressDetails( - contentId: contentId ?? this.contentId, + id: id ?? this.id, countryCode: countryCode, countryName: countryName, adminArea: adminArea, @@ -34,7 +34,7 @@ class AddressDetails extends Equatable { factory AddressDetails.fromMap(Map map) { return AddressDetails( - contentId: map['contentId'] as int?, + id: map['id'] as int, countryCode: map['countryCode'] as String?, countryName: map['countryName'] as String?, adminArea: map['adminArea'] as String?, @@ -43,7 +43,7 @@ class AddressDetails extends Equatable { } Map toMap() => { - 'contentId': contentId, + 'id': id, 'countryCode': countryCode, 'countryName': countryName, 'adminArea': adminArea, diff --git a/lib/model/metadata/catalog.dart b/lib/model/metadata/catalog.dart index 008065451..2c380018f 100644 --- a/lib/model/metadata/catalog.dart +++ b/lib/model/metadata/catalog.dart @@ -2,7 +2,8 @@ import 'package:aves/services/geocoding_service.dart'; import 'package:flutter/foundation.dart'; class CatalogMetadata { - final int? contentId, dateMillis; + final int id; + final int? dateMillis; final bool isAnimated, isGeotiff, is360, isMultiPage; bool isFlipped; int? rotationDegrees; @@ -19,7 +20,7 @@ class CatalogMetadata { static const _isMultiPageMask = 1 << 4; CatalogMetadata({ - this.contentId, + required this.id, this.mimeType, this.dateMillis, this.isAnimated = false, @@ -49,14 +50,14 @@ class CatalogMetadata { } CatalogMetadata copyWith({ - int? contentId, + int? id, String? mimeType, int? dateMillis, bool? isMultiPage, int? rotationDegrees, }) { return CatalogMetadata( - contentId: contentId ?? this.contentId, + id: id ?? this.id, mimeType: mimeType ?? this.mimeType, dateMillis: dateMillis ?? this.dateMillis, isAnimated: isAnimated, @@ -76,7 +77,7 @@ class CatalogMetadata { factory CatalogMetadata.fromMap(Map map) { final flags = map['flags'] ?? 0; return CatalogMetadata( - contentId: map['contentId'], + id: map['id'], mimeType: map['mimeType'], dateMillis: map['dateMillis'] ?? 0, isAnimated: flags & _isAnimatedMask != 0, @@ -95,7 +96,7 @@ class CatalogMetadata { } Map toMap() => { - 'contentId': contentId, + 'id': id, 'mimeType': mimeType, 'dateMillis': dateMillis, 'flags': (isAnimated ? _isAnimatedMask : 0) | (isFlipped ? _isFlippedMask : 0) | (isGeotiff ? _isGeotiffMask : 0) | (is360 ? _is360Mask : 0) | (isMultiPage ? _isMultiPageMask : 0), @@ -108,5 +109,5 @@ class CatalogMetadata { }; @override - String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, is360=$is360, isMultiPage=$isMultiPage, rotationDegrees=$rotationDegrees, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription, latitude=$latitude, longitude=$longitude, rating=$rating}'; + String toString() => '$runtimeType#${shortHash(this)}{id=$id, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, is360=$is360, isMultiPage=$isMultiPage, rotationDegrees=$rotationDegrees, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription, latitude=$latitude, longitude=$longitude, rating=$rating}'; } diff --git a/lib/model/metadata/trash.dart b/lib/model/metadata/trash.dart new file mode 100644 index 000000000..260661d29 --- /dev/null +++ b/lib/model/metadata/trash.dart @@ -0,0 +1,42 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; + +@immutable +class TrashDetails extends Equatable { + final int id; + final String path; + final int dateMillis; + + @override + List get props => [id, path, dateMillis]; + + const TrashDetails({ + required this.id, + required this.path, + required this.dateMillis, + }); + + TrashDetails copyWith({ + int? id, + }) { + return TrashDetails( + id: id ?? this.id, + path: path, + dateMillis: dateMillis, + ); + } + + factory TrashDetails.fromMap(Map map) { + return TrashDetails( + id: map['id'] as int, + path: map['path'] as String, + dateMillis: map['dateMillis'] as int, + ); + } + + Map toMap() => { + 'id': id, + 'path': path, + 'dateMillis': dateMillis, + }; +} diff --git a/lib/model/multipage.dart b/lib/model/multipage.dart index 7593a4c0b..6e3ad53d8 100644 --- a/lib/model/multipage.dart +++ b/lib/model/multipage.dart @@ -87,7 +87,11 @@ class MultiPageInfo { // and retrieve cached images for it final pageId = eraseDefaultPageId && pageInfo.isDefault ? null : pageInfo.pageId; + // dynamically extracted video is not in the trash like the original motion photo + final trashed = (mainEntry.isMotionPhoto && pageInfo.isVideo) ? false : mainEntry.trashed; + return AvesEntry( + id: mainEntry.id, uri: pageInfo.uri ?? mainEntry.uri, path: mainEntry.path, contentId: mainEntry.contentId, @@ -101,13 +105,15 @@ class MultiPageInfo { dateModifiedSecs: mainEntry.dateModifiedSecs, sourceDateTakenMillis: mainEntry.sourceDateTakenMillis, durationMillis: pageInfo.durationMillis ?? mainEntry.durationMillis, + trashed: trashed, ) ..catalogMetadata = mainEntry.catalogMetadata?.copyWith( mimeType: pageInfo.mimeType, isMultiPage: false, rotationDegrees: pageInfo.rotationDegrees, ) - ..addressDetails = mainEntry.addressDetails?.copyWith(); + ..addressDetails = mainEntry.addressDetails?.copyWith() + ..trashDetails = trashed ? mainEntry.trashDetails : null; } @override diff --git a/lib/model/settings/defaults.dart b/lib/model/settings/defaults.dart index 41306eafa..bde23b6fa 100644 --- a/lib/model/settings/defaults.dart +++ b/lib/model/settings/defaults.dart @@ -100,6 +100,9 @@ class SettingsDefaults { // search static const saveSearchHistory = true; + // bin + static const enableBin = true; + // accessibility static const accessibilityAnimations = AccessibilityAnimations.system; static const timeToTakeAction = AccessibilityTimeout.appDefault; // `timeToTakeAction` has a contextual default value diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index a1ca41cae..7643136ac 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -111,6 +111,9 @@ class Settings extends ChangeNotifier { static const saveSearchHistoryKey = 'save_search_history'; static const searchHistoryKey = 'search_history'; + // bin + static const enableBinKey = 'enable_bin'; + // accessibility static const accessibilityAnimationsKey = 'accessibility_animations'; static const timeToTakeActionKey = 'time_to_take_action'; @@ -462,6 +465,12 @@ class Settings extends ChangeNotifier { set searchHistory(List newValue) => setAndNotify(searchHistoryKey, newValue.map((filter) => filter.toJson()).toList()); + // bin + + bool get enableBin => getBoolOrDefault(enableBinKey, SettingsDefaults.enableBin); + + set enableBin(bool newValue) => setAndNotify(enableBinKey, newValue); + // accessibility AccessibilityAnimations get accessibilityAnimations => getEnumOrDefault(accessibilityAnimationsKey, SettingsDefaults.accessibilityAnimations, AccessibilityAnimations.values); diff --git a/lib/model/settings/store/store_shared_pref.dart b/lib/model/settings/store/store_shared_pref.dart index 8f5019cf5..d7d6fd6fd 100644 --- a/lib/model/settings/store/store_shared_pref.dart +++ b/lib/model/settings/store/store_shared_pref.dart @@ -1,4 +1,5 @@ import 'package:aves/model/settings/store/store.dart'; +import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; class SharedPrefSettingsStore implements SettingsStore { @@ -9,7 +10,11 @@ class SharedPrefSettingsStore implements SettingsStore { @override Future init() async { - _prefs = await SharedPreferences.getInstance(); + try { + _prefs = await SharedPreferences.getInstance(); + } catch (error, stack) { + debugPrint('$runtimeType init error=$error\n$stack'); + } } @override diff --git a/lib/model/source/analysis_controller.dart b/lib/model/source/analysis_controller.dart index aeac9dd31..c4e1e9e41 100644 --- a/lib/model/source/analysis_controller.dart +++ b/lib/model/source/analysis_controller.dart @@ -2,12 +2,12 @@ import 'package:flutter/foundation.dart'; class AnalysisController { final bool canStartService, force; - final List? contentIds; + final List? entryIds; final ValueNotifier stopSignal; AnalysisController({ this.canStartService = true, - this.contentIds, + this.entryIds, this.force = false, ValueNotifier? stopSignal, }) : stopSignal = stopSignal ?? ValueNotifier(false); diff --git a/lib/model/source/collection_lens.dart b/lib/model/source/collection_lens.dart index 301693be0..3f1d2fddb 100644 --- a/lib/model/source/collection_lens.dart +++ b/lib/model/source/collection_lens.dart @@ -10,6 +10,7 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/query.dart'; import 'package:aves/model/filters/rating.dart'; +import 'package:aves/model/filters/trash.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/events.dart'; @@ -53,9 +54,18 @@ class CollectionLens with ChangeNotifier { _subscriptions.add(sourceEvents.on().listen((e) => _onEntryAdded(e.entries))); _subscriptions.add(sourceEvents.on().listen((e) => _onEntryRemoved(e.entries))); _subscriptions.add(sourceEvents.on().listen((e) { - if (e.type == MoveType.move) { - // refreshing copied items is already handled via `EntryAddedEvent`s - _refresh(); + switch (e.type) { + case MoveType.copy: + case MoveType.export: + // refreshing new items is already handled via `EntryAddedEvent`s + break; + case MoveType.move: + case MoveType.fromBin: + _refresh(); + break; + case MoveType.toBin: + _onEntryRemoved(e.entries); + break; } })); _subscriptions.add(sourceEvents.on().listen((e) => _refresh())); @@ -167,7 +177,7 @@ class CollectionLens with ChangeNotifier { final bool groupBursts = true; void _applyFilters() { - final entries = fixedSelection ?? source.visibleEntries; + final entries = fixedSelection ?? (filters.contains(TrashFilter.instance) ? source.trashedEntries : source.visibleEntries); _filteredSortedEntries = List.of(filters.isEmpty ? entries : entries.where((entry) => filters.every((filter) => filter.test(entry)))); if (groupBursts) { diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index 0b151aefc..0d753ebbc 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -8,6 +8,8 @@ import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/tag.dart'; +import 'package:aves/model/filters/trash.dart'; +import 'package:aves/model/metadata/trash.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/album.dart'; import 'package:aves/model/source/analysis_controller.dart'; @@ -15,6 +17,7 @@ import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/events.dart'; import 'package:aves/model/source/location.dart'; import 'package:aves/model/source/tag.dart'; +import 'package:aves/model/source/trash.dart'; import 'package:aves/services/analysis_service.dart'; import 'package:aves/services/common/image_op_events.dart'; import 'package:aves/services/common/services.dart'; @@ -25,10 +28,12 @@ import 'package:flutter/foundation.dart'; mixin SourceBase { EventBus get eventBus; - Map get entryById; + Map get entryById; Set get visibleEntries; + Set get trashedEntries; + List get sortedEntriesByDate; ValueNotifier stateNotifier = ValueNotifier(SourceState.ready); @@ -38,7 +43,7 @@ mixin SourceBase { void setProgress({required int done, required int total}) => progressNotifier.value = ProgressEvent(done: done, total: total); } -abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin { +abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin, TrashMixin { CollectionSource() { settings.updateStream.where((key) => key == Settings.localeKey).listen((_) => invalidateAlbumDisplayNames()); } @@ -48,16 +53,16 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM @override EventBus get eventBus => _eventBus; - final Map _entryById = {}; + final Map _entryById = {}; @override - Map get entryById => Map.unmodifiable(_entryById); + Map get entryById => Map.unmodifiable(_entryById); final Set _rawEntries = {}; Set get allEntries => Set.unmodifiable(_rawEntries); - Set? _visibleEntries; + Set? _visibleEntries, _trashedEntries; @override Set get visibleEntries { @@ -65,6 +70,12 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM return _visibleEntries!; } + @override + Set get trashedEntries { + _trashedEntries ??= Set.unmodifiable(_applyTrashFilter(_rawEntries)); + return _trashedEntries!; + } + List? _sortedEntriesByDate; @override @@ -73,6 +84,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM return _sortedEntriesByDate!; } + // known date by entry ID late Map _savedDates; Future loadDates() async { @@ -80,12 +92,20 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM } Iterable _applyHiddenFilters(Iterable entries) { - final hiddenFilters = settings.hiddenFilters; - return hiddenFilters.isEmpty ? entries : entries.where((entry) => !hiddenFilters.any((filter) => filter.test(entry))); + final hiddenFilters = { + TrashFilter.instance, + ...settings.hiddenFilters, + }; + return entries.where((entry) => !hiddenFilters.any((filter) => filter.test(entry))); + } + + Iterable _applyTrashFilter(Iterable entries) { + return entries.where(TrashFilter.instance.test); } void _invalidate([Set? entries]) { _visibleEntries = null; + _trashedEntries = null; _sortedEntriesByDate = null; invalidateAlbumFilterSummary(entries: entries); invalidateCountryFilterSummary(entries: entries); @@ -104,14 +124,14 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM void addEntries(Set entries) { if (entries.isEmpty) return; - final newIdMapEntries = Map.fromEntries(entries.map((v) => MapEntry(v.contentId, v))); + final newIdMapEntries = Map.fromEntries(entries.map((entry) => MapEntry(entry.id, entry))); if (_rawEntries.isNotEmpty) { - final newContentIds = newIdMapEntries.keys.toSet(); - _rawEntries.removeWhere((entry) => newContentIds.contains(entry.contentId)); + final newIds = newIdMapEntries.keys.toSet(); + _rawEntries.removeWhere((entry) => newIds.contains(entry.id)); } entries.where((entry) => entry.catalogDateMillis == null).forEach((entry) { - entry.catalogDateMillis = _savedDates[entry.contentId]; + entry.catalogDateMillis = _savedDates[entry.id]; }); _entryById.addAll(newIdMapEntries); @@ -122,14 +142,21 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM eventBus.fire(EntryAddedEvent(entries)); } - Future removeEntries(Set uris) async { + Future removeEntries(Set uris, {required bool includeTrash}) async { if (uris.isEmpty) return; - final entries = _rawEntries.where((entry) => uris.contains(entry.uri)).toSet(); - await favourites.remove(entries); - await covers.removeEntries(entries); - await metadataDb.removeVideoPlayback(entries.map((entry) => entry.contentId).whereNotNull().toSet()); - entries.forEach((v) => _entryById.remove(v.contentId)); + final entries = _rawEntries.where((entry) => uris.contains(entry.uri)).toSet(); + if (!includeTrash) { + entries.removeWhere(TrashFilter.instance.test); + } + if (entries.isEmpty) return; + + final ids = entries.map((entry) => entry.id).toSet(); + await favourites.removeIds(ids); + await covers.removeIds(ids); + await metadataDb.removeIds(ids); + + ids.forEach((id) => _entryById.remove); _rawEntries.removeAll(entries); updateDerivedFilters(entries); eventBus.fire(EntryRemovedEvent(entries)); @@ -146,27 +173,51 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM } Future _moveEntry(AvesEntry entry, Map newFields, {required bool persist}) async { - final oldContentId = entry.contentId!; - final newContentId = newFields['contentId'] as int?; + newFields.keys.forEach((key) { + switch (key) { + case 'contentId': + entry.contentId = newFields['contentId'] as int?; + break; + case 'dateModifiedSecs': + // `dateModifiedSecs` changes when moving entries to another directory, + // but it does not change when renaming the containing directory + entry.dateModifiedSecs = newFields['dateModifiedSecs'] as int?; + break; + case 'path': + entry.path = newFields['path'] as String?; + break; + case 'title': + entry.sourceTitle = newFields['title'] as String?; + break; + case 'trashed': + final trashed = newFields['trashed'] as bool; + entry.trashed = trashed; + entry.trashDetails = trashed + ? TrashDetails( + id: entry.id, + path: newFields['trashPath'] as String, + dateMillis: DateTime.now().millisecondsSinceEpoch, + ) + : null; + break; + case 'uri': + entry.uri = newFields['uri'] as String; + break; + } + }); + if (entry.trashed) { + entry.contentId = null; + entry.uri = 'file://${entry.trashDetails?.path}'; + } - entry.contentId = newContentId; - // `dateModifiedSecs` changes when moving entries to another directory, - // but it does not change when renaming the containing directory - if (newFields.containsKey('dateModifiedSecs')) entry.dateModifiedSecs = newFields['dateModifiedSecs'] as int?; - if (newFields.containsKey('path')) entry.path = newFields['path'] as String?; - if (newFields.containsKey('uri')) entry.uri = newFields['uri'] as String; - if (newFields.containsKey('title')) entry.sourceTitle = newFields['title'] as String?; - - entry.catalogMetadata = entry.catalogMetadata?.copyWith(contentId: newContentId); - entry.addressDetails = entry.addressDetails?.copyWith(contentId: newContentId); + await covers.moveEntry(entry, persist: persist); if (persist) { - await metadataDb.updateEntryId(oldContentId, entry); - await metadataDb.updateMetadataId(oldContentId, entry.catalogMetadata); - await metadataDb.updateAddressId(oldContentId, entry.addressDetails); - await favourites.moveEntry(oldContentId, entry); - await covers.moveEntry(oldContentId, entry); - await metadataDb.updateVideoPlaybackId(oldContentId, entry.contentId); + final id = entry.id; + await metadataDb.updateEntry(id, entry); + await metadataDb.updateMetadata(id, entry.catalogMetadata); + await metadataDb.updateAddress(id, entry.addressDetails); + await metadataDb.updateTrash(id, entry.trashDetails); } } @@ -202,42 +253,40 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM return success; } - Future renameAlbum(String sourceAlbum, String destinationAlbum, Set todoEntries, Set movedOps) async { + Future renameAlbum(String sourceAlbum, String destinationAlbum, Set entries, Set movedOps) async { final oldFilter = AlbumFilter(sourceAlbum, null); - final bookmarked = settings.drawerAlbumBookmarks?.contains(sourceAlbum) == true; + final newFilter = AlbumFilter(destinationAlbum, null); + + final bookmark = settings.drawerAlbumBookmarks?.indexOf(sourceAlbum); final pinned = settings.pinnedFilters.contains(oldFilter); - final oldCoverContentId = covers.coverContentId(oldFilter); - final coverEntry = oldCoverContentId != null ? todoEntries.firstWhereOrNull((entry) => entry.contentId == oldCoverContentId) : null; + await covers.set(newFilter, covers.coverEntryId(oldFilter)); renameNewAlbum(sourceAlbum, destinationAlbum); await updateAfterMove( - todoEntries: todoEntries, - copy: false, - destinationAlbum: destinationAlbum, + todoEntries: entries, + moveType: MoveType.move, + destinationAlbums: {destinationAlbum}, movedOps: movedOps, ); - // restore bookmark, pin and cover, as the obsolete album got removed and its associated state cleaned - final newFilter = AlbumFilter(destinationAlbum, null); - if (bookmarked) { - settings.drawerAlbumBookmarks = settings.drawerAlbumBookmarks?..add(destinationAlbum); + // restore bookmark and pin, as the obsolete album got removed and its associated state cleaned + if (bookmark != null && bookmark != -1) { + settings.drawerAlbumBookmarks = settings.drawerAlbumBookmarks?..insert(bookmark, destinationAlbum); } if (pinned) { settings.pinnedFilters = settings.pinnedFilters..add(newFilter); } - if (coverEntry != null) { - await covers.set(newFilter, coverEntry.contentId); - } } Future updateAfterMove({ required Set todoEntries, - required bool copy, - required String destinationAlbum, + required MoveType moveType, + required Set destinationAlbums, required Set movedOps, }) async { if (movedOps.isEmpty) return; final fromAlbums = {}; final movedEntries = {}; + final copy = moveType == MoveType.copy; if (copy) { movedOps.forEach((movedOp) { final sourceUri = movedOp.uri; @@ -246,6 +295,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM if (sourceEntry != null) { fromAlbums.add(sourceEntry.directory); movedEntries.add(sourceEntry.copyWith( + id: metadataDb.nextId, uri: newFields['uri'] as String?, path: newFields['path'] as String?, contentId: newFields['contentId'] as int?, @@ -267,7 +317,11 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM final sourceUri = movedOp.uri; final entry = todoEntries.firstWhereOrNull((entry) => entry.uri == sourceUri); if (entry != null) { - fromAlbums.add(entry.directory); + if (moveType == MoveType.fromBin) { + newFields['trashed'] = false; + } else { + fromAlbums.add(entry.directory); + } movedEntries.add(entry); await _moveEntry(entry, newFields, persist: true); } @@ -279,11 +333,13 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM addEntries(movedEntries); } else { cleanEmptyAlbums(fromAlbums); - addDirectories({destinationAlbum}); + if (moveType != MoveType.toBin) { + addDirectories(destinationAlbums); + } } invalidateAlbumFilterSummary(directories: fromAlbums); _invalidate(movedEntries); - eventBus.fire(EntryMovedEvent(copy ? MoveType.copy : MoveType.move, movedEntries)); + eventBus.fire(EntryMovedEvent(moveType, movedEntries)); } bool get initialized => false; @@ -298,13 +354,13 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM await entry.refresh(background: false, persist: true, dataTypes: dataTypes, geocoderLocale: settings.appliedLocale); // update/delete in DB - final contentId = entry.contentId!; + final id = entry.id; if (dataTypes.contains(EntryDataType.catalog)) { - await metadataDb.updateMetadataId(contentId, entry.catalogMetadata); + await metadataDb.updateMetadata(id, entry.catalogMetadata); onCatalogMetadataChanged(); } if (dataTypes.contains(EntryDataType.address)) { - await metadataDb.updateAddressId(contentId, entry.addressDetails); + await metadataDb.updateAddress(id, entry.addressDetails); onAddressMetadataChanged(); } @@ -338,7 +394,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM if (startAnalysisService) { await AnalysisService.startService( force: force, - contentIds: entries?.map((entry) => entry.contentId).whereNotNull().toList(), + entryIds: entries?.map((entry) => entry.id).toList(), ); } else { await catalogEntries(_analysisController, todoEntries); @@ -377,9 +433,9 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM } AvesEntry? coverEntry(CollectionFilter filter) { - final contentId = covers.coverContentId(filter); - if (contentId != null) { - final entry = visibleEntries.firstWhereOrNull((entry) => entry.contentId == contentId); + final id = covers.coverEntryId(filter); + if (id != null) { + final entry = visibleEntries.firstWhereOrNull((entry) => entry.id == id); if (entry != null) return entry; } return recentEntry(filter); diff --git a/lib/model/source/location.dart b/lib/model/source/location.dart index dbabb7954..ffffd7af0 100644 --- a/lib/model/source/location.dart +++ b/lib/model/source/location.dart @@ -23,7 +23,7 @@ mixin LocationMixin on SourceBase { Future loadAddresses() async { final saved = await metadataDb.loadAllAddresses(); final idMap = entryById; - saved.forEach((metadata) => idMap[metadata.contentId]?.addressDetails = metadata); + saved.forEach((metadata) => idMap[metadata.id]?.addressDetails = metadata); onAddressMetadataChanged(); } @@ -31,7 +31,7 @@ mixin LocationMixin on SourceBase { await _locateCountries(controller, candidateEntries); await _locatePlaces(controller, candidateEntries); - final unlocatedIds = candidateEntries.where((entry) => !entry.hasGps).map((entry) => entry.contentId).whereNotNull().toSet(); + final unlocatedIds = candidateEntries.where((entry) => !entry.hasGps).map((entry) => entry.id).toSet(); if (unlocatedIds.isNotEmpty) { await metadataDb.removeIds(unlocatedIds, dataTypes: {EntryDataType.address}); onAddressMetadataChanged(); @@ -115,7 +115,7 @@ mixin LocationMixin on SourceBase { for (final entry in todo) { final latLng = approximateLatLng(entry); if (knownLocations.containsKey(latLng)) { - entry.addressDetails = knownLocations[latLng]?.copyWith(contentId: entry.contentId); + entry.addressDetails = knownLocations[latLng]?.copyWith(id: entry.id); } else { await entry.locatePlace(background: true, force: force, geocoderLocale: settings.appliedLocale); // it is intended to insert `null` if the geocoder failed, diff --git a/lib/model/source/media_store_source.dart b/lib/model/source/media_store_source.dart index d30a663d7..3f7262589 100644 --- a/lib/model/source/media_store_source.dart +++ b/lib/model/source/media_store_source.dart @@ -4,6 +4,7 @@ import 'dart:math'; import 'package:aves/model/covers.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/favourites.dart'; +import 'package:aves/model/settings/enums/enums.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/analysis_controller.dart'; import 'package:aves/model/source/collection_source.dart'; @@ -50,31 +51,33 @@ class MediaStoreSource extends CollectionSource { stateNotifier.value = SourceState.loading; clearEntries(); - final topIds = settings.topEntryIds; - late final Set topEntries; - if (topIds != null) { - debugPrint('$runtimeType refresh ${stopwatch.elapsed} load ${topIds.length} top entries'); - topEntries = await metadataDb.loadEntries(topIds); - addEntries(topEntries); - } else { - topEntries = {}; + final Set topEntries = {}; + if (settings.homePage == HomePageSetting.collection) { + final topIds = settings.topEntryIds; + if (topIds != null) { + debugPrint('$runtimeType refresh ${stopwatch.elapsed} load ${topIds.length} top entries'); + topEntries.addAll(await metadataDb.loadEntries(topIds)); + addEntries(topEntries); + } } debugPrint('$runtimeType refresh ${stopwatch.elapsed} fetch known entries'); - final oldEntries = await metadataDb.loadAllEntries(); - debugPrint('$runtimeType refresh ${stopwatch.elapsed} check obsolete entries'); - final knownDateById = Map.fromEntries(oldEntries.map((entry) => MapEntry(entry.contentId!, entry.dateModifiedSecs!))); - final obsoleteContentIds = (await mediaStoreService.checkObsoleteContentIds(knownDateById.keys.toList())).toSet(); - oldEntries.removeWhere((entry) => obsoleteContentIds.contains(entry.contentId)); + final knownEntries = await metadataDb.loadAllEntries(); + final knownLiveEntries = knownEntries.where((entry) => !entry.trashed).toSet(); + debugPrint('$runtimeType refresh ${stopwatch.elapsed} check obsolete entries'); + final knownDateByContentId = Map.fromEntries(knownLiveEntries.map((entry) => MapEntry(entry.contentId, entry.dateModifiedSecs))); + final obsoleteContentIds = (await mediaStoreService.checkObsoleteContentIds(knownDateByContentId.keys.toList())).toSet(); if (topEntries.isNotEmpty) { final obsoleteTopEntries = topEntries.where((entry) => obsoleteContentIds.contains(entry.contentId)); - await removeEntries(obsoleteTopEntries.map((entry) => entry.uri).toSet()); + await removeEntries(obsoleteTopEntries.map((entry) => entry.uri).toSet(), includeTrash: false); } + knownEntries.removeWhere((entry) => obsoleteContentIds.contains(entry.contentId)); // show known entries debugPrint('$runtimeType refresh ${stopwatch.elapsed} add known entries'); - addEntries(oldEntries); + addEntries(knownEntries); + debugPrint('$runtimeType refresh ${stopwatch.elapsed} load metadata'); await loadCatalogMetadata(); await loadAddresses(); @@ -84,16 +87,28 @@ class MediaStoreSource extends CollectionSource { debugPrint('$runtimeType refresh ${stopwatch.elapsed} remove obsolete entries'); await metadataDb.removeIds(obsoleteContentIds); + // trash + await loadTrashDetails(); + unawaited(deleteExpiredTrash().then( + (deletedUris) { + if (deletedUris.isNotEmpty) { + debugPrint('evicted ${deletedUris.length} expired items from the trash'); + removeEntries(deletedUris, includeTrash: true); + } + }, + onError: (error) => debugPrint('failed to evict expired trash error=$error'), + )); + // verify paths because some apps move files without updating their `last modified date` debugPrint('$runtimeType refresh ${stopwatch.elapsed} check obsolete paths'); - final knownPathById = Map.fromEntries(allEntries.map((entry) => MapEntry(entry.contentId!, entry.path))); - final movedContentIds = (await mediaStoreService.checkObsoletePaths(knownPathById)).toSet(); + final knownPathByContentId = Map.fromEntries(knownLiveEntries.map((entry) => MapEntry(entry.contentId, entry.path))); + final movedContentIds = (await mediaStoreService.checkObsoletePaths(knownPathByContentId)).toSet(); movedContentIds.forEach((contentId) { // make obsolete by resetting its modified date - knownDateById[contentId] = 0; + knownDateByContentId[contentId] = 0; }); - // fetch new entries + // fetch new & modified entries debugPrint('$runtimeType refresh ${stopwatch.elapsed} fetch new entries'); // refresh after the first 10 entries, then after 100 more, then every 1000 entries var refreshCount = 10; @@ -105,8 +120,9 @@ class MediaStoreSource extends CollectionSource { pendingNewEntries.clear(); } - mediaStoreService.getEntries(knownDateById).listen( + mediaStoreService.getEntries(knownDateByContentId).listen( (entry) { + entry.id = metadataDb.nextId; pendingNewEntries.add(entry); if (pendingNewEntries.length >= refreshCount) { refreshCount = min(refreshCount * 10, refreshCountMax); @@ -127,13 +143,13 @@ class MediaStoreSource extends CollectionSource { } Set? analysisEntries; - final analysisIds = analysisController?.contentIds; + final analysisIds = analysisController?.entryIds; if (analysisIds != null) { - analysisEntries = visibleEntries.where((entry) => analysisIds.contains(entry.contentId)).toSet(); + analysisEntries = visibleEntries.where((entry) => analysisIds.contains(entry.id)).toSet(); } await analyze(analysisController, entries: analysisEntries); - debugPrint('$runtimeType refresh ${stopwatch.elapsed} done for ${oldEntries.length} known, ${allNewEntries.length} new, ${obsoleteContentIds.length} obsolete'); + debugPrint('$runtimeType refresh ${stopwatch.elapsed} done for ${knownEntries.length} known, ${allNewEntries.length} new, ${obsoleteContentIds.length} obsolete'); }, onError: (error) => debugPrint('$runtimeType stream error=$error'), ); @@ -162,7 +178,7 @@ class MediaStoreSource extends CollectionSource { // clean up obsolete entries final obsoleteContentIds = (await mediaStoreService.checkObsoleteContentIds(uriByContentId.keys.toList())).toSet(); final obsoleteUris = obsoleteContentIds.map((contentId) => uriByContentId[contentId]).whereNotNull().toSet(); - await removeEntries(obsoleteUris); + await removeEntries(obsoleteUris, includeTrash: false); obsoleteContentIds.forEach(uriByContentId.remove); // fetch new entries @@ -180,6 +196,7 @@ class MediaStoreSource extends CollectionSource { final newPath = sourceEntry.path; final volume = newPath != null ? androidFileUtils.getStorageVolume(newPath) : null; if (volume != null) { + sourceEntry.id = existingEntry?.id ?? metadataDb.nextId; newEntries.add(sourceEntry); final existingDirectory = existingEntry?.directory; if (existingDirectory != null) { diff --git a/lib/model/source/tag.dart b/lib/model/source/tag.dart index 16583cc2a..c1f7b5602 100644 --- a/lib/model/source/tag.dart +++ b/lib/model/source/tag.dart @@ -17,7 +17,7 @@ mixin TagMixin on SourceBase { Future loadCatalogMetadata() async { final saved = await metadataDb.loadAllMetadataEntries(); final idMap = entryById; - saved.forEach((metadata) => idMap[metadata.contentId]?.catalogMetadata = metadata); + saved.forEach((metadata) => idMap[metadata.id]?.catalogMetadata = metadata); onCatalogMetadataChanged(); } diff --git a/lib/model/source/trash.dart b/lib/model/source/trash.dart new file mode 100644 index 000000000..cbd2fd3ad --- /dev/null +++ b/lib/model/source/trash.dart @@ -0,0 +1,34 @@ +import 'dart:async'; + +import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/services/common/image_op_events.dart'; +import 'package:aves/services/common/services.dart'; + +mixin TrashMixin on SourceBase { + static const Duration binKeepDuration = Duration(days: 30); + + Future loadTrashDetails() async { + final saved = await metadataDb.loadAllTrashDetails(); + final idMap = entryById; + saved.forEach((details) => idMap[details.id]?.trashDetails = details); + } + + Future> deleteExpiredTrash() async { + final expiredEntries = trashedEntries.where((entry) => entry.isExpiredTrash).toSet(); + if (expiredEntries.isEmpty) return {}; + + final processed = {}; + final completer = Completer>(); + mediaFileService.delete(entries: expiredEntries).listen( + processed.add, + onError: completer.completeError, + onDone: () async { + final successOps = processed.where((e) => e.success).toSet(); + final deletedOps = successOps.where((e) => !e.skipped).toSet(); + final deletedUris = deletedOps.map((event) => event.uri).toSet(); + completer.complete(deletedUris); + }, + ); + return await completer.future; + } +} diff --git a/lib/model/video/metadata.dart b/lib/model/video/metadata.dart index c83759bc4..1b7564ba3 100644 --- a/lib/model/video/metadata.dart +++ b/lib/model/video/metadata.dart @@ -99,7 +99,7 @@ class VideoMetadataFormatter { } if (dateMillis != null) { - return (entry.catalogMetadata ?? CatalogMetadata(contentId: entry.contentId)).copyWith( + return (entry.catalogMetadata ?? CatalogMetadata(id: entry.id)).copyWith( dateMillis: dateMillis, ); } diff --git a/lib/model/video_playback.dart b/lib/model/video_playback.dart index 660e4a77f..43bfce360 100644 --- a/lib/model/video_playback.dart +++ b/lib/model/video_playback.dart @@ -3,25 +3,25 @@ import 'package:flutter/foundation.dart'; @immutable class VideoPlaybackRow extends Equatable { - final int contentId, resumeTimeMillis; + final int entryId, resumeTimeMillis; @override - List get props => [contentId, resumeTimeMillis]; + List get props => [entryId, resumeTimeMillis]; const VideoPlaybackRow({ - required this.contentId, + required this.entryId, required this.resumeTimeMillis, }); static VideoPlaybackRow? fromMap(Map map) { return VideoPlaybackRow( - contentId: map['contentId'], + entryId: map['id'], resumeTimeMillis: map['resumeTimeMillis'], ); } Map toMap() => { - 'contentId': contentId, + 'id': entryId, 'resumeTimeMillis': resumeTimeMillis, }; } diff --git a/lib/services/analysis_service.dart b/lib/services/analysis_service.dart index 89d52983f..c048d08a5 100644 --- a/lib/services/analysis_service.dart +++ b/lib/services/analysis_service.dart @@ -25,10 +25,10 @@ class AnalysisService { } } - static Future startService({required bool force, List? contentIds}) async { + static Future startService({required bool force, List? entryIds}) async { try { await platform.invokeMethod('startService', { - 'contentIds': contentIds, + 'entryIds': entryIds, 'force': force, }); } on PlatformException catch (e, stack) { @@ -98,16 +98,16 @@ class Analyzer { } Future start(dynamic args) async { - debugPrint('$runtimeType start'); - List? contentIds; + List? entryIds; var force = false; if (args is Map) { - contentIds = (args['contentIds'] as List?)?.cast(); + entryIds = (args['entryIds'] as List?)?.cast(); force = args['force'] ?? false; } + debugPrint('$runtimeType start for ${entryIds?.length ?? 'all'} entries'); _controller = AnalysisController( canStartService: false, - contentIds: contentIds, + entryIds: entryIds, force: force, stopSignal: ValueNotifier(false), ); diff --git a/lib/services/media/media_file_service.dart b/lib/services/media/media_file_service.dart index a73e74319..46c3210ca 100644 --- a/lib/services/media/media_file_service.dart +++ b/lib/services/media/media_file_service.dart @@ -80,9 +80,8 @@ abstract class MediaFileService { Stream move({ String? opId, - required Iterable entries, + required Map> entriesByDestination, required bool copy, - required String destinationAlbum, required NameConflictStrategy nameConflictStrategy, }); @@ -126,6 +125,8 @@ class PlatformMediaFileService implements MediaFileService { 'isFlipped': entry.isFlipped, 'dateModifiedSecs': entry.dateModifiedSecs, 'sizeBytes': entry.sizeBytes, + 'trashed': entry.trashed, + 'trashPath': entry.trashDetails?.path, }; } @@ -343,9 +344,8 @@ class PlatformMediaFileService implements MediaFileService { @override Stream move({ String? opId, - required Iterable entries, + required Map> entriesByDestination, required bool copy, - required String destinationAlbum, required NameConflictStrategy nameConflictStrategy, }) { try { @@ -353,9 +353,8 @@ class PlatformMediaFileService implements MediaFileService { .receiveBroadcastStream({ 'op': 'move', 'id': opId, - 'entries': entries.map(_toPlatformEntryMap).toList(), + 'entriesByDestination': entriesByDestination.map((destination, entries) => MapEntry(destination, entries.map(_toPlatformEntryMap).toList())), 'copy': copy, - 'destinationPath': destinationAlbum, 'nameConflictStrategy': nameConflictStrategy.toPlatform(), }) .where((event) => event is Map) diff --git a/lib/services/media/media_store_service.dart b/lib/services/media/media_store_service.dart index 9bb493e92..e22b7d2bc 100644 --- a/lib/services/media/media_store_service.dart +++ b/lib/services/media/media_store_service.dart @@ -6,12 +6,12 @@ import 'package:flutter/services.dart'; import 'package:streams_channel/streams_channel.dart'; abstract class MediaStoreService { - Future> checkObsoleteContentIds(List knownContentIds); + Future> checkObsoleteContentIds(List knownContentIds); - Future> checkObsoletePaths(Map knownPathById); + Future> checkObsoletePaths(Map knownPathById); // knownEntries: map of contentId -> dateModifiedSecs - Stream getEntries(Map knownEntries); + Stream getEntries(Map knownEntries); // returns media URI Future scanFile(String path, String mimeType); @@ -22,7 +22,7 @@ class PlatformMediaStoreService implements MediaStoreService { static final StreamsChannel _streamChannel = StreamsChannel('deckers.thibault/aves/media_store_stream'); @override - Future> checkObsoleteContentIds(List knownContentIds) async { + Future> checkObsoleteContentIds(List knownContentIds) async { try { final result = await platform.invokeMethod('checkObsoleteContentIds', { 'knownContentIds': knownContentIds, @@ -35,7 +35,7 @@ class PlatformMediaStoreService implements MediaStoreService { } @override - Future> checkObsoletePaths(Map knownPathById) async { + Future> checkObsoletePaths(Map knownPathById) async { try { final result = await platform.invokeMethod('checkObsoletePaths', { 'knownPathById': knownPathById, @@ -48,7 +48,7 @@ class PlatformMediaStoreService implements MediaStoreService { } @override - Stream getEntries(Map knownEntries) { + Stream getEntries(Map knownEntries) { try { return _streamChannel .receiveBroadcastStream({ diff --git a/lib/services/metadata/metadata_fetch_service.dart b/lib/services/metadata/metadata_fetch_service.dart index 40e6143bf..c3b397cc4 100644 --- a/lib/services/metadata/metadata_fetch_service.dart +++ b/lib/services/metadata/metadata_fetch_service.dart @@ -79,7 +79,7 @@ class PlatformMetadataFetchService implements MetadataFetchService { 'path': entry.path, 'sizeBytes': entry.sizeBytes, }) as Map; - result['contentId'] = entry.contentId; + result['id'] = entry.id; return CatalogMetadata.fromMap(result); } on PlatformException catch (e, stack) { if (!entry.isMissingAtPath) { diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index 8d5b07d82..46a45c308 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -9,6 +9,7 @@ class AIcons { static const IconData accessibility = Icons.accessibility_new_outlined; static const IconData android = Icons.android; + static const IconData bin = Icons.delete_outlined; static const IconData broken = Icons.broken_image_outlined; static const IconData checked = Icons.done_outlined; static const IconData date = Icons.calendar_today_outlined; @@ -45,8 +46,6 @@ class AIcons { static const IconData add = Icons.add_circle_outline; static const IconData addShortcut = Icons.add_to_home_screen_outlined; static const IconData cancel = Icons.cancel_outlined; - static const IconData replay10 = Icons.replay_10_outlined; - static const IconData skip10 = Icons.forward_10_outlined; static const IconData captureFrame = Icons.screenshot_outlined; static const IconData clear = Icons.clear_outlined; static const IconData clipboard = Icons.content_copy_outlined; @@ -57,6 +56,7 @@ class AIcons { static const IconData edit = Icons.edit_outlined; static const IconData editRating = MdiIcons.starPlusOutline; static const IconData editTags = MdiIcons.tagPlusOutline; + static const IconData emptyBin = Icons.delete_sweep_outlined; static const IconData export = Icons.open_with_outlined; static const IconData fileExport = MdiIcons.fileExportOutline; static const IconData fileImport = MdiIcons.fileImportOutline; @@ -81,7 +81,10 @@ class AIcons { static const IconData print = Icons.print_outlined; static const IconData refresh = Icons.refresh_outlined; static const IconData rename = Icons.title_outlined; + static const IconData replay10 = Icons.replay_10_outlined; + static const IconData skip10 = Icons.forward_10_outlined; static const IconData reset = Icons.restart_alt_outlined; + static const IconData restore = Icons.restore_outlined; static const IconData rotateLeft = Icons.rotate_left_outlined; static const IconData rotateRight = Icons.rotate_right_outlined; static const IconData rotateScreen = Icons.screen_rotation_outlined; diff --git a/lib/utils/android_file_utils.dart b/lib/utils/android_file_utils.dart index c0de12a18..429692b40 100644 --- a/lib/utils/android_file_utils.dart +++ b/lib/utils/android_file_utils.dart @@ -8,6 +8,8 @@ import 'package:flutter/widgets.dart'; final AndroidFileUtils androidFileUtils = AndroidFileUtils._private(); class AndroidFileUtils { + static const String trashDirPath = '#trash'; + late final String separator, primaryStorage, dcimPath, downloadPath, moviesPath, picturesPath, avesVideoCapturesPath; late final Set videoCapturesPaths; Set storageVolumes = {}; diff --git a/lib/widgets/aves_app.dart b/lib/widgets/aves_app.dart index 357a1ad85..1d7647990 100644 --- a/lib/widgets/aves_app.dart +++ b/lib/widgets/aves_app.dart @@ -26,7 +26,6 @@ import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/providers/highlight_info_provider.dart'; import 'package:aves/widgets/home_page.dart'; import 'package:aves/widgets/welcome_page.dart'; -import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart'; import 'package:fijkplayer/fijkplayer.dart'; import 'package:flutter/foundation.dart'; @@ -190,7 +189,7 @@ class _AvesAppState extends State with WidgetsBindingObserver { final columns = (screenSize.width / tileExtent).ceil(); final count = rows * columns; final collection = CollectionLens(source: _mediaStoreSource, listenToSource: false); - settings.topEntryIds = collection.sortedEntries.take(count).map((entry) => entry.contentId).whereNotNull().toList(); + settings.topEntryIds = collection.sortedEntries.take(count).map((entry) => entry.id).toList(); collection.dispose(); debugPrint('Saved $count top entries in ${stopwatch.elapsed.inMilliseconds}ms'); } diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index 04c8be3a9..1dd10f071 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -3,7 +3,9 @@ import 'dart:async'; import 'package:aves/app_mode.dart'; import 'package:aves/model/actions/entry_set_actions.dart'; import 'package:aves/model/entry.dart'; +import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/query.dart'; +import 'package:aves/model/filters/trash.dart'; import 'package:aves/model/query.dart'; import 'package:aves/model/selection.dart'; import 'package:aves/model/settings/settings.dart'; @@ -54,9 +56,13 @@ class _CollectionAppBarState extends State with SingleTickerPr CollectionLens get collection => widget.collection; + bool get isTrash => collection.filters.contains(TrashFilter.instance); + CollectionSource get source => collection.source; - bool get showFilterBar => collection.filters.any((v) => !(v is QueryFilter && v.live)); + Set get visibleFilters => collection.filters.where((v) => !(v is QueryFilter && v.live) && v is! TrashFilter).toSet(); + + bool get showFilterBar => visibleFilters.isNotEmpty; @override void initState() { @@ -126,7 +132,7 @@ class _CollectionAppBarState extends State with SingleTickerPr children: [ if (showFilterBar) FilterBar( - filters: collection.filters.where((v) => !(v is QueryFilter && v.live)).toSet(), + filters: visibleFilters, removable: removableFilters, onTap: removableFilters ? collection.removeFilter : null, ), @@ -185,7 +191,7 @@ class _CollectionAppBarState extends State with SingleTickerPr ); } else { final appMode = context.watch>().value; - Widget title = Text(appMode.isPickingMedia ? l10n.collectionPickPageTitle : l10n.collectionPageTitle); + Widget title = Text(appMode.isPickingMedia ? l10n.collectionPickPageTitle : (isTrash ? l10n.binPageTitle : l10n.collectionPageTitle)); if (appMode == AppMode.main) { title = SourceStateAwareAppBarTitle( title: title, @@ -210,6 +216,7 @@ class _CollectionAppBarState extends State with SingleTickerPr isSelecting: isSelecting, itemCount: collection.entryCount, selectedItemCount: selectedItemCount, + isTrash: isTrash, ); bool canApply(EntrySetAction action) => _actionDelegate.canApply( action, @@ -220,7 +227,7 @@ class _CollectionAppBarState extends State with SingleTickerPr final canApplyEditActions = selectedItemCount > 0; final browsingQuickActions = settings.collectionBrowsingQuickActions; - final selectionQuickActions = settings.collectionSelectionQuickActions; + final selectionQuickActions = isTrash ? [EntrySetAction.delete, EntrySetAction.restore] : settings.collectionSelectionQuickActions; final quickActionButtons = (isSelecting ? selectionQuickActions : browsingQuickActions).where(isVisible).map( (action) => _toActionButton(action, enabled: canApply(action), selection: selection), ); @@ -242,7 +249,7 @@ class _CollectionAppBarState extends State with SingleTickerPr ...(isSelecting ? selectionMenuActions : browsingMenuActions).where(isVisible).map( (action) => _toMenuItem(action, enabled: canApply(action), selection: selection), ), - if (isSelecting) + if (isSelecting && !isTrash) PopupMenuItem( enabled: canApplyEditActions, padding: EdgeInsets.zero, @@ -252,13 +259,7 @@ class _CollectionAppBarState extends State with SingleTickerPr title: context.l10n.collectionActionEdit, items: [ _buildRotateAndFlipMenuItems(context, canApply: canApply), - ...[ - EntrySetAction.editDate, - EntrySetAction.editLocation, - EntrySetAction.editRating, - EntrySetAction.editTags, - EntrySetAction.removeMetadata, - ].map((action) => _toMenuItem(action, enabled: canApply(action), selection: selection)), + ...EntrySetActions.edit.where(isVisible).map((action) => _toMenuItem(action, enabled: canApply(action), selection: selection)), ], ), ), @@ -430,9 +431,11 @@ class _CollectionAppBarState extends State with SingleTickerPr case EntrySetAction.map: case EntrySetAction.stats: case EntrySetAction.rescan: + case EntrySetAction.emptyBin: // selecting case EntrySetAction.share: case EntrySetAction.delete: + case EntrySetAction.restore: case EntrySetAction.copy: case EntrySetAction.move: case EntrySetAction.toggleFavourite: diff --git a/lib/widgets/collection/collection_grid.dart b/lib/widgets/collection/collection_grid.dart index 0c599becc..2b2be1150 100644 --- a/lib/widgets/collection/collection_grid.dart +++ b/lib/widgets/collection/collection_grid.dart @@ -37,7 +37,7 @@ import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; class CollectionGrid extends StatefulWidget { - final String? settingsRouteKey; + final String settingsRouteKey; static const int columnCountDefault = 4; static const double extentMin = 46; @@ -46,7 +46,7 @@ class CollectionGrid extends StatefulWidget { const CollectionGrid({ Key? key, - this.settingsRouteKey, + required this.settingsRouteKey, }) : super(key: key); @override @@ -65,7 +65,7 @@ class _CollectionGridState extends State { @override Widget build(BuildContext context) { _tileExtentController ??= TileExtentController( - settingsRouteKey: widget.settingsRouteKey ?? context.currentRouteName!, + settingsRouteKey: widget.settingsRouteKey, columnCountDefault: CollectionGrid.columnCountDefault, extentMin: CollectionGrid.extentMin, extentMax: CollectionGrid.extentMax, @@ -114,7 +114,7 @@ class _CollectionGridContent extends StatelessWidget { animation: favourites, builder: (context, child) { return InteractiveTile( - key: ValueKey(entry.contentId), + key: ValueKey(entry.id), collection: collection, entry: entry, thumbnailExtent: thumbnailExtent, diff --git a/lib/widgets/collection/collection_page.dart b/lib/widgets/collection/collection_page.dart index 94dcd625a..4267bc1b1 100644 --- a/lib/widgets/collection/collection_page.dart +++ b/lib/widgets/collection/collection_page.dart @@ -1,6 +1,10 @@ +import 'dart:async'; + import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/query.dart'; +import 'package:aves/model/filters/trash.dart'; import 'package:aves/model/selection.dart'; +import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/widgets/collection/collection_grid.dart'; import 'package:aves/widgets/common/basic/insets.dart'; @@ -29,10 +33,25 @@ class CollectionPage extends StatefulWidget { } class _CollectionPageState extends State { + final List _subscriptions = []; + CollectionLens get collection => widget.collection; + @override + void initState() { + super.initState(); + _subscriptions.add(settings.updateStream.where((key) => key == Settings.enableBinKey).listen((_) { + if (!settings.enableBin) { + collection.removeFilter(TrashFilter.instance); + } + })); + } + @override void dispose() { + _subscriptions + ..forEach((sub) => sub.cancel()) + ..clear(); collection.dispose(); super.dispose(); } @@ -64,6 +83,7 @@ class _CollectionPageState extends State { child: const CollectionGrid( // key is expected by test driver key: Key('collection-grid'), + settingsRouteKey: CollectionPage.routeName, ), ), ), @@ -73,7 +93,7 @@ class _CollectionPageState extends State { ), ), ), - drawer: const AppDrawer(), + drawer: AppDrawer(currentCollection: collection), resizeToAvoidBottomInset: false, ), ); diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index 38be53935..6e38e1f22 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -10,6 +10,7 @@ import 'package:aves/model/favourites.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/query.dart'; import 'package:aves/model/selection.dart'; +import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/analysis_controller.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; @@ -41,6 +42,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware required bool isSelecting, required int itemCount, required int selectedItemCount, + required bool isTrash, }) { switch (action) { // general @@ -58,15 +60,19 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware case EntrySetAction.toggleTitleSearch: return !isSelecting; case EntrySetAction.addShortcut: - return appMode == AppMode.main && !isSelecting && device.canPinShortcut; + return appMode == AppMode.main && !isSelecting && device.canPinShortcut && !isTrash; + case EntrySetAction.emptyBin: + return isTrash; // browsing or selecting case EntrySetAction.map: case EntrySetAction.stats: - case EntrySetAction.rescan: return appMode == AppMode.main; + case EntrySetAction.rescan: + return appMode == AppMode.main && !isTrash; // selecting - case EntrySetAction.share: case EntrySetAction.delete: + return appMode == AppMode.main && isSelecting; + case EntrySetAction.share: case EntrySetAction.copy: case EntrySetAction.move: case EntrySetAction.toggleFavourite: @@ -78,7 +84,9 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware case EntrySetAction.editRating: case EntrySetAction.editTags: case EntrySetAction.removeMetadata: - return appMode == AppMode.main && isSelecting; + return appMode == AppMode.main && isSelecting && !isTrash; + case EntrySetAction.restore: + return appMode == AppMode.main && isSelecting && isTrash; } } @@ -104,6 +112,8 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware case EntrySetAction.toggleTitleSearch: case EntrySetAction.addShortcut: return true; + case EntrySetAction.emptyBin: + return !isSelecting && hasItems; case EntrySetAction.map: case EntrySetAction.stats: case EntrySetAction.rescan: @@ -111,6 +121,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware // selecting case EntrySetAction.share: case EntrySetAction.delete: + case EntrySetAction.restore: case EntrySetAction.copy: case EntrySetAction.move: case EntrySetAction.toggleFavourite: @@ -159,8 +170,12 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware _share(context); break; case EntrySetAction.delete: + case EntrySetAction.emptyBin: _delete(context); break; + case EntrySetAction.restore: + _move(context, moveType: MoveType.fromBin); + break; case EntrySetAction.copy: _move(context, moveType: MoveType.copy); break; @@ -197,53 +212,61 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware } } - Set _getExpandedSelectedItems(Selection selection) { - return selection.selectedItems.expand((entry) => entry.burstEntries ?? {entry}).toSet(); + Set _getTargetItems(BuildContext context) { + final selection = context.read>(); + final groupedEntries = (selection.isSelecting ? selection.selectedItems : context.read().sortedEntries); + return groupedEntries.expand((entry) => entry.burstEntries ?? {entry}).toSet(); } void _share(BuildContext context) { - final selection = context.read>(); - final selectedItems = _getExpandedSelectedItems(selection); - androidAppService.shareEntries(selectedItems).then((success) { + final entries = _getTargetItems(context); + androidAppService.shareEntries(entries).then((success) { if (!success) showNoMatchingAppDialog(context); }); } void _rescan(BuildContext context) { - final selection = context.read>(); - final collection = context.read(); - final entries = (selection.isSelecting ? _getExpandedSelectedItems(selection) : collection.sortedEntries.toSet()); + final entries = _getTargetItems(context); final controller = AnalysisController(canStartService: true, force: true); + final collection = context.read(); collection.source.analyze(controller, entries: entries); + final selection = context.read>(); selection.browse(); } Future _toggleFavourite(BuildContext context) async { - final selection = context.read>(); - final selectedItems = _getExpandedSelectedItems(selection); - if (selectedItems.every((entry) => entry.isFavourite)) { - await favourites.remove(selectedItems); + final entries = _getTargetItems(context); + if (entries.every((entry) => entry.isFavourite)) { + await favourites.removeEntries(entries); } else { - await favourites.add(selectedItems); + await favourites.add(entries); } + final selection = context.read>(); selection.browse(); } Future _delete(BuildContext context) async { + final entries = _getTargetItems(context); + + final pureTrash = entries.every((entry) => entry.trashed); + if (settings.enableBin && !pureTrash) { + await _move(context, moveType: MoveType.toBin); + return; + } + + final l10n = context.l10n; final source = context.read(); - final selection = context.read>(); - final selectedItems = _getExpandedSelectedItems(selection); - final selectionDirs = selectedItems.map((e) => e.directory).whereNotNull().toSet(); - final todoCount = selectedItems.length; + final selectionDirs = entries.map((e) => e.directory).whereNotNull().toSet(); + final todoCount = entries.length; final confirmed = await showDialog( context: context, builder: (context) { return AvesDialog( - content: Text(context.l10n.deleteEntriesConfirmationDialogMessage(todoCount)), + content: Text(l10n.deleteEntriesConfirmationDialogMessage(todoCount)), actions: [ TextButton( onPressed: () => Navigator.pop(context), @@ -251,7 +274,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware ), TextButton( onPressed: () => Navigator.pop(context, true), - child: Text(context.l10n.deleteButtonLabel), + child: Text(l10n.deleteButtonLabel), ), ], ); @@ -259,21 +282,20 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware ); if (confirmed == null || !confirmed) return; - if (!await checkStoragePermissionForAlbums(context, selectionDirs, entries: selectedItems)) return; + if (!pureTrash && !await checkStoragePermissionForAlbums(context, selectionDirs, entries: entries)) return; source.pauseMonitoring(); final opId = mediaFileService.newOpId; await showOpReport( context: context, - opStream: mediaFileService.delete(opId: opId, entries: selectedItems), + opStream: mediaFileService.delete(opId: opId, entries: entries), itemCount: todoCount, onCancel: () => mediaFileService.cancelFileOp(opId), onDone: (processed) async { final successOps = processed.where((e) => e.success).toSet(); final deletedOps = successOps.where((e) => !e.skipped).toSet(); final deletedUris = deletedOps.map((event) => event.uri).toSet(); - await source.removeEntries(deletedUris); - selection.browse(); + await source.removeEntries(deletedUris, includeTrash: true); source.resumeMonitoring(); final successCount = successOps.length; @@ -286,18 +308,21 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware await storageService.deleteEmptyDirectories(selectionDirs); }, ); + + final selection = context.read>(); + selection.browse(); } Future _move(BuildContext context, {required MoveType moveType}) async { + final entries = _getTargetItems(context); + await move(context, moveType: moveType, entries: entries); + final selection = context.read>(); - final selectedItems = _getExpandedSelectedItems(selection); - await move(context, moveType: moveType, selectedItems: selectedItems); selection.browse(); } Future _edit( BuildContext context, - Selection selection, Set todoItems, Future> Function(AvesEntry entry) op, ) async { @@ -327,7 +352,6 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware onDone: (processed) async { final successOps = processed.where((e) => e.success).toSet(); final editedOps = successOps.where((e) => !e.skipped).toSet(); - selection.browse(); source.resumeMonitoring(); unawaited(source.refreshUris(editedOps.map((v) => v.uri).toSet()).then((_) { @@ -353,14 +377,15 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware } }, ); + final selection = context.read>(); + selection.browse(); } - Future?> _getEditableItems( + Future?> _getEditableTargetItems( BuildContext context, { - required Set selectedItems, required bool Function(AvesEntry entry) canEdit, }) async { - final bySupported = groupBy(selectedItems, canEdit); + final bySupported = groupBy(_getTargetItems(context), canEdit); final supported = (bySupported[true] ?? []).toSet(); final unsupported = (bySupported[false] ?? []).toSet(); @@ -396,70 +421,52 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware } Future _rotate(BuildContext context, {required bool clockwise}) async { - final selection = context.read>(); - final selectedItems = _getExpandedSelectedItems(selection); - - final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canRotateAndFlip); + final todoItems = await _getEditableTargetItems(context, canEdit: (entry) => entry.canRotateAndFlip); if (todoItems == null || todoItems.isEmpty) return; - await _edit(context, selection, todoItems, (entry) => entry.rotate(clockwise: clockwise)); + await _edit(context, todoItems, (entry) => entry.rotate(clockwise: clockwise)); } Future _flip(BuildContext context) async { - final selection = context.read>(); - final selectedItems = _getExpandedSelectedItems(selection); - - final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canRotateAndFlip); + final todoItems = await _getEditableTargetItems(context, canEdit: (entry) => entry.canRotateAndFlip); if (todoItems == null || todoItems.isEmpty) return; - await _edit(context, selection, todoItems, (entry) => entry.flip()); + await _edit(context, todoItems, (entry) => entry.flip()); } Future _editDate(BuildContext context) async { - final selection = context.read>(); - final selectedItems = _getExpandedSelectedItems(selection); - - final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canEditDate); + final todoItems = await _getEditableTargetItems(context, canEdit: (entry) => entry.canEditDate); if (todoItems == null || todoItems.isEmpty) return; final modifier = await selectDateModifier(context, todoItems); if (modifier == null) return; - await _edit(context, selection, todoItems, (entry) => entry.editDate(modifier)); + await _edit(context, todoItems, (entry) => entry.editDate(modifier)); } Future _editLocation(BuildContext context) async { - final collection = context.read(); - final selection = context.read>(); - final selectedItems = _getExpandedSelectedItems(selection); - - final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canEditLocation); + final todoItems = await _getEditableTargetItems(context, canEdit: (entry) => entry.canEditLocation); if (todoItems == null || todoItems.isEmpty) return; + final collection = context.read(); final location = await selectLocation(context, todoItems, collection); if (location == null) return; - await _edit(context, selection, todoItems, (entry) => entry.editLocation(location)); + await _edit(context, todoItems, (entry) => entry.editLocation(location)); } Future _editRating(BuildContext context) async { - final selection = context.read>(); - final selectedItems = _getExpandedSelectedItems(selection); - - final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canEditRating); + final todoItems = await _getEditableTargetItems(context, canEdit: (entry) => entry.canEditRating); if (todoItems == null || todoItems.isEmpty) return; final rating = await selectRating(context, todoItems); if (rating == null) return; - await _edit(context, selection, todoItems, (entry) => entry.editRating(rating)); + await _edit(context, todoItems, (entry) => entry.editRating(rating)); } Future _editTags(BuildContext context) async { - final selection = context.read>(); - final selectedItems = _getExpandedSelectedItems(selection); - - final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canEditTags); + final todoItems = await _getEditableTargetItems(context, canEdit: (entry) => entry.canEditTags); if (todoItems == null || todoItems.isEmpty) return; final newTagsByEntry = await selectTags(context, todoItems); @@ -474,26 +481,22 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware if (todoItems.isEmpty) return; - await _edit(context, selection, todoItems, (entry) => entry.editTags(newTagsByEntry[entry]!)); + await _edit(context, todoItems, (entry) => entry.editTags(newTagsByEntry[entry]!)); } Future _removeMetadata(BuildContext context) async { - final selection = context.read>(); - final selectedItems = _getExpandedSelectedItems(selection); - - final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canRemoveMetadata); + final todoItems = await _getEditableTargetItems(context, canEdit: (entry) => entry.canRemoveMetadata); if (todoItems == null || todoItems.isEmpty) return; final types = await selectMetadataToRemove(context, todoItems); if (types == null || types.isEmpty) return; - await _edit(context, selection, todoItems, (entry) => entry.removeMetadata(types)); + await _edit(context, todoItems, (entry) => entry.removeMetadata(types)); } void _goToMap(BuildContext context) { - final selection = context.read>(); final collection = context.read(); - final entries = (selection.isSelecting ? _getExpandedSelectedItems(selection) : collection.sortedEntries); + final entries = _getTargetItems(context); Navigator.push( context, @@ -512,9 +515,8 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware } void _goToStats(BuildContext context) { - final selection = context.read>(); final collection = context.read(); - final entries = selection.isSelecting ? _getExpandedSelectedItems(selection) : collection.sortedEntries.toSet(); + final entries = _getTargetItems(context); Navigator.push( context, diff --git a/lib/widgets/collection/grid/tile.dart b/lib/widgets/collection/grid/tile.dart index cb0f82d21..9b02c1813 100644 --- a/lib/widgets/collection/grid/tile.dart +++ b/lib/widgets/collection/grid/tile.dart @@ -66,7 +66,7 @@ class InteractiveTile extends StatelessWidget { // hero tag should include a collection identifier, so that it animates // between different views of the entry in the same collection (e.g. thumbnails <-> viewer) // but not between different collection instances, even with the same attributes (e.g. reloading collection page via drawer) - heroTagger: () => Object.hashAll([collection.id, entry.uri]), + heroTagger: () => Object.hashAll([collection.id, entry.id]), ), ), ); @@ -81,7 +81,7 @@ class InteractiveTile extends StatelessWidget { final viewerCollection = collection.copyWith( listenToSource: false, ); - assert(viewerCollection.sortedEntries.map((e) => e.contentId).contains(entry.contentId)); + assert(viewerCollection.sortedEntries.map((entry) => entry.id).contains(entry.id)); return EntryViewerPage( collection: viewerCollection, initialEntry: entry, diff --git a/lib/widgets/common/action_mixins/entry_storage.dart b/lib/widgets/common/action_mixins/entry_storage.dart index 79385c0a4..c175c0e2a 100644 --- a/lib/widgets/common/action_mixins/entry_storage.dart +++ b/lib/widgets/common/action_mixins/entry_storage.dart @@ -5,6 +5,7 @@ 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/filters/trash.dart'; import 'package:aves/model/highlight.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; @@ -12,11 +13,13 @@ 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/android_file_utils.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_dialog.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/filter_grids/album_pick.dart'; import 'package:collection/collection.dart'; @@ -27,9 +30,38 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { Future move( BuildContext context, { required MoveType moveType, - required Set selectedItems, + required Set entries, VoidCallback? onSuccess, }) async { + final todoCount = entries.length; + assert(todoCount > 0); + + final toBin = moveType == MoveType.toBin; + final copy = moveType == MoveType.copy; + + final l10n = context.l10n; + if (toBin) { + final confirmed = await showDialog( + context: context, + builder: (context) { + return AvesDialog( + content: Text(l10n.binEntriesConfirmationDialogMessage(todoCount)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text(l10n.deleteButtonLabel), + ), + ], + ); + }, + ); + if (confirmed == null || !confirmed) return; + } + final source = context.read(); if (!source.initialized) { // source may be uninitialized in viewer mode @@ -37,48 +69,64 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { unawaited(source.refresh()); } - final l10n = context.l10n; - final selectionDirs = selectedItems.map((e) => e.directory).whereNotNull().toSet(); + final entriesByDestination = >{}; + switch (moveType) { + case MoveType.copy: + case MoveType.move: + case MoveType.export: + final destinationAlbum = await pickAlbum(context: context, moveType: moveType); + if (destinationAlbum == null) return; + entriesByDestination[destinationAlbum] = entries; + break; + case MoveType.toBin: + entriesByDestination[AndroidFileUtils.trashDirPath] = entries; + break; + case MoveType.fromBin: + groupBy(entries, (e) => e.directory).forEach((originAlbum, dirEntries) { + if (originAlbum != null) { + entriesByDestination[originAlbum] = dirEntries.toSet(); + } + }); + break; + } - final destinationAlbum = await pickAlbum(context: context, moveType: moveType); - if (destinationAlbum == null) return; - if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return; + // permission for modification at destinations + final destinationAlbums = entriesByDestination.keys.toSet(); + if (!await checkStoragePermissionForAlbums(context, destinationAlbums)) return; - if (moveType == MoveType.move && !await checkStoragePermissionForAlbums(context, selectionDirs, entries: selectedItems)) return; + // permission for modification at origins + final originAlbums = entries.map((e) => e.directory).whereNotNull().toSet(); + if ({MoveType.move, MoveType.toBin}.contains(moveType) && !await checkStoragePermissionForAlbums(context, originAlbums, entries: entries)) return; - if (!await checkFreeSpaceForMove(context, selectedItems, destinationAlbum, moveType)) return; + await Future.forEach(destinationAlbums, (destinationAlbum) async { + if (!await checkFreeSpaceForMove(context, entries, 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; + if (!toBin && destinationAlbums.length == 1) { + final destinationDirectory = Directory(destinationAlbums.single); + final names = [ + ...entries.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(); + 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: originAlbums.length == 1 ? l10n.nameConflictDialogSingleSourceMessage : l10n.nameConflictDialogMultipleSourceMessage, + confirmationButtonLabel: l10n.continueButtonLabel, + ); + }, + ); + if (value == null) return; + nameConflictStrategy = value; + } } source.pauseMonitoring(); @@ -87,9 +135,8 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { context: context, opStream: mediaFileService.move( opId: opId, - entries: todoItems, + entriesByDestination: entriesByDestination, copy: copy, - destinationAlbum: destinationAlbum, nameConflictStrategy: nameConflictStrategy, ), itemCount: todoCount, @@ -98,16 +145,16 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { 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, + todoEntries: entries, + moveType: moveType, + destinationAlbums: destinationAlbums, movedOps: movedOps, ); source.resumeMonitoring(); // cleanup - if (moveType == MoveType.move) { - await storageService.deleteEmptyDirectories(selectionDirs); + if ({MoveType.move, MoveType.toBin}.contains(moveType)) { + await storageService.deleteEmptyDirectories(originAlbums); } final successCount = successOps.length; @@ -119,7 +166,7 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { final appMode = context.read>().value; SnackBarAction? action; - if (count > 0 && appMode == AppMode.main) { + if (count > 0 && appMode == AppMode.main && !toBin) { action = SnackBarAction( label: l10n.showButtonLabel, onPressed: () async { @@ -130,14 +177,18 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { 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 + if (collection == null || collection.filters.any((f) => f is AlbumFilter || f is TrashFilter)) { targetCollection = CollectionLens( source: source, - filters: collection?.filters, - )..addFilter(filter); + filters: collection?.filters.where((f) => f != TrashFilter.instance).toSet(), + ); + // we could simply add the filter to the current collection + // but navigating makes the change less jarring + if (destinationAlbums.length == 1) { + final destinationAlbum = destinationAlbums.single; + final filter = AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum)); + targetCollection.addFilter(filter); + } unawaited(Navigator.pushAndRemoveUntil( context, MaterialPageRoute( diff --git a/lib/widgets/common/action_mixins/size_aware.dart b/lib/widgets/common/action_mixins/size_aware.dart index a7f37b123..35c880bd5 100644 --- a/lib/widgets/common/action_mixins/size_aware.dart +++ b/lib/widgets/common/action_mixins/size_aware.dart @@ -19,6 +19,8 @@ mixin SizeAwareMixin { String destinationAlbum, MoveType moveType, ) async { + if (moveType == MoveType.toBin) return true; + // assume we have enough space if we cannot find the volume or its remaining free space final destinationVolume = androidFileUtils.getStorageVolume(destinationAlbum); if (destinationVolume == null) return true; @@ -34,6 +36,8 @@ mixin SizeAwareMixin { needed = selection.fold(0, sumSize); break; case MoveType.move: + case MoveType.toBin: + case MoveType.fromBin: // when moving, we only need space for the entries that are not already on the destination volume final byVolume = groupBy(selection, (entry) => androidFileUtils.getStorageVolume(entry.path)).whereNotNullKey(); final otherVolumes = byVolume.keys.where((volume) => volume != destinationVolume); diff --git a/lib/widgets/common/grid/theme.dart b/lib/widgets/common/grid/theme.dart index 2422ab2bd..670c9995e 100644 --- a/lib/widgets/common/grid/theme.dart +++ b/lib/widgets/common/grid/theme.dart @@ -6,13 +6,14 @@ import 'package:provider/provider.dart'; class GridTheme extends StatelessWidget { final double extent; - final bool? showLocation; + final bool? showLocation, showTrash; final Widget child; const GridTheme({ Key? key, required this.extent, this.showLocation, + this.showTrash, required this.child, }) : super(key: key); @@ -33,6 +34,7 @@ class GridTheme extends StatelessWidget { showMotionPhoto: settings.showThumbnailMotionPhoto, showRating: settings.showThumbnailRating, showRaw: settings.showThumbnailRaw, + showTrash: showTrash ?? true, showVideoDuration: settings.showThumbnailVideoDuration, ); }, @@ -43,7 +45,7 @@ class GridTheme extends StatelessWidget { class GridThemeData { final double iconSize, fontSize, highlightBorderWidth; - final bool showFavourite, showLocation, showMotionPhoto, showRating, showRaw, showVideoDuration; + final bool showFavourite, showLocation, showMotionPhoto, showRating, showRaw, showTrash, showVideoDuration; const GridThemeData({ required this.iconSize, @@ -54,6 +56,7 @@ class GridThemeData { required this.showMotionPhoto, required this.showRating, required this.showRaw, + required this.showTrash, required this.showVideoDuration, }); } diff --git a/lib/widgets/common/identity/aves_icons.dart b/lib/widgets/common/identity/aves_icons.dart index efd6550f7..f259495e2 100644 --- a/lib/widgets/common/identity/aves_icons.dart +++ b/lib/widgets/common/identity/aves_icons.dart @@ -2,6 +2,7 @@ import 'package:aves/image_providers/app_icon_image_provider.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/android_file_utils.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/grid/theme.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -177,6 +178,31 @@ class RatingIcon extends StatelessWidget { } } +class TrashIcon extends StatelessWidget { + final int? trashDaysLeft; + + const TrashIcon({ + Key? key, + required this.trashDaysLeft, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final child = OverlayIcon( + icon: AIcons.bin, + text: trashDaysLeft != null ? context.l10n.timeDays(trashDaysLeft!) : null, + ); + + return DefaultTextStyle( + style: TextStyle( + color: Colors.grey.shade200, + fontSize: context.select((t) => t.fontSize), + ), + child: child, + ); + } +} + class OverlayIcon extends StatelessWidget { final IconData icon; final String? text; diff --git a/lib/widgets/common/identity/empty.dart b/lib/widgets/common/identity/empty.dart index 459726dbc..af4ff6096 100644 --- a/lib/widgets/common/identity/empty.dart +++ b/lib/widgets/common/identity/empty.dart @@ -7,6 +7,7 @@ class EmptyContent extends StatelessWidget { final String text; final AlignmentGeometry alignment; final double fontSize; + final bool safeBottom; const EmptyContent({ Key? key, @@ -14,15 +15,18 @@ class EmptyContent extends StatelessWidget { required this.text, this.alignment = const FractionalOffset(.5, .35), this.fontSize = 22, + this.safeBottom = true, }) : super(key: key); @override Widget build(BuildContext context) { const color = Colors.blueGrey; return Padding( - padding: EdgeInsets.only( - bottom: context.select((mq) => mq.effectiveBottomPadding), - ), + padding: safeBottom + ? EdgeInsets.only( + bottom: context.select((mq) => mq.effectiveBottomPadding), + ) + : EdgeInsets.zero, child: Align( alignment: alignment, child: Column( diff --git a/lib/widgets/common/thumbnail/decorated.dart b/lib/widgets/common/thumbnail/decorated.dart index bcf87ee42..f3a955b91 100644 --- a/lib/widgets/common/thumbnail/decorated.dart +++ b/lib/widgets/common/thumbnail/decorated.dart @@ -27,7 +27,6 @@ class DecoratedThumbnail extends StatelessWidget { @override Widget build(BuildContext context) { - final isSvg = entry.isSvg; Widget child = ThumbnailImage( entry: entry, extent: tileExtent, @@ -36,10 +35,10 @@ class DecoratedThumbnail extends StatelessWidget { ); child = Stack( - alignment: isSvg ? Alignment.center : AlignmentDirectional.bottomStart, + alignment: AlignmentDirectional.bottomStart, children: [ child, - if (!isSvg) ThumbnailEntryOverlay(entry: entry), + ThumbnailEntryOverlay(entry: entry), if (selectable) GridItemSelectionOverlay( item: entry, diff --git a/lib/widgets/common/thumbnail/error.dart b/lib/widgets/common/thumbnail/error.dart index b1015de3e..6ca741e0d 100644 --- a/lib/widgets/common/thumbnail/error.dart +++ b/lib/widgets/common/thumbnail/error.dart @@ -4,7 +4,6 @@ import 'dart:math'; import 'package:aves/model/entry.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/mime_utils.dart'; -import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -58,14 +57,10 @@ class _ErrorThumbnailState extends State { textAlign: TextAlign.center, ); }) - : Tooltip( - message: context.l10n.viewerErrorDoesNotExist, - preferBelow: false, - child: Icon( - AIcons.broken, - size: extent / 2, - color: color, - ), + : Icon( + AIcons.broken, + size: extent / 2, + color: color, ); } return Container( diff --git a/lib/widgets/common/thumbnail/overlay.dart b/lib/widgets/common/thumbnail/overlay.dart index 28b8251b5..ad3a74d44 100644 --- a/lib/widgets/common/thumbnail/overlay.dart +++ b/lib/widgets/common/thumbnail/overlay.dart @@ -35,6 +35,7 @@ class ThumbnailEntryOverlay extends StatelessWidget { if (entry.isMotionPhoto && context.select((t) => t.showMotionPhoto)) const MotionPhotoIcon(), if (!entry.isMotionPhoto) MultiPageIcon(entry: entry), ], + if (entry.trashed && context.select((t) => t.showTrash)) TrashIcon(trashDaysLeft: entry.trashDaysLeft), ]; if (children.isEmpty) return const SizedBox(); if (children.length == 1) return children.first; diff --git a/lib/widgets/common/thumbnail/scroller.dart b/lib/widgets/common/thumbnail/scroller.dart index b04af682d..6b8e14a74 100644 --- a/lib/widgets/common/thumbnail/scroller.dart +++ b/lib/widgets/common/thumbnail/scroller.dart @@ -86,6 +86,7 @@ class _ThumbnailScrollerState extends State { return GridTheme( extent: extent, showLocation: false, + showTrash: false, child: SizedBox( height: extent, child: ListView.separated( diff --git a/lib/widgets/debug/database.dart b/lib/widgets/debug/database.dart index d734ac5d5..5fd835ed7 100644 --- a/lib/widgets/debug/database.dart +++ b/lib/widgets/debug/database.dart @@ -3,6 +3,7 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/favourites.dart'; import 'package:aves/model/metadata/address.dart'; import 'package:aves/model/metadata/catalog.dart'; +import 'package:aves/model/metadata/trash.dart'; import 'package:aves/model/video_playback.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/utils/file_utils.dart'; @@ -21,7 +22,8 @@ class _DebugAppDatabaseSectionState extends State with late Future> _dbEntryLoader; late Future> _dbDateLoader; late Future> _dbMetadataLoader; - late Future> _dbAddressLoader; + late Future> _dbAddressLoader; + late Future> _dbTrashLoader; late Future> _dbFavouritesLoader; late Future> _dbCoversLoader; late Future> _dbVideoPlaybackLoader; @@ -127,7 +129,7 @@ class _DebugAppDatabaseSectionState extends State with ); }, ), - FutureBuilder( + FutureBuilder( future: _dbAddressLoader, builder: (context, snapshot) { if (snapshot.hasError) return Text(snapshot.error.toString()); @@ -148,6 +150,27 @@ class _DebugAppDatabaseSectionState extends State with ); }, ), + FutureBuilder( + future: _dbTrashLoader, + builder: (context, snapshot) { + if (snapshot.hasError) return Text(snapshot.error.toString()); + + if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); + + return Row( + children: [ + Expanded( + child: Text('trash rows: ${snapshot.data!.length}'), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: () => metadataDb.clearTrashDetails().then((_) => _startDbReport()), + child: const Text('Clear'), + ), + ], + ); + }, + ), FutureBuilder( future: _dbFavouritesLoader, builder: (context, snapshot) { @@ -224,6 +247,7 @@ class _DebugAppDatabaseSectionState extends State with _dbDateLoader = metadataDb.loadDates(); _dbMetadataLoader = metadataDb.loadAllMetadataEntries(); _dbAddressLoader = metadataDb.loadAllAddresses(); + _dbTrashLoader = metadataDb.loadAllTrashDetails(); _dbFavouritesLoader = metadataDb.loadAllFavourites(); _dbCoversLoader = metadataDb.loadAllCovers(); _dbVideoPlaybackLoader = metadataDb.loadAllVideoPlayback(); diff --git a/lib/widgets/dialogs/add_shortcut_dialog.dart b/lib/widgets/dialogs/add_shortcut_dialog.dart index 94cbbbbd3..42ce29bb7 100644 --- a/lib/widgets/dialogs/add_shortcut_dialog.dart +++ b/lib/widgets/dialogs/add_shortcut_dialog.dart @@ -38,7 +38,7 @@ class _AddShortcutDialogState extends State { if (_collection != null) { final entries = _collection.sortedEntries; if (entries.isNotEmpty) { - final coverEntries = _collection.filters.map(covers.coverContentId).whereNotNull().map((id) => entries.firstWhereOrNull((entry) => entry.contentId == id)).whereNotNull(); + final coverEntries = _collection.filters.map(covers.coverEntryId).whereNotNull().map((id) => entries.firstWhereOrNull((entry) => entry.id == id)).whereNotNull(); _coverEntry = coverEntries.firstOrNull ?? entries.first; } } diff --git a/lib/widgets/drawer/app_drawer.dart b/lib/widgets/drawer/app_drawer.dart index 3604fa3e0..45665b918 100644 --- a/lib/widgets/drawer/app_drawer.dart +++ b/lib/widgets/drawer/app_drawer.dart @@ -1,7 +1,10 @@ import 'dart:ui'; +import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/trash.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/album.dart'; +import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/location.dart'; import 'package:aves/model/source/tag.dart'; @@ -20,12 +23,19 @@ import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/filter_grids/countries_page.dart'; import 'package:aves/widgets/filter_grids/tags_page.dart'; import 'package:aves/widgets/settings/settings_page.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; class AppDrawer extends StatelessWidget { - const AppDrawer({Key? key}) : super(key: key); + // collection loaded in the `CollectionPage`, if any + final CollectionLens? currentCollection; + + const AppDrawer({ + Key? key, + this.currentCollection, + }) : super(key: key); static List getDefaultAlbums(BuildContext context) { final source = context.read(); @@ -44,6 +54,10 @@ class AppDrawer extends StatelessWidget { ..._buildTypeLinks(), _buildAlbumLinks(context), ..._buildPageLinks(context), + if (settings.enableBin) ...[ + const Divider(), + binTile, + ], if (!kReleaseMode) ...[ const Divider(), debugTile, @@ -153,6 +167,7 @@ class AppDrawer extends StatelessWidget { List _buildTypeLinks() { final hiddenFilters = settings.hiddenFilters; final typeBookmarks = settings.drawerTypeBookmarks; + final currentFilters = currentCollection?.filters; return typeBookmarks .where((filter) => !hiddenFilters.contains(filter)) .map((filter) => CollectionNavTile( @@ -161,12 +176,17 @@ class AppDrawer extends StatelessWidget { leading: DrawerFilterIcon(filter: filter), title: DrawerFilterTitle(filter: filter), filter: filter, + isSelected: () { + if (currentFilters == null || currentFilters.length > 1) return false; + return currentFilters.firstOrNull == filter; + }, )) .toList(); } Widget _buildAlbumLinks(BuildContext context) { final source = context.read(); + final currentFilters = currentCollection?.filters; return StreamBuilder( stream: source.eventBus.on(), builder: (context, snapshot) { @@ -175,7 +195,14 @@ class AppDrawer extends StatelessWidget { return Column( children: [ const Divider(), - ...albums.map((album) => AlbumNavTile(album: album)), + ...albums.map((album) => AlbumNavTile( + album: album, + isSelected: () { + if (currentFilters == null || currentFilters.length > 1) return false; + final currentFilter = currentFilters.firstOrNull; + return currentFilter is AlbumFilter && currentFilter.album == album; + }, + )), ], ); }); @@ -226,6 +253,16 @@ class AppDrawer extends StatelessWidget { ]; } + Widget get binTile { + const filter = TrashFilter.instance; + return CollectionNavTile( + leading: const DrawerFilterIcon(filter: filter), + title: const DrawerFilterTitle(filter: filter), + filter: filter, + isSelected: () => currentCollection?.filters.contains(filter) ?? false, + ); + } + Widget get debugTile => PageNavTile( // key is expected by test driver key: const Key('drawer-debug'), diff --git a/lib/widgets/drawer/collection_nav_tile.dart b/lib/widgets/drawer/collection_nav_tile.dart index f8fa85239..17dc8a213 100644 --- a/lib/widgets/drawer/collection_nav_tile.dart +++ b/lib/widgets/drawer/collection_nav_tile.dart @@ -5,6 +5,7 @@ import 'package:aves/model/source/collection_source.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/collection/collection_page.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/drawer/tile.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -15,6 +16,7 @@ class CollectionNavTile extends StatelessWidget { final Widget? trailing; final bool dense; final CollectionFilter? filter; + final bool Function() isSelected; const CollectionNavTile({ Key? key, @@ -23,6 +25,7 @@ class CollectionNavTile extends StatelessWidget { this.trailing, bool? dense, required this.filter, + required this.isSelected, }) : dense = dense ?? false, super(key: key); @@ -37,6 +40,7 @@ class CollectionNavTile extends StatelessWidget { trailing: trailing, dense: dense, onTap: () => _goToCollection(context), + selected: context.currentRouteName == CollectionPage.routeName && isSelected(), ), ); } @@ -61,10 +65,12 @@ class CollectionNavTile extends StatelessWidget { class AlbumNavTile extends StatelessWidget { final String album; + final bool Function() isSelected; const AlbumNavTile({ Key? key, required this.album, + required this.isSelected, }) : super(key: key); @override @@ -82,6 +88,7 @@ class AlbumNavTile extends StatelessWidget { ) : null, filter: filter, + isSelected: isSelected, ); } } diff --git a/lib/widgets/drawer/page_nav_tile.dart b/lib/widgets/drawer/page_nav_tile.dart index 465d1ac33..938b43367 100644 --- a/lib/widgets/drawer/page_nav_tile.dart +++ b/lib/widgets/drawer/page_nav_tile.dart @@ -40,20 +40,18 @@ class PageNavTile extends StatelessWidget { onTap: _pageBuilder != null ? () { Navigator.pop(context); - if (routeName != context.currentRouteName) { - final route = MaterialPageRoute( - settings: RouteSettings(name: routeName), - builder: _pageBuilder, + final route = MaterialPageRoute( + settings: RouteSettings(name: routeName), + builder: _pageBuilder, + ); + if (topLevel) { + Navigator.pushAndRemoveUntil( + context, + route, + (route) => false, ); - if (topLevel) { - Navigator.pushAndRemoveUntil( - context, - route, - (route) => false, - ); - } else { - Navigator.push(context, route); - } + } else { + Navigator.push(context, route); } } : null, diff --git a/lib/widgets/filter_grids/album_pick.dart b/lib/widgets/filter_grids/album_pick.dart index 06dbc1aa6..00c404f5c 100644 --- a/lib/widgets/filter_grids/album_pick.dart +++ b/lib/widgets/filter_grids/album_pick.dart @@ -131,11 +131,13 @@ class _AlbumPickAppBar extends StatelessWidget { switch (moveType) { case MoveType.copy: return context.l10n.albumPickPageTitleCopy; - case MoveType.export: - return context.l10n.albumPickPageTitleExport; case MoveType.move: return context.l10n.albumPickPageTitleMove; - default: + case MoveType.export: + return context.l10n.albumPickPageTitleExport; + case MoveType.toBin: + case MoveType.fromBin: + case null: return context.l10n.albumPickPageTitlePick; } } 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 8dd275552..bc49eebac 100644 --- a/lib/widgets/filter_grids/common/action_delegates/album_set.dart +++ b/lib/widgets/filter_grids/common/action_delegates/album_set.dart @@ -15,6 +15,7 @@ import 'package:aves/services/common/services.dart'; import 'package:aves/services/media/enums.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/utils/android_file_utils.dart'; +import 'package:aves/widgets/common/action_mixins/entry_storage.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/filter_editors/create_album_dialog.dart'; @@ -28,7 +29,7 @@ import 'package:flutter/scheduler.dart'; import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; -class AlbumChipSetActionDelegate extends ChipSetActionDelegate { +class AlbumChipSetActionDelegate extends ChipSetActionDelegate with EntryStorageMixin { final Iterable> _items; AlbumChipSetActionDelegate(Iterable> items) : _items = items; @@ -181,15 +182,30 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate { } Future _delete(BuildContext context, Set filters) async { - final l10n = context.l10n; - final messenger = ScaffoldMessenger.of(context); final source = context.read(); final todoEntries = source.visibleEntries.where((entry) => filters.any((f) => f.test(entry))).toSet(); - final todoCount = todoEntries.length; final todoAlbums = filters.map((v) => v.album).toSet(); final filledAlbums = todoEntries.map((e) => e.directory).whereNotNull().toSet(); final emptyAlbums = todoAlbums.whereNot(filledAlbums.contains).toSet(); + if (settings.enableBin && filledAlbums.isNotEmpty) { + await move( + context, + moveType: MoveType.toBin, + entries: todoEntries, + onSuccess: () { + source.forgetNewAlbums(todoAlbums); + source.cleanEmptyAlbums(emptyAlbums); + _browse(context); + }, + ); + return; + } + + final l10n = context.l10n; + final messenger = ScaffoldMessenger.of(context); + final todoCount = todoEntries.length; + final confirmed = await showDialog( context: context, builder: (context) { @@ -226,7 +242,7 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate { final successOps = processed.where((event) => event.success); final deletedOps = successOps.where((e) => !e.skipped).toSet(); final deletedUris = deletedOps.map((event) => event.uri).toSet(); - await source.removeEntries(deletedUris); + await source.removeEntries(deletedUris, includeTrash: true); _browse(context); source.resumeMonitoring(); @@ -285,9 +301,8 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate { context: context, opStream: mediaFileService.move( opId: opId, - entries: todoEntries, + entriesByDestination: {destinationAlbum: todoEntries}, copy: false, - destinationAlbum: destinationAlbum, // there should be no file conflict, as the target directory itself does not exist nameConflictStrategy: NameConflictStrategy.rename, ), diff --git a/lib/widgets/filter_grids/common/action_delegates/chip_set.dart b/lib/widgets/filter_grids/common/action_delegates/chip_set.dart index c55aa8d8d..ebfd59b05 100644 --- a/lib/widgets/filter_grids/common/action_delegates/chip_set.dart +++ b/lib/widgets/filter_grids/common/action_delegates/chip_set.dart @@ -278,8 +278,8 @@ abstract class ChipSetActionDelegate with FeedbackMi } void _setCover(BuildContext context, T filter) async { - final contentId = covers.coverContentId(filter); - final customEntry = context.read().visibleEntries.firstWhereOrNull((entry) => entry.contentId == contentId); + final entryId = covers.coverEntryId(filter); + final customEntry = context.read().visibleEntries.firstWhereOrNull((entry) => entry.id == entryId); final coverSelection = await showDialog>( context: context, builder: (context) => CoverSelectionDialog( @@ -290,7 +290,7 @@ abstract class ChipSetActionDelegate with FeedbackMi if (coverSelection == null) return; final isCustom = coverSelection.item1; - await covers.set(filter, isCustom ? coverSelection.item2?.contentId : null); + await covers.set(filter, isCustom ? coverSelection.item2?.id : null); _browse(context); } diff --git a/lib/widgets/map/map_page.dart b/lib/widgets/map/map_page.dart index 61195d8a6..41de8297a 100644 --- a/lib/widgets/map/map_page.dart +++ b/lib/widgets/map/map_page.dart @@ -267,7 +267,7 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin entryBuilder: (index) => index < regionEntries.length ? regionEntries[index] : null, indexNotifier: _selectedIndexNotifier, onTap: _onThumbnailTap, - heroTagger: (entry) => Object.hashAll([regionCollection?.id, entry.uri]), + heroTagger: (entry) => Object.hashAll([regionCollection?.id, entry.id]), highlightable: true, ); }, diff --git a/lib/widgets/settings/privacy/privacy.dart b/lib/widgets/settings/privacy/privacy.dart index d00919fc2..1a571dcd8 100644 --- a/lib/widgets/settings/privacy/privacy.dart +++ b/lib/widgets/settings/privacy/privacy.dart @@ -63,6 +63,20 @@ class PrivacySection extends StatelessWidget { title: Text(context.l10n.settingsSaveSearchHistory), ), ), + Selector( + selector: (context, s) => s.enableBin, + builder: (context, current, child) => SwitchListTile( + value: current, + onChanged: (v) { + settings.enableBin = v; + if (!v) { + settings.searchHistory = []; + } + }, + title: Text(context.l10n.settingsEnableBin), + subtitle: Text(context.l10n.settingsEnableBinSubtitle), + ), + ), const HiddenItemsTile(), if (device.canGrantDirectoryAccess) const StorageAccessTile(), ], diff --git a/lib/widgets/viewer/action/entry_action_delegate.dart b/lib/widgets/viewer/action/entry_action_delegate.dart index d90ba4284..8e98a8d4f 100644 --- a/lib/widgets/viewer/action/entry_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_action_delegate.dart @@ -9,6 +9,7 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/entry_metadata_edition.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/highlight.dart'; +import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/services/common/image_op_events.dart'; @@ -56,6 +57,9 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix case EntryAction.delete: _delete(context); break; + case EntryAction.restore: + _move(context, moveType: MoveType.fromBin); + break; case EntryAction.convert: _convert(context); break; @@ -163,11 +167,17 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix } Future _delete(BuildContext context) async { + if (settings.enableBin && !entry.trashed) { + await _move(context, moveType: MoveType.toBin); + return; + } + + final l10n = context.l10n; final confirmed = await showDialog( context: context, builder: (context) { return AvesDialog( - content: Text(context.l10n.deleteEntriesConfirmationDialogMessage(1)), + content: Text(l10n.deleteEntriesConfirmationDialogMessage(1)), actions: [ TextButton( onPressed: () => Navigator.pop(context), @@ -175,7 +185,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix ), TextButton( onPressed: () => Navigator.pop(context, true), - child: Text(context.l10n.deleteButtonLabel), + child: Text(l10n.deleteButtonLabel), ), ], ); @@ -186,11 +196,11 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix if (!await checkStoragePermission(context, {entry})) return; if (!await entry.delete()) { - showFeedback(context, context.l10n.genericFailureFeedback); + showFeedback(context, l10n.genericFailureFeedback); } else { final source = context.read(); if (source.initialized) { - await source.removeEntries({entry.uri}); + await source.removeEntries({entry.uri}, includeTrash: true); } EntryRemovedNotification(entry).dispatch(context); } @@ -300,8 +310,14 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix await move( context, moveType: moveType, - selectedItems: {entry}, - onSuccess: moveType == MoveType.move ? () => EntryRemovedNotification(entry).dispatch(context) : null, + entries: {entry}, + onSuccess: { + MoveType.move, + MoveType.toBin, + MoveType.fromBin, + }.contains(moveType) + ? () => EntryRemovedNotification(entry).dispatch(context) + : null, ); } diff --git a/lib/widgets/viewer/action/entry_info_action_delegate.dart b/lib/widgets/viewer/action/entry_info_action_delegate.dart index 77007ede6..727065f34 100644 --- a/lib/widgets/viewer/action/entry_info_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_info_action_delegate.dart @@ -9,7 +9,9 @@ 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'; import 'package:aves/widgets/viewer/action/single_entry_editor.dart'; +import 'package:aves/widgets/viewer/debug/debug_page.dart'; import 'package:aves/widgets/viewer/embedded/notifications.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEditorMixin, SingleEntryEditorMixin { @@ -35,6 +37,9 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi // motion photo case EntryInfoAction.viewMotionPhotoVideo: return entry.isMotionPhoto; + // debug + case EntryInfoAction.debug: + return kDebugMode; } } @@ -54,6 +59,9 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi // motion photo case EntryInfoAction.viewMotionPhotoVideo: return true; + // debug + case EntryInfoAction.debug: + return true; } } @@ -80,6 +88,10 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi case EntryInfoAction.viewMotionPhotoVideo: OpenEmbeddedDataNotification.motionPhotoVideo().dispatch(context); break; + // debug + case EntryInfoAction.debug: + _goToDebug(context); + break; } _eventStreamController.add(ActionEndedEvent(action)); } @@ -122,4 +134,14 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi await edit(context, () => entry.removeMetadata(types)); } + + void _goToDebug(BuildContext context) { + Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: ViewerDebugPage.routeName), + builder: (context) => ViewerDebugPage(entry: entry), + ), + ); + } } diff --git a/lib/widgets/viewer/debug/db.dart b/lib/widgets/viewer/debug/db.dart index ecc490ee5..5b38fab6c 100644 --- a/lib/widgets/viewer/debug/db.dart +++ b/lib/widgets/viewer/debug/db.dart @@ -1,6 +1,7 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/metadata/address.dart'; import 'package:aves/model/metadata/catalog.dart'; +import 'package:aves/model/metadata/trash.dart'; import 'package:aves/model/video_playback.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/widgets/viewer/info/common.dart'; @@ -24,6 +25,7 @@ class _DbTabState extends State { late Future _dbEntryLoader; late Future _dbMetadataLoader; late Future _dbAddressLoader; + late Future _dbTrashDetailsLoader; late Future _dbVideoPlaybackLoader; AvesEntry get entry => widget.entry; @@ -35,12 +37,13 @@ class _DbTabState extends State { } void _loadDatabase() { - final contentId = entry.contentId; - _dbDateLoader = metadataDb.loadDates().then((values) => values[contentId]); - _dbEntryLoader = metadataDb.loadAllEntries().then((values) => values.firstWhereOrNull((row) => row.contentId == contentId)); - _dbMetadataLoader = metadataDb.loadAllMetadataEntries().then((values) => values.firstWhereOrNull((row) => row.contentId == contentId)); - _dbAddressLoader = metadataDb.loadAllAddresses().then((values) => values.firstWhereOrNull((row) => row.contentId == contentId)); - _dbVideoPlaybackLoader = metadataDb.loadVideoPlayback(contentId); + final id = entry.id; + _dbDateLoader = metadataDb.loadDates().then((values) => values[id]); + _dbEntryLoader = metadataDb.loadAllEntries().then((values) => values.firstWhereOrNull((row) => row.id == id)); + _dbMetadataLoader = metadataDb.loadAllMetadataEntries().then((values) => values.firstWhereOrNull((row) => row.id == id)); + _dbAddressLoader = metadataDb.loadAllAddresses().then((values) => values.firstWhereOrNull((row) => row.id == id)); + _dbTrashDetailsLoader = metadataDb.loadAllTrashDetails().then((values) => values.firstWhereOrNull((row) => row.id == id)); + _dbVideoPlaybackLoader = metadataDb.loadVideoPlayback(id); setState(() {}); } @@ -94,6 +97,7 @@ class _DbTabState extends State { 'dateModifiedSecs': '${data.dateModifiedSecs}', 'sourceDateTakenMillis': '${data.sourceDateTakenMillis}', 'durationMillis': '${data.durationMillis}', + 'trashed': '${data.trashed}', }, ), ], @@ -155,6 +159,28 @@ class _DbTabState extends State { }, ), const SizedBox(height: 16), + FutureBuilder( + future: _dbTrashDetailsLoader, + builder: (context, snapshot) { + if (snapshot.hasError) return Text(snapshot.error.toString()); + if (snapshot.connectionState != ConnectionState.done) return const SizedBox(); + final data = snapshot.data; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('DB trash details:${data == null ? ' no row' : ''}'), + if (data != null) + InfoRowGroup( + info: { + 'dateMillis': '${data.dateMillis}', + 'path': data.path, + }, + ), + ], + ); + }, + ), + const SizedBox(height: 16), FutureBuilder( future: _dbVideoPlaybackLoader, builder: (context, snapshot) { diff --git a/lib/widgets/viewer/debug/debug_page.dart b/lib/widgets/viewer/debug/debug_page.dart index f916b96d5..7fe35f4a7 100644 --- a/lib/widgets/viewer/debug/debug_page.dart +++ b/lib/widgets/viewer/debug/debug_page.dart @@ -68,8 +68,9 @@ class ViewerDebugPage extends StatelessWidget { InfoRowGroup( info: { 'hash': '#${shortHash(entry)}', - 'uri': entry.uri, + 'id': '${entry.id}', 'contentId': '${entry.contentId}', + 'uri': entry.uri, 'path': entry.path ?? '', 'directory': entry.directory ?? '', 'filenameWithoutExtension': entry.filenameWithoutExtension ?? '', @@ -77,6 +78,7 @@ class ViewerDebugPage extends StatelessWidget { 'sourceTitle': entry.sourceTitle ?? '', 'sourceMimeType': entry.sourceMimeType, 'mimeType': entry.mimeType, + 'trashed': '${entry.trashed}', 'isMissingAtPath': '${entry.isMissingAtPath}', }, ), diff --git a/lib/widgets/viewer/entry_viewer_stack.dart b/lib/widgets/viewer/entry_viewer_stack.dart index cbf054fa4..7ce90aa63 100644 --- a/lib/widgets/viewer/entry_viewer_stack.dart +++ b/lib/widgets/viewer/entry_viewer_stack.dart @@ -97,7 +97,7 @@ class _EntryViewerStackState extends State with FeedbackMixin, // so it is, strictly speaking, not contained in the lens used by the viewer, // but it can be found by content ID final initialEntry = widget.initialEntry; - final entry = entries.firstWhereOrNull((v) => v.contentId == initialEntry.contentId) ?? entries.firstOrNull; + final entry = entries.firstWhereOrNull((entry) => entry.id == initialEntry.id) ?? entries.firstOrNull; // opening hero, with viewer as target _heroInfoNotifier.value = HeroInfo(collection?.id, entry); _entryNotifier.value = entry; diff --git a/lib/widgets/viewer/info/basic_section.dart b/lib/widgets/viewer/info/basic_section.dart index a750d91b7..91b3a97a3 100644 --- a/lib/widgets/viewer/info/basic_section.dart +++ b/lib/widgets/viewer/info/basic_section.dart @@ -70,11 +70,11 @@ class BasicSection extends StatelessWidget { if (entry.isVideo) ..._buildVideoRows(context), if (showResolution) l10n.viewerInfoLabelResolution: rasterResolutionText, l10n.viewerInfoLabelSize: sizeText, - l10n.viewerInfoLabelUri: entry.uri, + if (!entry.trashed) l10n.viewerInfoLabelUri: entry.uri, if (path != null) l10n.viewerInfoLabelPath: path, }, ), - OwnerProp(entry: entry), + if (!entry.trashed) OwnerProp(entry: entry), _buildChips(context), _buildEditButtons(context), ], diff --git a/lib/widgets/viewer/info/info_app_bar.dart b/lib/widgets/viewer/info/info_app_bar.dart index 0b4185886..beb758122 100644 --- a/lib/widgets/viewer/info/info_app_bar.dart +++ b/lib/widgets/viewer/info/info_app_bar.dart @@ -9,6 +9,7 @@ import 'package:aves/widgets/common/sliver_app_bar_title.dart'; import 'package:aves/widgets/viewer/action/entry_info_action_delegate.dart'; import 'package:aves/widgets/viewer/info/info_search.dart'; import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; @@ -53,7 +54,13 @@ class InfoAppBar extends StatelessWidget { if (entry.canEdit) MenuIconTheme( child: PopupMenuButton( - itemBuilder: (context) => menuActions.map((action) => _toMenuItem(context, action, enabled: actionDelegate.canApply(action))).toList(), + itemBuilder: (context) => [ + ...menuActions.map((action) => _toMenuItem(context, action, enabled: actionDelegate.canApply(action))), + if (!kReleaseMode) ...[ + const PopupMenuDivider(), + _toMenuItem(context, EntryInfoAction.debug, enabled: true), + ] + ], onSelected: (action) async { // wait for the popup menu to hide before proceeding with the action await Future.delayed(Durations.popupMenuAnimation * timeDilation); diff --git a/lib/widgets/viewer/overlay/bottom/common.dart b/lib/widgets/viewer/overlay/bottom/common.dart index 38e1b9912..775c418dd 100644 --- a/lib/widgets/viewer/overlay/bottom/common.dart +++ b/lib/widgets/viewer/overlay/bottom/common.dart @@ -384,7 +384,7 @@ class _PositionTitleRow extends StatelessWidget { // but fail to get information about these pages final pageCount = multiPageInfo.pageCount; if (pageCount > 0) { - final page = multiPageInfo.getById(entry.pageId ?? entry.contentId) ?? multiPageInfo.defaultPage; + final page = multiPageInfo.getById(entry.pageId ?? entry.id) ?? multiPageInfo.defaultPage; pagePosition = '${(page?.index ?? 0) + 1}/$pageCount'; } } diff --git a/lib/widgets/viewer/overlay/bottom/video.dart b/lib/widgets/viewer/overlay/bottom/video.dart index 7e453e924..564325bd5 100644 --- a/lib/widgets/viewer/overlay/bottom/video.dart +++ b/lib/widgets/viewer/overlay/bottom/video.dart @@ -67,14 +67,15 @@ class _VideoControlOverlayState extends State with SingleTi final status = controller?.status ?? VideoStatus.idle; Widget child; if (status == VideoStatus.error) { + const action = VideoAction.playOutside; child = Align( alignment: AlignmentDirectional.centerEnd, child: OverlayButton( scale: scale, child: IconButton( - icon: VideoAction.playOutside.getIcon(), - onPressed: () => widget.onActionSelected(VideoAction.playOutside), - tooltip: VideoAction.playOutside.getText(context), + icon: action.getIcon(), + onPressed: entry.trashed ? null : () => widget.onActionSelected(action), + tooltip: action.getText(context), ), ), ); @@ -327,13 +328,15 @@ class _ButtonRow extends StatelessWidget { case VideoAction.setSpeed: enabled = controller?.canSetSpeedNotifier.value ?? false; break; - case VideoAction.playOutside: case VideoAction.replay10: case VideoAction.skip10: case VideoAction.settings: case VideoAction.togglePlay: enabled = true; break; + case VideoAction.playOutside: + enabled = !(controller?.entry.trashed ?? true); + break; } Widget? child; diff --git a/lib/widgets/viewer/overlay/top.dart b/lib/widgets/viewer/overlay/top.dart index 7fb39b04a..dcd2d774a 100644 --- a/lib/widgets/viewer/overlay/top.dart +++ b/lib/widgets/viewer/overlay/top.dart @@ -65,47 +65,62 @@ class ViewerTopOverlay extends StatelessWidget { Widget _buildOverlay(BuildContext context, int availableCount, AvesEntry mainEntry, {AvesEntry? pageEntry}) { pageEntry ??= mainEntry; + final trashed = mainEntry.trashed; bool _isVisible(EntryAction action) { - final targetEntry = EntryActions.pageActions.contains(action) ? pageEntry! : mainEntry; - switch (action) { - case EntryAction.toggleFavourite: - 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.convert: - case EntryAction.print: - return !targetEntry.isVideo && device.canPrint; - case EntryAction.openMap: - return targetEntry.hasGps; - case EntryAction.viewSource: - return targetEntry.isSvg; - case EntryAction.rotateScreen: - return settings.isRotationLocked; - case EntryAction.addShortcut: - return device.canPinShortcut; - case EntryAction.copyToClipboard: - case EntryAction.edit: - case EntryAction.open: - case EntryAction.setAs: - case EntryAction.share: - return true; - case EntryAction.debug: - return kDebugMode; + if (trashed) { + switch (action) { + case EntryAction.delete: + case EntryAction.restore: + return true; + case EntryAction.debug: + return kDebugMode; + default: + return false; + } + } else { + final targetEntry = EntryActions.pageActions.contains(action) ? pageEntry! : mainEntry; + switch (action) { + case EntryAction.toggleFavourite: + 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.convert: + case EntryAction.print: + return !targetEntry.isVideo && device.canPrint; + case EntryAction.openMap: + return targetEntry.hasGps; + case EntryAction.viewSource: + return targetEntry.isSvg; + case EntryAction.rotateScreen: + return settings.isRotationLocked; + case EntryAction.addShortcut: + return device.canPinShortcut; + case EntryAction.copyToClipboard: + case EntryAction.edit: + case EntryAction.open: + case EntryAction.setAs: + case EntryAction.share: + return true; + case EntryAction.restore: + return false; + case EntryAction.debug: + return kDebugMode; + } } } final buttonRow = Selector( selector: (context, s) => s.isRotationLocked, builder: (context, s, child) { - final quickActions = settings.viewerQuickActions.where(_isVisible).take(availableCount - 1).toList(); + final quickActions = (trashed ? EntryActions.trashed : settings.viewerQuickActions).where(_isVisible).take(availableCount - 1).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( @@ -160,6 +175,7 @@ class _TopOverlayRow extends StatelessWidget { @override Widget build(BuildContext context) { + final hasOverflowMenu = pageEntry.canRotateAndFlip || topLevelActions.isNotEmpty || exportActions.isNotEmpty; return Row( children: [ OverlayButton( @@ -168,48 +184,50 @@ class _TopOverlayRow extends StatelessWidget { ), const Spacer(), ...quickActions.map((action) => _buildOverlayButton(context, action)), - OverlayButton( - scale: scale, - child: MenuIconTheme( - child: AvesPopupMenuButton( - key: const Key('entry-menu-button'), - 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)); - }, - onMenuOpened: () { - // if the menu is opened while overlay is hiding, - // the popup menu button is disposed and menu items are ineffective, - // so we make sure overlay stays visible - const ToggleOverlayNotification(visible: true).dispatch(context); - }, + if (hasOverflowMenu) + OverlayButton( + scale: scale, + child: MenuIconTheme( + child: AvesPopupMenuButton( + key: const Key('entry-menu-button'), + 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)), + if (exportActions.isNotEmpty) + 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)); + }, + onMenuOpened: () { + // if the menu is opened while overlay is hiding, + // the popup menu button is disposed and menu items are ineffective, + // so we make sure overlay stays visible + const ToggleOverlayNotification(visible: true).dispatch(context); + }, + ), ), ), - ), ], ); } @@ -226,7 +244,7 @@ class _TopOverlayRow extends StatelessWidget { break; default: child = IconButton( - icon: action.getIcon() ?? const SizedBox(), + icon: action.getIcon(), onPressed: onPressed, tooltip: action.getText(context), ); diff --git a/lib/widgets/viewer/video/controller.dart b/lib/widgets/viewer/video/controller.dart index fdeff47bf..58048fefa 100644 --- a/lib/widgets/viewer/video/controller.dart +++ b/lib/widgets/viewer/video/controller.dart @@ -27,36 +27,34 @@ abstract class AvesVideoController { } Future _savePlaybackState() async { - final contentId = entry.contentId; - if (contentId == null || !isReady || duration < resumeTimeSaveMinDuration.inMilliseconds) return; + final id = entry.id; + if (!isReady || duration < resumeTimeSaveMinDuration.inMilliseconds) return; if (persistPlayback) { final _progress = progress; if (resumeTimeSaveMinProgress < _progress && _progress < resumeTimeSaveMaxProgress) { await metadataDb.addVideoPlayback({ VideoPlaybackRow( - contentId: contentId, + entryId: id, resumeTimeMillis: currentPosition, ) }); } else { - await metadataDb.removeVideoPlayback({contentId}); + await metadataDb.removeVideoPlayback({id}); } } } Future getResumeTime(BuildContext context) async { - final contentId = entry.contentId; - if (contentId == null) return null; - if (!persistPlayback) return null; - final playback = await metadataDb.loadVideoPlayback(contentId); + final id = entry.id; + final playback = await metadataDb.loadVideoPlayback(id); final resumeTime = playback?.resumeTimeMillis ?? 0; if (resumeTime == 0) return null; // clear on retrieval - await metadataDb.removeVideoPlayback({contentId}); + await metadataDb.removeVideoPlayback({id}); final resume = await showDialog( context: context, diff --git a/lib/widgets/viewer/visual/entry_page_view.dart b/lib/widgets/viewer/visual/entry_page_view.dart index cb754bd1b..20ad5e563 100644 --- a/lib/widgets/viewer/visual/entry_page_view.dart +++ b/lib/widgets/viewer/visual/entry_page_view.dart @@ -143,7 +143,7 @@ class _EntryPageViewState extends State { if (animate) { child = Consumer( builder: (context, info, child) => Hero( - tag: info != null && info.entry == mainEntry ? Object.hashAll([info.collectionId, mainEntry.uri]) : hashCode, + tag: info != null && info.entry == mainEntry ? Object.hashAll([info.collectionId, mainEntry.id]) : hashCode, transitionOnUserGestures: true, child: child!, ), diff --git a/lib/widgets/viewer/visual/error.dart b/lib/widgets/viewer/visual/error.dart index e233637e7..e53ecc716 100644 --- a/lib/widgets/viewer/visual/error.dart +++ b/lib/widgets/viewer/visual/error.dart @@ -49,6 +49,7 @@ class _ErrorViewState extends State { icon: exists ? AIcons.error : AIcons.broken, text: exists ? context.l10n.viewerErrorUnknown : context.l10n.viewerErrorDoesNotExist, alignment: Alignment.center, + safeBottom: false, ); }), ), diff --git a/lib/widgets/viewer/visual/raster.dart b/lib/widgets/viewer/visual/raster.dart index 98ca8534e..f15a7df16 100644 --- a/lib/widgets/viewer/visual/raster.dart +++ b/lib/widgets/viewer/visual/raster.dart @@ -350,6 +350,7 @@ class _RegionTile extends StatefulWidget { @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); + properties.add(IntProperty('id', entry.id)); properties.add(IntProperty('contentId', entry.contentId)); properties.add(DiagnosticsProperty('tileRect', tileRect)); properties.add(DiagnosticsProperty>('regionRect', regionRect)); diff --git a/lib/widgets/viewer/visual/vector.dart b/lib/widgets/viewer/visual/vector.dart index 3901b8c35..dc41b5708 100644 --- a/lib/widgets/viewer/visual/vector.dart +++ b/lib/widgets/viewer/visual/vector.dart @@ -305,6 +305,7 @@ class _RegionTile extends StatefulWidget { @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); + properties.add(IntProperty('id', entry.id)); properties.add(IntProperty('contentId', entry.contentId)); properties.add(DiagnosticsProperty('tileRect', tileRect)); properties.add(DiagnosticsProperty>('regionRect', regionRect)); diff --git a/pubspec.lock b/pubspec.lock index c96adbee2..bc228cb42 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -883,12 +883,12 @@ packages: source: hosted version: "2.0.13" shared_preferences_android: - dependency: transitive + dependency: "direct main" description: name: shared_preferences_android url: "https://pub.dartlang.org" source: hosted - version: "2.0.11" + version: "2.0.10" shared_preferences_ios: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b83d90623..6398ea090 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -63,6 +63,8 @@ dependencies: provider: screen_brightness: shared_preferences: +# TODO TLAD as of 2022/02/12, latest version (v2.0.11) fails to load from analysis service (target wrong channel?) + shared_preferences_android: 2.0.10 sqflite: streams_channel: git: diff --git a/test/fake/media_file_service.dart b/test/fake/media_file_service.dart index 70b5e07f4..22cbb16b5 100644 --- a/test/fake/media_file_service.dart +++ b/test/fake/media_file_service.dart @@ -11,7 +11,7 @@ class FakeMediaFileService extends Fake implements MediaFileService { Iterable entries, { required String newName, }) { - final contentId = FakeMediaStoreService.nextContentId; + final contentId = FakeMediaStoreService.nextId; final entry = entries.first; return Stream.value(MoveOpEvent( success: true, diff --git a/test/fake/media_store_service.dart b/test/fake/media_store_service.dart index b32a368d9..50e37144f 100644 --- a/test/fake/media_store_service.dart +++ b/test/fake/media_store_service.dart @@ -9,27 +9,28 @@ class FakeMediaStoreService extends Fake implements MediaStoreService { Set entries = {}; @override - Future> checkObsoleteContentIds(List knownContentIds) => SynchronousFuture([]); + Future> checkObsoleteContentIds(List knownContentIds) => SynchronousFuture([]); @override - Future> checkObsoletePaths(Map knownPathById) => SynchronousFuture([]); + Future> checkObsoletePaths(Map knownPathById) => SynchronousFuture([]); @override - Stream getEntries(Map knownEntries) => Stream.fromIterable(entries); + Stream getEntries(Map knownEntries) => Stream.fromIterable(entries); - static var _lastContentId = 1; + static var _lastId = 1; - static int get nextContentId => _lastContentId++; + static int get nextId => _lastId++; static int get dateSecs => DateTime.now().millisecondsSinceEpoch ~/ 1000; static AvesEntry newImage(String album, String filenameWithoutExtension) { - final contentId = nextContentId; + final id = nextId; final date = dateSecs; return AvesEntry( - uri: 'content://media/external/images/media/$contentId', - contentId: contentId, + id: id, + uri: 'content://media/external/images/media/$id', path: '$album/$filenameWithoutExtension.jpg', + contentId: id, pageId: null, sourceMimeType: MimeTypes.jpeg, width: 360, @@ -40,11 +41,12 @@ class FakeMediaStoreService extends Fake implements MediaStoreService { dateModifiedSecs: date, sourceDateTakenMillis: date, durationMillis: null, + trashed: false, ); } static MoveOpEvent moveOpEventFor(AvesEntry entry, String sourceAlbum, String destinationAlbum) { - final newContentId = nextContentId; + final newContentId = nextId; return MoveOpEvent( success: true, skipped: false, diff --git a/test/fake/metadata_db.dart b/test/fake/metadata_db.dart index 595d2265e..a3c62df19 100644 --- a/test/fake/metadata_db.dart +++ b/test/fake/metadata_db.dart @@ -1,19 +1,25 @@ import 'package:aves/model/covers.dart'; +import 'package:aves/model/db/db_metadata.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/favourites.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/metadata/address.dart'; import 'package:aves/model/metadata/catalog.dart'; -import 'package:aves/model/db/db_metadata.dart'; +import 'package:aves/model/metadata/trash.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; class FakeMetadataDb extends Fake implements MetadataDb { + static int _lastId = 0; + + @override + int get nextId => ++_lastId; + @override Future init() => SynchronousFuture(null); @override - Future removeIds(Set contentIds, {Set? dataTypes}) => SynchronousFuture(null); + Future removeIds(Set ids, {Set? dataTypes}) => SynchronousFuture(null); // entries @@ -24,7 +30,7 @@ class FakeMetadataDb extends Fake implements MetadataDb { Future saveEntries(Iterable entries) => SynchronousFuture(null); @override - Future updateEntryId(int oldId, AvesEntry entry) => SynchronousFuture(null); + Future updateEntry(int id, AvesEntry entry) => SynchronousFuture(null); // date taken @@ -40,18 +46,29 @@ class FakeMetadataDb extends Fake implements MetadataDb { Future saveMetadata(Set metadataEntries) => SynchronousFuture(null); @override - Future updateMetadataId(int oldId, CatalogMetadata? metadata) => SynchronousFuture(null); + Future updateMetadata(int id, CatalogMetadata? metadata) => SynchronousFuture(null); // address @override - Future> loadAllAddresses() => SynchronousFuture([]); + Future> loadAllAddresses() => SynchronousFuture({}); @override Future saveAddresses(Set addresses) => SynchronousFuture(null); @override - Future updateAddressId(int oldId, AddressDetails? address) => SynchronousFuture(null); + Future updateAddress(int id, AddressDetails? address) => SynchronousFuture(null); + + // trash + + @override + Future clearTrashDetails() => SynchronousFuture(null); + + @override + Future> loadAllTrashDetails() => SynchronousFuture({}); + + @override + Future updateTrash(int id, TrashDetails? details) => SynchronousFuture(null); // favourites @@ -62,7 +79,7 @@ class FakeMetadataDb extends Fake implements MetadataDb { Future addFavourites(Iterable rows) => SynchronousFuture(null); @override - Future updateFavouriteId(int oldId, FavouriteRow row) => SynchronousFuture(null); + Future updateFavouriteId(int id, FavouriteRow row) => SynchronousFuture(null); @override Future removeFavourites(Iterable rows) => SynchronousFuture(null); @@ -76,7 +93,7 @@ class FakeMetadataDb extends Fake implements MetadataDb { Future addCovers(Iterable rows) => SynchronousFuture(null); @override - Future updateCoverEntryId(int oldId, CoverRow row) => SynchronousFuture(null); + Future updateCoverEntryId(int id, CoverRow row) => SynchronousFuture(null); @override Future removeCovers(Set filters) => SynchronousFuture(null); @@ -84,8 +101,5 @@ class FakeMetadataDb extends Fake implements MetadataDb { // video playback @override - Future updateVideoPlaybackId(int oldId, int? newId) => SynchronousFuture(null); - - @override - Future removeVideoPlayback(Set contentIds) => SynchronousFuture(null); + Future removeVideoPlayback(Set ids) => SynchronousFuture(null); } diff --git a/test/model/collection_source_test.dart b/test/model/collection_source_test.dart index 584ca3b81..8da7aab0c 100644 --- a/test/model/collection_source_test.dart +++ b/test/model/collection_source_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:aves/model/actions/move_type.dart'; import 'package:aves/model/availability.dart'; import 'package:aves/model/covers.dart'; import 'package:aves/model/db/db_metadata.dart'; @@ -45,6 +46,7 @@ void main() { const aTag = 'sometag'; final australiaLatLng = LatLng(-26, 141); const australiaAddress = AddressDetails( + id: 0, countryCode: 'AU', countryName: 'AUS', ); @@ -96,7 +98,7 @@ void main() { (metadataFetchService as FakeMetadataFetchService).setUp( image1, CatalogMetadata( - contentId: image1.contentId, + id: image1.id, xmpSubjects: aTag, latitude: australiaLatLng.latitude, longitude: australiaLatLng.longitude, @@ -119,7 +121,7 @@ void main() { (metadataFetchService as FakeMetadataFetchService).setUp( image1, CatalogMetadata( - contentId: image1.contentId, + id: image1.id, xmpSubjects: aTag, latitude: australiaLatLng.latitude, longitude: australiaLatLng.longitude, @@ -129,7 +131,7 @@ void main() { final source = await _initSource(); expect(image1.tags, {aTag}); - expect(image1.addressDetails, australiaAddress.copyWith(contentId: image1.contentId)); + expect(image1.addressDetails, australiaAddress.copyWith(id: image1.id)); expect(source.visibleEntries.length, 0); expect(source.rawAlbums.length, 0); @@ -168,15 +170,15 @@ void main() { const albumFilter = AlbumFilter(testAlbum, 'whatever'); expect(albumFilter.test(image1), true); expect(covers.count, 0); - expect(covers.coverContentId(albumFilter), null); + expect(covers.coverEntryId(albumFilter), null); - await covers.set(albumFilter, image1.contentId); + await covers.set(albumFilter, image1.id); expect(covers.count, 1); - expect(covers.coverContentId(albumFilter), image1.contentId); + expect(covers.coverEntryId(albumFilter), image1.id); await covers.set(albumFilter, null); expect(covers.count, 0); - expect(covers.coverContentId(albumFilter), null); + expect(covers.coverEntryId(albumFilter), null); }); test('favourites and covers are kept when renaming entries', () async { @@ -188,13 +190,13 @@ void main() { final source = await _initSource(); await image1.toggleFavourite(); const albumFilter = AlbumFilter(testAlbum, 'whatever'); - await covers.set(albumFilter, image1.contentId); + await covers.set(albumFilter, image1.id); await source.renameEntry(image1, 'image1b.jpg', persist: true); expect(favourites.count, 1); expect(image1.isFavourite, true); expect(covers.count, 1); - expect(covers.coverContentId(albumFilter), image1.contentId); + expect(covers.coverEntryId(albumFilter), image1.id); }); test('favourites and covers are cleared when removing entries', () async { @@ -206,13 +208,13 @@ void main() { final source = await _initSource(); await image1.toggleFavourite(); final albumFilter = AlbumFilter(image1.directory!, 'whatever'); - await covers.set(albumFilter, image1.contentId); - await source.removeEntries({image1.uri}); + await covers.set(albumFilter, image1.id); + await source.removeEntries({image1.uri}, includeTrash: true); expect(source.rawAlbums.length, 0); expect(favourites.count, 0); expect(covers.count, 0); - expect(covers.coverContentId(albumFilter), null); + expect(covers.coverEntryId(albumFilter), null); }); test('albums are updated when moving entries', () async { @@ -232,8 +234,8 @@ void main() { await source.updateAfterMove( todoEntries: {image1}, - copy: false, - destinationAlbum: destinationAlbum, + moveType: MoveType.move, + destinationAlbums: {destinationAlbum}, movedOps: { FakeMediaStoreService.moveOpEventFor(image1, sourceAlbum, destinationAlbum), }, @@ -256,8 +258,8 @@ void main() { await source.updateAfterMove( todoEntries: {image1}, - copy: false, - destinationAlbum: destinationAlbum, + moveType: MoveType.move, + destinationAlbums: {destinationAlbum}, movedOps: { FakeMediaStoreService.moveOpEventFor(image1, sourceAlbum, destinationAlbum), }, @@ -277,12 +279,12 @@ void main() { final source = await _initSource(); expect(source.rawAlbums.length, 1); const sourceAlbumFilter = AlbumFilter(sourceAlbum, 'whatever'); - await covers.set(sourceAlbumFilter, image1.contentId); + await covers.set(sourceAlbumFilter, image1.id); await source.updateAfterMove( todoEntries: {image1}, - copy: false, - destinationAlbum: destinationAlbum, + moveType: MoveType.move, + destinationAlbums: {destinationAlbum}, movedOps: { FakeMediaStoreService.moveOpEventFor(image1, sourceAlbum, destinationAlbum), }, @@ -290,7 +292,7 @@ void main() { expect(source.rawAlbums.length, 2); expect(covers.count, 0); - expect(covers.coverContentId(sourceAlbumFilter), null); + expect(covers.coverEntryId(sourceAlbumFilter), null); }); test('favourites and covers are kept when renaming albums', () async { @@ -302,7 +304,7 @@ void main() { final source = await _initSource(); await image1.toggleFavourite(); var albumFilter = const AlbumFilter(sourceAlbum, 'whatever'); - await covers.set(albumFilter, image1.contentId); + await covers.set(albumFilter, image1.id); await source.renameAlbum(sourceAlbum, destinationAlbum, { image1 }, { @@ -313,7 +315,7 @@ void main() { expect(favourites.count, 1); expect(image1.isFavourite, true); expect(covers.count, 1); - expect(covers.coverContentId(albumFilter), image1.contentId); + expect(covers.coverEntryId(albumFilter), image1.id); }); testWidgets('unique album names', (tester) async { diff --git a/untranslated.json b/untranslated.json index f3738f5d2..9b8810c6f 100644 --- a/untranslated.json +++ b/untranslated.json @@ -1,33 +1,66 @@ { "de": [ - "entryActionConvert" + "timeDays", + "entryActionConvert", + "entryActionRestore", + "binEntriesConfirmationDialogMessage", + "collectionActionEmptyBin", + "binPageTitle", + "settingsEnableBin", + "settingsEnableBinSubtitle" ], "es": [ - "entryActionConvert", - "entryInfoActionEditLocation", - "exportEntryDialogWidth", - "exportEntryDialogHeight", - "editEntryLocationDialogTitle", - "editEntryLocationDialogChooseOnMapTooltip", - "editEntryLocationDialogLatitude", - "editEntryLocationDialogLongitude", - "locationPickerUseThisLocationButton" + "timeDays", + "entryActionRestore", + "binEntriesConfirmationDialogMessage", + "collectionActionEmptyBin", + "binPageTitle", + "settingsEnableBin", + "settingsEnableBinSubtitle" ], "fr": [ - "entryActionConvert" + "timeDays", + "entryActionConvert", + "entryActionRestore", + "binEntriesConfirmationDialogMessage", + "collectionActionEmptyBin", + "binPageTitle", + "settingsEnableBin", + "settingsEnableBinSubtitle" ], "ko": [ - "entryActionConvert" + "timeDays", + "entryActionConvert", + "entryActionRestore", + "binEntriesConfirmationDialogMessage", + "collectionActionEmptyBin", + "binPageTitle", + "settingsEnableBin", + "settingsEnableBinSubtitle" ], "pt": [ - "entryActionConvert" + "timeDays", + "entryActionConvert", + "entryActionRestore", + "binEntriesConfirmationDialogMessage", + "collectionActionEmptyBin", + "binPageTitle", + "settingsEnableBin", + "settingsEnableBinSubtitle" ], "ru": [ - "entryActionConvert" + "timeDays", + "entryActionConvert", + "entryActionRestore", + "binEntriesConfirmationDialogMessage", + "collectionActionEmptyBin", + "binPageTitle", + "settingsEnableBin", + "settingsEnableBinSubtitle" ] }