#12 bin; entry id v content id

This commit is contained in:
Thibault Deckers 2022-02-18 09:51:26 +09:00
parent 6d1c0e6b2c
commit 0d9e0ca787
98 changed files with 1765 additions and 858 deletions

View file

@ -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"
}
}

View file

@ -52,12 +52,12 @@ class AnalysisHandler(private val activity: Activity, private val onAnalysisComp
}
// can be null or empty
val contentIds = call.argument<List<Int>>("contentIds")
val entryIds = call.argument<List<Int>>("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)

View file

@ -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(

View file

@ -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<List<Int>>("knownContentIds")
val knownContentIds = call.argument<List<Int?>>("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<Map<Int, String>>("knownPathById")
val knownPathById = call.argument<Map<Int?, String?>>("knownPathById")
if (knownPathById == null) {
result.error("checkObsoletePaths-args", "failed because of missing arguments", null)
return

View file

@ -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()

View file

@ -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) {}

View file

@ -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<String, List<AvesEntry>>()
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<FieldMap>
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)
})

View file

@ -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<Int, Int?>? = null
private var knownEntries: Map<Int?, Int?>? = null
init {
if (arguments is Map<*, *>) {
@Suppress("unchecked_cast")
knownEntries = arguments["knownEntries"] as Map<Int, Int?>?
knownEntries = arguments["knownEntries"] as Map<Int?, Int?>?
}
}
@ -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?) {}

View file

@ -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()
}
}

View file

@ -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?
}

View file

@ -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<MediaStoreImageProvider>()
}
}

View file

@ -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<AvesEntry>,
entriesByTargetDir: Map<String, List<AvesEntry>>,
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")

View file

@ -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<Int, Int?>, handleNewEntry: NewEntryHandler) {
fun fetchAll(context: Context, knownEntries: Map<Int?, Int?>, 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<Int>): List<Int> {
fun checkObsoleteContentIds(context: Context, knownContentIds: List<Int?>): List<Int> {
val foundContentIds = HashSet<Int>()
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<Int, String>): List<Int> {
fun checkObsoletePaths(context: Context, knownPathById: Map<Int?, String?>): List<Int> {
val obsoleteIds = ArrayList<Int>()
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,25 +333,44 @@ class MediaStoreImageProvider : ImageProvider() {
override suspend fun moveMultiple(
activity: Activity,
copy: Boolean,
targetDir: String,
nameConflictStrategy: NameConflictStrategy,
entries: List<AvesEntry>,
entriesByTargetDir: Map<String, List<AvesEntry>>,
isCancelledOp: CancelCheck,
callback: ImageOpCallback,
) {
val targetDirDocFile = StorageUtils.createDirectoryDocIfAbsent(activity, targetDir)
entriesByTargetDir.forEach { kv ->
val targetDir = kv.key
val entries = kv.value
val toBin = targetDir == StorageUtils.TRASH_PATH_PLACEHOLDER
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
}
}
for (entry in entries) {
val sourceUri = entry.uri
val sourcePath = entry.path
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" to sourceUri.toString(),
// `uri` should reference original content URI,
// so it is different with `sourceUri` when recycling trashed entries
"uri" to entry.uri.toString(),
"success" to false,
)
@ -368,18 +391,32 @@ class MediaStoreImageProvider : ImageProvider() {
// - 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(
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,
sourcePath = sourcePath,
sourceFile = sourceFile,
sourceUri = sourceUri,
targetDir = targetDir,
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)
}
@ -387,26 +424,28 @@ class MediaStoreImageProvider : ImageProvider() {
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)
}

View file

@ -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<PermissionManager>()
@ -94,11 +92,12 @@ object PermissionManager {
}
fun getInaccessibleDirectories(context: Context, dirPaths: List<String>): List<Map<String, String>> {
val concreteDirPaths = dirPaths.filter { it != StorageUtils.TRASH_PATH_PLACEHOLDER }
val accessibleDirs = getAccessibleDirs(context)
// find set of inaccessible directories for each volume
val dirsPerVolume = HashMap<String, MutableSet<String>>()
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)

View file

@ -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
}

View file

@ -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",

View file

@ -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:

View file

@ -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;
}
}
}

View file

@ -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:

View file

@ -1 +1 @@
enum MoveType { copy, move, export }
enum MoveType { copy, move, export, toBin, fromBin }

View file

@ -23,19 +23,19 @@ class Covers with ChangeNotifier {
Set<CoverRow> 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<void> set(CollectionFilter filter, int? contentId) async {
Future<void> 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<void> 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 {
Future<void> 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<void> removeEntries(Set<AvesEntry> entries) async {
final contentIds = entries.map((entry) => entry.contentId).toSet();
final removedRows = _rows.where((row) => contentIds.contains(row.contentId)).toSet();
Future<void> removeEntries(Set<AvesEntry> entries) => removeIds(entries.map((entry) => entry.id).toSet());
Future<void> removeIds(Set<int> 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<Object?> get props => [filter, contentId];
List<Object?> 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<String, dynamic> toMap() => {
'filter': filter.toJson(),
'contentId': contentId,
'entryId': entryId,
};
}

View file

@ -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<void> init();
Future<int> dbFileSize();
Future<void> reset();
Future<void> removeIds(Set<int> contentIds, {Set<EntryDataType>? dataTypes});
Future<void> removeIds(Set<int> ids, {Set<EntryDataType>? dataTypes});
// entries
@ -23,7 +26,7 @@ abstract class MetadataDb {
Future<void> saveEntries(Iterable<AvesEntry> entries);
Future<void> updateEntryId(int oldId, AvesEntry entry);
Future<void> updateEntry(int id, AvesEntry entry);
Future<Set<AvesEntry>> searchEntries(String query, {int? limit});
@ -43,17 +46,25 @@ abstract class MetadataDb {
Future<void> saveMetadata(Set<CatalogMetadata> metadataEntries);
Future<void> updateMetadataId(int oldId, CatalogMetadata? metadata);
Future<void> updateMetadata(int id, CatalogMetadata? metadata);
// address
Future<void> clearAddresses();
Future<List<AddressDetails>> loadAllAddresses();
Future<Set<AddressDetails>> loadAllAddresses();
Future<void> saveAddresses(Set<AddressDetails> addresses);
Future<void> updateAddressId(int oldId, AddressDetails? address);
Future<void> updateAddress(int id, AddressDetails? address);
// trash
Future<void> clearTrashDetails();
Future<Set<TrashDetails>> loadAllTrashDetails();
Future<void> updateTrash(int id, TrashDetails? details);
// favourites
@ -63,7 +74,7 @@ abstract class MetadataDb {
Future<void> addFavourites(Iterable<FavouriteRow> rows);
Future<void> updateFavouriteId(int oldId, FavouriteRow row);
Future<void> updateFavouriteId(int id, FavouriteRow row);
Future<void> removeFavourites(Iterable<FavouriteRow> rows);
@ -75,7 +86,7 @@ abstract class MetadataDb {
Future<void> addCovers(Iterable<CoverRow> rows);
Future<void> updateCoverEntryId(int oldId, CoverRow row);
Future<void> updateCoverEntryId(int id, CoverRow row);
Future<void> removeCovers(Set<CollectionFilter> filters);
@ -85,11 +96,9 @@ abstract class MetadataDb {
Future<Set<VideoPlaybackRow>> loadAllVideoPlayback();
Future<VideoPlaybackRow?> loadVideoPlayback(int? contentId);
Future<VideoPlaybackRow?> loadVideoPlayback(int? id);
Future<void> addVideoPlayback(Set<VideoPlaybackRow> rows);
Future<void> updateVideoPlaybackId(int oldId, int? newId);
Future<void> removeVideoPlayback(Set<int> contentIds);
Future<void> removeVideoPlayback(Set<int> ids);
}

View file

@ -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> _database;
late Database _db;
Future<String> 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<void> 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<void> reset() async {
debugPrint('$runtimeType reset');
await (await _database).close();
await _db.close();
await deleteDatabase(await path);
await init();
}
@override
Future<void> removeIds(Set<int> contentIds, {Set<EntryDataType>? dataTypes}) async {
if (contentIds.isEmpty) return;
Future<void> removeIds(Set<int> ids, {Set<EntryDataType>? 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<void> 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<Set<AvesEntry>> 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<Set<AvesEntry>> loadEntries(List<int> ids) async {
if (ids.isEmpty) return {};
final db = await _database;
final entries = <AvesEntry>{};
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<void> saveEntries(Iterable<AvesEntry> 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<void> updateEntryId(int oldId, AvesEntry entry) async {
final db = await _database;
final batch = db.batch();
batch.delete(entryTable, where: 'contentId = ?', whereArgs: [oldId]);
Future<void> 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<Set<AvesEntry>> 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<void> 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<Map<int?, int?>> 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<void> 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<List<CatalogMetadata>> 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<void> 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<void> 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<void> 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<List<AddressDetails>> loadAllAddresses() async {
final db = await _database;
final maps = await db.query(addressTable);
final addresses = maps.map(AddressDetails.fromMap).toList();
return addresses;
Future<Set<AddressDetails>> loadAllAddresses() async {
final rows = await _db.query(addressTable);
return rows.map(AddressDetails.fromMap).toSet();
}
@override
Future<void> saveAddresses(Set<AddressDetails> 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<void> updateAddressId(int oldId, AddressDetails? address) async {
final db = await _database;
final batch = db.batch();
batch.delete(addressTable, where: 'contentId = ?', whereArgs: [oldId]);
Future<void> 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<void> clearTrashDetails() async {
final count = await _db.delete(trashTable, where: '1');
debugPrint('$runtimeType clearTrashDetails deleted $count rows');
}
@override
Future<Set<TrashDetails>> loadAllTrashDetails() async {
final rows = await _db.query(trashTable);
return rows.map(TrashDetails.fromMap).toSet();
}
@override
Future<void> 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<void> 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<Set<FavouriteRow>> 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<void> addFavourites(Iterable<FavouriteRow> 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<void> updateFavouriteId(int oldId, FavouriteRow row) async {
final db = await _database;
final batch = db.batch();
batch.delete(favouriteTable, where: 'contentId = ?', whereArgs: [oldId]);
Future<void> 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<void> removeFavourites(Iterable<FavouriteRow> 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<void> 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<Set<CoverRow>> 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<void> addCovers(Iterable<CoverRow> 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<void> updateCoverEntryId(int oldId, CoverRow row) async {
final db = await _database;
final batch = db.batch();
batch.delete(coverTable, where: 'contentId = ?', whereArgs: [oldId]);
Future<void> 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<void> removeCovers(Set<CollectionFilter> 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<void> 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<Set<VideoPlaybackRow>> 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<VideoPlaybackRow?> loadVideoPlayback(int? contentId) async {
if (contentId == null) return null;
Future<VideoPlaybackRow?> 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<void> addVideoPlayback(Set<VideoPlaybackRow> 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<void> 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<void> removeVideoPlayback(Set<int> ids) async {
if (ids.isEmpty) return;
@override
Future<void> removeVideoPlayback(Set<int> 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);
}
}

View file

@ -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<void> _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'
')');
}
}

View file

@ -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<AvesEntry>? 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<AvesEntry>? 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<String, dynamic> 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<void> 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,

View file

@ -19,11 +19,11 @@ class Favourites with ChangeNotifier {
int get count => _rows.length;
Set<int> get all => Set.unmodifiable(_rows.map((v) => v.contentId));
Set<int> 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<void> add(Set<AvesEntry> entries) async {
final newRows = entries.map(_entryToRow);
@ -34,9 +34,10 @@ class Favourites with ChangeNotifier {
notifyListeners();
}
Future<void> remove(Set<AvesEntry> entries) async {
final contentIds = entries.map((entry) => entry.contentId).toSet();
final removedRows = _rows.where((row) => contentIds.contains(row.contentId)).toSet();
Future<void> removeEntries(Set<AvesEntry> entries) => removeIds(entries.map((entry) => entry.id).toSet());
Future<void> removeIds(Set<int> 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<void> 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<void> clear() async {
await metadataDb.clearFavourites();
_rows.clear();
@ -69,7 +57,7 @@ class Favourites with ChangeNotifier {
Map<String, List<String>>? 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<String, StorageVolume?>(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<Object?> get props => [contentId, path];
List<Object?> 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<String, dynamic> toMap() => {
'contentId': contentId,
'path': path,
'id': entryId,
};
}

View file

@ -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<CollectionFilter> {
static const List<String> categoryOrder = [
TrashFilter.type,
QueryFilter.type,
MimeFilter.type,
AlbumFilter.type,
@ -64,6 +66,8 @@ abstract class CollectionFilter extends Equatable implements Comparable<Collecti
return TagFilter.fromMap(jsonMap);
case TypeFilter.type:
return TypeFilter.fromMap(jsonMap);
case TrashFilter.type:
return TrashFilter.instance;
}
}
} catch (error, stack) {

View file

@ -22,7 +22,7 @@ class QueryFilter extends CollectionFilter {
var upQuery = query.toUpperCase();
if (upQuery.startsWith('ID:')) {
final id = int.tryParse(upQuery.substring(3));
_test = (entry) => entry.contentId == id;
_test = (entry) => entry.id == id;
return;
}

View file

@ -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<Object?> get props => [];
const TrashFilter._private();
@override
Map<String, dynamic> 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;
}

View file

@ -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<Object?> get props => [contentId, countryCode, countryName, adminArea, locality];
List<Object?> 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<String, dynamic> toMap() => {
'contentId': contentId,
'id': id,
'countryCode': countryCode,
'countryName': countryName,
'adminArea': adminArea,

View file

@ -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<String, dynamic> 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}';
}

View file

@ -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<Object?> 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<String, dynamic> toMap() => {
'id': id,
'path': path,
'dateMillis': dateMillis,
};
}

View file

@ -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

View file

@ -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

View file

@ -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<CollectionFilter> 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);

View file

@ -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<void> init() async {
try {
_prefs = await SharedPreferences.getInstance();
} catch (error, stack) {
debugPrint('$runtimeType init error=$error\n$stack');
}
}
@override

View file

@ -2,12 +2,12 @@ import 'package:flutter/foundation.dart';
class AnalysisController {
final bool canStartService, force;
final List<int>? contentIds;
final List<int>? entryIds;
final ValueNotifier<bool> stopSignal;
AnalysisController({
this.canStartService = true,
this.contentIds,
this.entryIds,
this.force = false,
ValueNotifier<bool>? stopSignal,
}) : stopSignal = stopSignal ?? ValueNotifier(false);

View file

@ -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<EntryAddedEvent>().listen((e) => _onEntryAdded(e.entries)));
_subscriptions.add(sourceEvents.on<EntryRemovedEvent>().listen((e) => _onEntryRemoved(e.entries)));
_subscriptions.add(sourceEvents.on<EntryMovedEvent>().listen((e) {
if (e.type == MoveType.move) {
// refreshing copied items is already handled via `EntryAddedEvent`s
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<EntryRefreshedEvent>().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) {

View file

@ -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<int?, AvesEntry> get entryById;
Map<int, AvesEntry> get entryById;
Set<AvesEntry> get visibleEntries;
Set<AvesEntry> get trashedEntries;
List<AvesEntry> get sortedEntriesByDate;
ValueNotifier<SourceState> 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<int?, AvesEntry> _entryById = {};
final Map<int, AvesEntry> _entryById = {};
@override
Map<int?, AvesEntry> get entryById => Map.unmodifiable(_entryById);
Map<int, AvesEntry> get entryById => Map.unmodifiable(_entryById);
final Set<AvesEntry> _rawEntries = {};
Set<AvesEntry> get allEntries => Set.unmodifiable(_rawEntries);
Set<AvesEntry>? _visibleEntries;
Set<AvesEntry>? _visibleEntries, _trashedEntries;
@override
Set<AvesEntry> get visibleEntries {
@ -65,6 +70,12 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
return _visibleEntries!;
}
@override
Set<AvesEntry> get trashedEntries {
_trashedEntries ??= Set.unmodifiable(_applyTrashFilter(_rawEntries));
return _trashedEntries!;
}
List<AvesEntry>? _sortedEntriesByDate;
@override
@ -73,6 +84,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
return _sortedEntriesByDate!;
}
// known date by entry ID
late Map<int?, int?> _savedDates;
Future<void> loadDates() async {
@ -80,12 +92,20 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
}
Iterable<AvesEntry> _applyHiddenFilters(Iterable<AvesEntry> 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<AvesEntry> _applyTrashFilter(Iterable<AvesEntry> entries) {
return entries.where(TrashFilter.instance.test);
}
void _invalidate([Set<AvesEntry>? 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<AvesEntry> 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<void> removeEntries(Set<String> uris) async {
Future<void> removeEntries(Set<String> 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<void> _moveEntry(AvesEntry entry, Map newFields, {required bool persist}) async {
final oldContentId = entry.contentId!;
final newContentId = newFields['contentId'] as int?;
entry.contentId = newContentId;
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
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.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.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<void> renameAlbum(String sourceAlbum, String destinationAlbum, Set<AvesEntry> todoEntries, Set<MoveOpEvent> movedOps) async {
Future<void> renameAlbum(String sourceAlbum, String destinationAlbum, Set<AvesEntry> entries, Set<MoveOpEvent> 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<void> updateAfterMove({
required Set<AvesEntry> todoEntries,
required bool copy,
required String destinationAlbum,
required MoveType moveType,
required Set<String> destinationAlbums,
required Set<MoveOpEvent> movedOps,
}) async {
if (movedOps.isEmpty) return;
final fromAlbums = <String?>{};
final movedEntries = <AvesEntry>{};
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) {
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);

View file

@ -23,7 +23,7 @@ mixin LocationMixin on SourceBase {
Future<void> 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,

View file

@ -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 Set<AvesEntry> topEntries = {};
if (settings.homePage == HomePageSetting.collection) {
final topIds = settings.topEntryIds;
late final Set<AvesEntry> topEntries;
if (topIds != null) {
debugPrint('$runtimeType refresh ${stopwatch.elapsed} load ${topIds.length} top entries');
topEntries = await metadataDb.loadEntries(topIds);
topEntries.addAll(await metadataDb.loadEntries(topIds));
addEntries(topEntries);
} else {
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<AvesEntry>? 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) {

View file

@ -17,7 +17,7 @@ mixin TagMixin on SourceBase {
Future<void> 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();
}

View file

@ -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<void> loadTrashDetails() async {
final saved = await metadataDb.loadAllTrashDetails();
final idMap = entryById;
saved.forEach((details) => idMap[details.id]?.trashDetails = details);
}
Future<Set<String>> deleteExpiredTrash() async {
final expiredEntries = trashedEntries.where((entry) => entry.isExpiredTrash).toSet();
if (expiredEntries.isEmpty) return {};
final processed = <ImageOpEvent>{};
final completer = Completer<Set<String>>();
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;
}
}

View file

@ -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,
);
}

View file

@ -3,25 +3,25 @@ import 'package:flutter/foundation.dart';
@immutable
class VideoPlaybackRow extends Equatable {
final int contentId, resumeTimeMillis;
final int entryId, resumeTimeMillis;
@override
List<Object?> get props => [contentId, resumeTimeMillis];
List<Object?> 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<String, dynamic> toMap() => {
'contentId': contentId,
'id': entryId,
'resumeTimeMillis': resumeTimeMillis,
};
}

View file

@ -25,10 +25,10 @@ class AnalysisService {
}
}
static Future<void> startService({required bool force, List<int>? contentIds}) async {
static Future<void> startService({required bool force, List<int>? entryIds}) async {
try {
await platform.invokeMethod('startService', <String, dynamic>{
'contentIds': contentIds,
'entryIds': entryIds,
'force': force,
});
} on PlatformException catch (e, stack) {
@ -98,16 +98,16 @@ class Analyzer {
}
Future<void> start(dynamic args) async {
debugPrint('$runtimeType start');
List<int>? contentIds;
List<int>? entryIds;
var force = false;
if (args is Map) {
contentIds = (args['contentIds'] as List?)?.cast<int>();
entryIds = (args['entryIds'] as List?)?.cast<int>();
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),
);

View file

@ -80,9 +80,8 @@ abstract class MediaFileService {
Stream<MoveOpEvent> move({
String? opId,
required Iterable<AvesEntry> entries,
required Map<String, Iterable<AvesEntry>> 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<MoveOpEvent> move({
String? opId,
required Iterable<AvesEntry> entries,
required Map<String, Iterable<AvesEntry>> entriesByDestination,
required bool copy,
required String destinationAlbum,
required NameConflictStrategy nameConflictStrategy,
}) {
try {
@ -353,9 +353,8 @@ class PlatformMediaFileService implements MediaFileService {
.receiveBroadcastStream(<String, dynamic>{
'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)

View file

@ -6,12 +6,12 @@ import 'package:flutter/services.dart';
import 'package:streams_channel/streams_channel.dart';
abstract class MediaStoreService {
Future<List<int>> checkObsoleteContentIds(List<int> knownContentIds);
Future<List<int>> checkObsoleteContentIds(List<int?> knownContentIds);
Future<List<int>> checkObsoletePaths(Map<int, String?> knownPathById);
Future<List<int>> checkObsoletePaths(Map<int?, String?> knownPathById);
// knownEntries: map of contentId -> dateModifiedSecs
Stream<AvesEntry> getEntries(Map<int, int> knownEntries);
Stream<AvesEntry> getEntries(Map<int?, int?> knownEntries);
// returns media URI
Future<Uri?> 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<List<int>> checkObsoleteContentIds(List<int> knownContentIds) async {
Future<List<int>> checkObsoleteContentIds(List<int?> knownContentIds) async {
try {
final result = await platform.invokeMethod('checkObsoleteContentIds', <String, dynamic>{
'knownContentIds': knownContentIds,
@ -35,7 +35,7 @@ class PlatformMediaStoreService implements MediaStoreService {
}
@override
Future<List<int>> checkObsoletePaths(Map<int, String?> knownPathById) async {
Future<List<int>> checkObsoletePaths(Map<int?, String?> knownPathById) async {
try {
final result = await platform.invokeMethod('checkObsoletePaths', <String, dynamic>{
'knownPathById': knownPathById,
@ -48,7 +48,7 @@ class PlatformMediaStoreService implements MediaStoreService {
}
@override
Stream<AvesEntry> getEntries(Map<int, int> knownEntries) {
Stream<AvesEntry> getEntries(Map<int?, int?> knownEntries) {
try {
return _streamChannel
.receiveBroadcastStream(<String, dynamic>{

View file

@ -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) {

View file

@ -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;

View file

@ -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<String> videoCapturesPaths;
Set<StorageVolume> storageVolumes = {};

View file

@ -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<AvesApp> 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');
}

View file

@ -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<CollectionAppBar> 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<CollectionFilter> 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<CollectionAppBar> 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<CollectionAppBar> with SingleTickerPr
);
} else {
final appMode = context.watch<ValueNotifier<AppMode>>().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<CollectionAppBar> 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<CollectionAppBar> 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<CollectionAppBar> with SingleTickerPr
...(isSelecting ? selectionMenuActions : browsingMenuActions).where(isVisible).map(
(action) => _toMenuItem(action, enabled: canApply(action), selection: selection),
),
if (isSelecting)
if (isSelecting && !isTrash)
PopupMenuItem<EntrySetAction>(
enabled: canApplyEditActions,
padding: EdgeInsets.zero,
@ -252,13 +259,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> 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<CollectionAppBar> 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:

View file

@ -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<CollectionGrid> {
@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,

View file

@ -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<CollectionPage> {
final List<StreamSubscription> _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<CollectionPage> {
child: const CollectionGrid(
// key is expected by test driver
key: Key('collection-grid'),
settingsRouteKey: CollectionPage.routeName,
),
),
),
@ -73,7 +93,7 @@ class _CollectionPageState extends State<CollectionPage> {
),
),
),
drawer: const AppDrawer(),
drawer: AppDrawer(currentCollection: collection),
resizeToAvoidBottomInset: false,
),
);

View file

@ -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<AvesEntry> _getExpandedSelectedItems(Selection<AvesEntry> selection) {
return selection.selectedItems.expand((entry) => entry.burstEntries ?? {entry}).toSet();
Set<AvesEntry> _getTargetItems(BuildContext context) {
final selection = context.read<Selection<AvesEntry>>();
final groupedEntries = (selection.isSelecting ? selection.selectedItems : context.read<CollectionLens>().sortedEntries);
return groupedEntries.expand((entry) => entry.burstEntries ?? {entry}).toSet();
}
void _share(BuildContext context) {
final selection = context.read<Selection<AvesEntry>>();
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<Selection<AvesEntry>>();
final collection = context.read<CollectionLens>();
final entries = (selection.isSelecting ? _getExpandedSelectedItems(selection) : collection.sortedEntries.toSet());
final entries = _getTargetItems(context);
final controller = AnalysisController(canStartService: true, force: true);
final collection = context.read<CollectionLens>();
collection.source.analyze(controller, entries: entries);
final selection = context.read<Selection<AvesEntry>>();
selection.browse();
}
Future<void> _toggleFavourite(BuildContext context) async {
final selection = context.read<Selection<AvesEntry>>();
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<AvesEntry>>();
selection.browse();
}
Future<void> _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<CollectionSource>();
final selection = context.read<Selection<AvesEntry>>();
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<bool>(
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<ImageOpEvent>(
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<AvesEntry>>();
selection.browse();
}
Future<void> _move(BuildContext context, {required MoveType moveType}) async {
final entries = _getTargetItems(context);
await move(context, moveType: moveType, entries: entries);
final selection = context.read<Selection<AvesEntry>>();
final selectedItems = _getExpandedSelectedItems(selection);
await move(context, moveType: moveType, selectedItems: selectedItems);
selection.browse();
}
Future<void> _edit(
BuildContext context,
Selection<AvesEntry> selection,
Set<AvesEntry> todoItems,
Future<Set<EntryDataType>> 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<AvesEntry>>();
selection.browse();
}
Future<Set<AvesEntry>?> _getEditableItems(
Future<Set<AvesEntry>?> _getEditableTargetItems(
BuildContext context, {
required Set<AvesEntry> selectedItems,
required bool Function(AvesEntry entry) canEdit,
}) async {
final bySupported = groupBy<AvesEntry, bool>(selectedItems, canEdit);
final bySupported = groupBy<AvesEntry, bool>(_getTargetItems(context), canEdit);
final supported = (bySupported[true] ?? []).toSet();
final unsupported = (bySupported[false] ?? []).toSet();
@ -396,70 +421,52 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
}
Future<void> _rotate(BuildContext context, {required bool clockwise}) async {
final selection = context.read<Selection<AvesEntry>>();
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<void> _flip(BuildContext context) async {
final selection = context.read<Selection<AvesEntry>>();
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<void> _editDate(BuildContext context) async {
final selection = context.read<Selection<AvesEntry>>();
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<void> _editLocation(BuildContext context) async {
final collection = context.read<CollectionLens>();
final selection = context.read<Selection<AvesEntry>>();
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<CollectionLens>();
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<void> _editRating(BuildContext context) async {
final selection = context.read<Selection<AvesEntry>>();
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<void> _editTags(BuildContext context) async {
final selection = context.read<Selection<AvesEntry>>();
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<void> _removeMetadata(BuildContext context) async {
final selection = context.read<Selection<AvesEntry>>();
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<Selection<AvesEntry>>();
final collection = context.read<CollectionLens>();
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<Selection<AvesEntry>>();
final collection = context.read<CollectionLens>();
final entries = selection.isSelecting ? _getExpandedSelectedItems(selection) : collection.sortedEntries.toSet();
final entries = _getTargetItems(context);
Navigator.push(
context,

View file

@ -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,

View file

@ -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<void> move(
BuildContext context, {
required MoveType moveType,
required Set<AvesEntry> selectedItems,
required Set<AvesEntry> 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<bool>(
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<CollectionSource>();
if (!source.initialized) {
// source may be uninitialized in viewer mode
@ -37,34 +69,49 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
unawaited(source.refresh());
}
final l10n = context.l10n;
final selectionDirs = selectedItems.map((e) => e.directory).whereNotNull().toSet();
final entriesByDestination = <String, Set<AvesEntry>>{};
switch (moveType) {
case MoveType.copy:
case MoveType.move:
case MoveType.export:
final destinationAlbum = await pickAlbum(context: context, moveType: moveType);
if (destinationAlbum == null) return;
if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return;
entriesByDestination[destinationAlbum] = entries;
break;
case MoveType.toBin:
entriesByDestination[AndroidFileUtils.trashDirPath] = entries;
break;
case MoveType.fromBin:
groupBy<AvesEntry, String?>(entries, (e) => e.directory).forEach((originAlbum, dirEntries) {
if (originAlbum != null) {
entriesByDestination[originAlbum] = dirEntries.toSet();
}
});
break;
}
if (moveType == MoveType.move && !await checkStoragePermissionForAlbums(context, selectionDirs, entries: selectedItems)) return;
// permission for modification at destinations
final destinationAlbums = entriesByDestination.keys.toSet();
if (!await checkStoragePermissionForAlbums(context, destinationAlbums)) return;
if (!await checkFreeSpaceForMove(context, selectedItems, destinationAlbum, moveType)) 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;
// 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();
await Future.forEach<String>(destinationAlbums, (destinationAlbum) async {
if (!await checkFreeSpaceForMove(context, entries, destinationAlbum, moveType)) return;
});
final copy = moveType == MoveType.copy;
final todoCount = todoItems.length;
assert(todoCount > 0);
final destinationDirectory = Directory(destinationAlbum);
var nameConflictStrategy = NameConflictStrategy.rename;
if (!toBin && destinationAlbums.length == 1) {
final destinationDirectory = Directory(destinationAlbums.single);
final names = [
...todoItems.map((v) => '${v.filenameWithoutExtension}${v.extension}'),
...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();
var nameConflictStrategy = NameConflictStrategy.rename;
if (uniqueNames.length < names.length) {
final value = await showDialog<NameConflictStrategy>(
context: context,
@ -72,7 +119,7 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
return AvesSelectionDialog<NameConflictStrategy>(
initialValue: nameConflictStrategy,
options: Map.fromEntries(NameConflictStrategy.values.map((v) => MapEntry(v, v.getName(context)))),
message: selectionDirs.length == 1 ? l10n.nameConflictDialogSingleSourceMessage : l10n.nameConflictDialogMultipleSourceMessage,
message: originAlbums.length == 1 ? l10n.nameConflictDialogSingleSourceMessage : l10n.nameConflictDialogMultipleSourceMessage,
confirmationButtonLabel: l10n.continueButtonLabel,
);
},
@ -80,6 +127,7 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
if (value == null) return;
nameConflictStrategy = value;
}
}
source.pauseMonitoring();
final opId = mediaFileService.newOpId;
@ -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<ValueNotifier<AppMode>>().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(

View file

@ -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<AvesEntry, StorageVolume?>(selection, (entry) => androidFileUtils.getStorageVolume(entry.path)).whereNotNullKey();
final otherVolumes = byVolume.keys.where((volume) => volume != destinationVolume);

View file

@ -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,
});
}

View file

@ -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<GridThemeData, double>((t) => t.fontSize),
),
child: child,
);
}
}
class OverlayIcon extends StatelessWidget {
final IconData icon;
final String? text;

View file

@ -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(
padding: safeBottom
? EdgeInsets.only(
bottom: context.select<MediaQueryData, double>((mq) => mq.effectiveBottomPadding),
),
)
: EdgeInsets.zero,
child: Align(
alignment: alignment,
child: Column(

View file

@ -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<AvesEntry>(
item: entry,

View file

@ -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<ErrorThumbnail> {
textAlign: TextAlign.center,
);
})
: Tooltip(
message: context.l10n.viewerErrorDoesNotExist,
preferBelow: false,
child: Icon(
: Icon(
AIcons.broken,
size: extent / 2,
color: color,
),
);
}
return Container(

View file

@ -35,6 +35,7 @@ class ThumbnailEntryOverlay extends StatelessWidget {
if (entry.isMotionPhoto && context.select<GridThemeData, bool>((t) => t.showMotionPhoto)) const MotionPhotoIcon(),
if (!entry.isMotionPhoto) MultiPageIcon(entry: entry),
],
if (entry.trashed && context.select<GridThemeData, bool>((t) => t.showTrash)) TrashIcon(trashDaysLeft: entry.trashDaysLeft),
];
if (children.isEmpty) return const SizedBox();
if (children.length == 1) return children.first;

View file

@ -86,6 +86,7 @@ class _ThumbnailScrollerState extends State<ThumbnailScroller> {
return GridTheme(
extent: extent,
showLocation: false,
showTrash: false,
child: SizedBox(
height: extent,
child: ListView.separated(

View file

@ -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<DebugAppDatabaseSection> with
late Future<Set<AvesEntry>> _dbEntryLoader;
late Future<Map<int?, int?>> _dbDateLoader;
late Future<List<CatalogMetadata>> _dbMetadataLoader;
late Future<List<AddressDetails>> _dbAddressLoader;
late Future<Set<AddressDetails>> _dbAddressLoader;
late Future<Set<TrashDetails>> _dbTrashLoader;
late Future<Set<FavouriteRow>> _dbFavouritesLoader;
late Future<Set<CoverRow>> _dbCoversLoader;
late Future<Set<VideoPlaybackRow>> _dbVideoPlaybackLoader;
@ -127,7 +129,7 @@ class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with
);
},
),
FutureBuilder<List>(
FutureBuilder<Set>(
future: _dbAddressLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
@ -148,6 +150,27 @@ class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with
);
},
),
FutureBuilder<Set>(
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<Set>(
future: _dbFavouritesLoader,
builder: (context, snapshot) {
@ -224,6 +247,7 @@ class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with
_dbDateLoader = metadataDb.loadDates();
_dbMetadataLoader = metadataDb.loadAllMetadataEntries();
_dbAddressLoader = metadataDb.loadAllAddresses();
_dbTrashLoader = metadataDb.loadAllTrashDetails();
_dbFavouritesLoader = metadataDb.loadAllFavourites();
_dbCoversLoader = metadataDb.loadAllCovers();
_dbVideoPlaybackLoader = metadataDb.loadAllVideoPlayback();

View file

@ -38,7 +38,7 @@ class _AddShortcutDialogState extends State<AddShortcutDialog> {
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;
}
}

View file

@ -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<String> getDefaultAlbums(BuildContext context) {
final source = context.read<CollectionSource>();
@ -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<Widget> _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<CollectionSource>();
final currentFilters = currentCollection?.filters;
return StreamBuilder(
stream: source.eventBus.on<AlbumsChangedEvent>(),
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'),

View file

@ -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,
);
}
}

View file

@ -40,7 +40,6 @@ class PageNavTile extends StatelessWidget {
onTap: _pageBuilder != null
? () {
Navigator.pop(context);
if (routeName != context.currentRouteName) {
final route = MaterialPageRoute(
settings: RouteSettings(name: routeName),
builder: _pageBuilder,
@ -55,7 +54,6 @@ class PageNavTile extends StatelessWidget {
Navigator.push(context, route);
}
}
}
: null,
selected: context.currentRouteName == routeName,
),

View file

@ -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;
}
}

View file

@ -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<AlbumFilter> {
class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> with EntryStorageMixin {
final Iterable<FilterGridItem<AlbumFilter>> _items;
AlbumChipSetActionDelegate(Iterable<FilterGridItem<AlbumFilter>> items) : _items = items;
@ -181,15 +182,30 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> {
}
Future<void> _delete(BuildContext context, Set<AlbumFilter> filters) async {
final l10n = context.l10n;
final messenger = ScaffoldMessenger.of(context);
final source = context.read<CollectionSource>();
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<bool>(
context: context,
builder: (context) {
@ -226,7 +242,7 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> {
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<AlbumFilter> {
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,
),

View file

@ -278,8 +278,8 @@ abstract class ChipSetActionDelegate<T extends CollectionFilter> with FeedbackMi
}
void _setCover(BuildContext context, T filter) async {
final contentId = covers.coverContentId(filter);
final customEntry = context.read<CollectionSource>().visibleEntries.firstWhereOrNull((entry) => entry.contentId == contentId);
final entryId = covers.coverEntryId(filter);
final customEntry = context.read<CollectionSource>().visibleEntries.firstWhereOrNull((entry) => entry.id == entryId);
final coverSelection = await showDialog<Tuple2<bool, AvesEntry?>>(
context: context,
builder: (context) => CoverSelectionDialog(
@ -290,7 +290,7 @@ abstract class ChipSetActionDelegate<T extends CollectionFilter> 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);
}

View file

@ -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,
);
},

View file

@ -63,6 +63,20 @@ class PrivacySection extends StatelessWidget {
title: Text(context.l10n.settingsSaveSearchHistory),
),
),
Selector<Settings, bool>(
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(),
],

View file

@ -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<void> _delete(BuildContext context) async {
if (settings.enableBin && !entry.trashed) {
await _move(context, moveType: MoveType.toBin);
return;
}
final l10n = context.l10n;
final confirmed = await showDialog<bool>(
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<CollectionSource>();
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,
);
}

View file

@ -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),
),
);
}
}

View file

@ -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<DbTab> {
late Future<AvesEntry?> _dbEntryLoader;
late Future<CatalogMetadata?> _dbMetadataLoader;
late Future<AddressDetails?> _dbAddressLoader;
late Future<TrashDetails?> _dbTrashDetailsLoader;
late Future<VideoPlaybackRow?> _dbVideoPlaybackLoader;
AvesEntry get entry => widget.entry;
@ -35,12 +37,13 @@ class _DbTabState extends State<DbTab> {
}
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<DbTab> {
'dateModifiedSecs': '${data.dateModifiedSecs}',
'sourceDateTakenMillis': '${data.sourceDateTakenMillis}',
'durationMillis': '${data.durationMillis}',
'trashed': '${data.trashed}',
},
),
],
@ -155,6 +159,28 @@ class _DbTabState extends State<DbTab> {
},
),
const SizedBox(height: 16),
FutureBuilder<TrashDetails?>(
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<VideoPlaybackRow?>(
future: _dbVideoPlaybackLoader,
builder: (context, snapshot) {

View file

@ -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}',
},
),

View file

@ -97,7 +97,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> 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;

View file

@ -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),
],

View file

@ -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<EntryInfoAction>(
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);

View file

@ -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';
}
}

View file

@ -67,14 +67,15 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> 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;

View file

@ -65,8 +65,20 @@ class ViewerTopOverlay extends StatelessWidget {
Widget _buildOverlay(BuildContext context, int availableCount, AvesEntry mainEntry, {AvesEntry? pageEntry}) {
pageEntry ??= mainEntry;
final trashed = mainEntry.trashed;
bool _isVisible(EntryAction action) {
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:
@ -97,15 +109,18 @@ class ViewerTopOverlay extends StatelessWidget {
case EntryAction.setAs:
case EntryAction.share:
return true;
case EntryAction.restore:
return false;
case EntryAction.debug:
return kDebugMode;
}
}
}
final buttonRow = Selector<Settings, bool>(
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,6 +184,7 @@ class _TopOverlayRow extends StatelessWidget {
),
const Spacer(),
...quickActions.map((action) => _buildOverlayButton(context, action)),
if (hasOverflowMenu)
OverlayButton(
scale: scale,
child: MenuIconTheme(
@ -179,6 +196,7 @@ class _TopOverlayRow extends StatelessWidget {
return [
if (pageEntry.canRotateAndFlip) _buildRotateAndFlipMenuItems(context),
...topLevelActions.map((action) => _buildPopupMenuItem(context, action)),
if (exportActions.isNotEmpty)
PopupMenuItem<EntryAction>(
padding: EdgeInsets.zero,
child: PopupMenuItemExpansionPanel<EntryAction>(
@ -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),
);

View file

@ -27,36 +27,34 @@ abstract class AvesVideoController {
}
Future<void> _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<int?> 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<bool>(
context: context,

View file

@ -143,7 +143,7 @@ class _EntryPageViewState extends State<EntryPageView> {
if (animate) {
child = Consumer<HeroInfo?>(
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!,
),

View file

@ -49,6 +49,7 @@ class _ErrorViewState extends State<ErrorView> {
icon: exists ? AIcons.error : AIcons.broken,
text: exists ? context.l10n.viewerErrorUnknown : context.l10n.viewerErrorDoesNotExist,
alignment: Alignment.center,
safeBottom: false,
);
}),
),

View file

@ -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<Rect>('tileRect', tileRect));
properties.add(DiagnosticsProperty<Rectangle<int>>('regionRect', regionRect));

View file

@ -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<Rect>('tileRect', tileRect));
properties.add(DiagnosticsProperty<Rectangle<double>>('regionRect', regionRect));

View file

@ -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:

View file

@ -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:

View file

@ -11,7 +11,7 @@ class FakeMediaFileService extends Fake implements MediaFileService {
Iterable<AvesEntry> entries, {
required String newName,
}) {
final contentId = FakeMediaStoreService.nextContentId;
final contentId = FakeMediaStoreService.nextId;
final entry = entries.first;
return Stream.value(MoveOpEvent(
success: true,

View file

@ -9,27 +9,28 @@ class FakeMediaStoreService extends Fake implements MediaStoreService {
Set<AvesEntry> entries = {};
@override
Future<List<int>> checkObsoleteContentIds(List<int> knownContentIds) => SynchronousFuture([]);
Future<List<int>> checkObsoleteContentIds(List<int?> knownContentIds) => SynchronousFuture([]);
@override
Future<List<int>> checkObsoletePaths(Map<int, String?> knownPathById) => SynchronousFuture([]);
Future<List<int>> checkObsoletePaths(Map<int?, String?> knownPathById) => SynchronousFuture([]);
@override
Stream<AvesEntry> getEntries(Map<int, int> knownEntries) => Stream.fromIterable(entries);
Stream<AvesEntry> getEntries(Map<int?, int?> 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,

View file

@ -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<void> init() => SynchronousFuture(null);
@override
Future<void> removeIds(Set<int> contentIds, {Set<EntryDataType>? dataTypes}) => SynchronousFuture(null);
Future<void> removeIds(Set<int> ids, {Set<EntryDataType>? dataTypes}) => SynchronousFuture(null);
// entries
@ -24,7 +30,7 @@ class FakeMetadataDb extends Fake implements MetadataDb {
Future<void> saveEntries(Iterable<AvesEntry> entries) => SynchronousFuture(null);
@override
Future<void> updateEntryId(int oldId, AvesEntry entry) => SynchronousFuture(null);
Future<void> updateEntry(int id, AvesEntry entry) => SynchronousFuture(null);
// date taken
@ -40,18 +46,29 @@ class FakeMetadataDb extends Fake implements MetadataDb {
Future<void> saveMetadata(Set<CatalogMetadata> metadataEntries) => SynchronousFuture(null);
@override
Future<void> updateMetadataId(int oldId, CatalogMetadata? metadata) => SynchronousFuture(null);
Future<void> updateMetadata(int id, CatalogMetadata? metadata) => SynchronousFuture(null);
// address
@override
Future<List<AddressDetails>> loadAllAddresses() => SynchronousFuture([]);
Future<Set<AddressDetails>> loadAllAddresses() => SynchronousFuture({});
@override
Future<void> saveAddresses(Set<AddressDetails> addresses) => SynchronousFuture(null);
@override
Future<void> updateAddressId(int oldId, AddressDetails? address) => SynchronousFuture(null);
Future<void> updateAddress(int id, AddressDetails? address) => SynchronousFuture(null);
// trash
@override
Future<void> clearTrashDetails() => SynchronousFuture(null);
@override
Future<Set<TrashDetails>> loadAllTrashDetails() => SynchronousFuture({});
@override
Future<void> updateTrash(int id, TrashDetails? details) => SynchronousFuture(null);
// favourites
@ -62,7 +79,7 @@ class FakeMetadataDb extends Fake implements MetadataDb {
Future<void> addFavourites(Iterable<FavouriteRow> rows) => SynchronousFuture(null);
@override
Future<void> updateFavouriteId(int oldId, FavouriteRow row) => SynchronousFuture(null);
Future<void> updateFavouriteId(int id, FavouriteRow row) => SynchronousFuture(null);
@override
Future<void> removeFavourites(Iterable<FavouriteRow> rows) => SynchronousFuture(null);
@ -76,7 +93,7 @@ class FakeMetadataDb extends Fake implements MetadataDb {
Future<void> addCovers(Iterable<CoverRow> rows) => SynchronousFuture(null);
@override
Future<void> updateCoverEntryId(int oldId, CoverRow row) => SynchronousFuture(null);
Future<void> updateCoverEntryId(int id, CoverRow row) => SynchronousFuture(null);
@override
Future<void> removeCovers(Set<CollectionFilter> filters) => SynchronousFuture(null);
@ -84,8 +101,5 @@ class FakeMetadataDb extends Fake implements MetadataDb {
// video playback
@override
Future<void> updateVideoPlaybackId(int oldId, int? newId) => SynchronousFuture(null);
@override
Future<void> removeVideoPlayback(Set<int> contentIds) => SynchronousFuture(null);
Future<void> removeVideoPlayback(Set<int> ids) => SynchronousFuture(null);
}

View file

@ -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 {

View file

@ -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"
]
}