#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 -> { COMMAND_START -> {
runBlocking { runBlocking {
FlutterUtils.runOnUiThread { 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( backgroundChannel?.invokeMethod(
"start", hashMapOf( "start", hashMapOf(
"contentIds" to contentIds, "entryIds" to entryIds,
"force" to data.getBoolean(KEY_FORCE), "force" to data.getBoolean(KEY_FORCE),
) )
) )
@ -197,7 +197,7 @@ class AnalysisService : MethodChannel.MethodCallHandler, Service() {
const val KEY_COMMAND = "command" const val KEY_COMMAND = "command"
const val COMMAND_START = "start" const val COMMAND_START = "start"
const val COMMAND_STOP = "stop" const val COMMAND_STOP = "stop"
const val KEY_CONTENT_IDS = "content_ids" const val KEY_ENTRY_IDS = "entry_ids"
const val KEY_FORCE = "force" 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 // 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)) { if (!activity.isMyServiceRunning(AnalysisService::class.java)) {
val intent = Intent(activity, AnalysisService::class.java) val intent = Intent(activity, AnalysisService::class.java)
intent.putExtra(AnalysisService.KEY_COMMAND, AnalysisService.COMMAND_START) 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) intent.putExtra(AnalysisService.KEY_FORCE, force)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
activity.startForegroundService(intent) activity.startForegroundService(intent)

View file

@ -69,6 +69,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
"filesDir" to context.filesDir, "filesDir" to context.filesDir,
"obbDir" to context.obbDir, "obbDir" to context.obbDir,
"externalCacheDir" to context.externalCacheDir, "externalCacheDir" to context.externalCacheDir,
"externalFilesDir" to context.getExternalFilesDir(null),
).apply { ).apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
putAll( putAll(
@ -82,6 +83,8 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
put("dataDir", context.dataDir) put("dataDir", context.dataDir)
} }
}.mapValues { it.value?.path }.toMutableMap() }.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` // used by flutter plugin `path_provider`
dirs.putAll( 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.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class MediaStoreHandler(private val context: Context) : MethodCallHandler { class MediaStoreHandler(private val context: Context) : MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) { when (call.method) {
"checkObsoleteContentIds" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::checkObsoleteContentIds) } "checkObsoleteContentIds" -> ioScope.launch { safe(call, result, ::checkObsoleteContentIds) }
"checkObsoletePaths" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::checkObsoletePaths) } "checkObsoletePaths" -> ioScope.launch { safe(call, result, ::checkObsoletePaths) }
"scanFile" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::scanFile) } "scanFile" -> ioScope.launch { safe(call, result, ::scanFile) }
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
private fun checkObsoleteContentIds(call: MethodCall, result: MethodChannel.Result) { 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) { if (knownContentIds == null) {
result.error("checkObsoleteContentIds-args", "failed because of missing arguments", null) result.error("checkObsoleteContentIds-args", "failed because of missing arguments", null)
return return
@ -32,7 +35,7 @@ class MediaStoreHandler(private val context: Context) : MethodCallHandler {
} }
private fun checkObsoletePaths(call: MethodCall, result: MethodChannel.Result) { 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) { if (knownPathById == null) {
result.error("checkObsoletePaths-args", "failed because of missing arguments", null) result.error("checkObsoletePaths-args", "failed because of missing arguments", null)
return 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.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File import java.io.File
import java.util.*
class StorageHandler(private val context: Context) : MethodCallHandler { class StorageHandler(private val context: Context) : MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) { when (call.method) {
"getStorageVolumes" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getStorageVolumes) } "getStorageVolumes" -> ioScope.launch { safe(call, result, ::getStorageVolumes) }
"getFreeSpace" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getFreeSpace) } "getFreeSpace" -> ioScope.launch { safe(call, result, ::getFreeSpace) }
"getGrantedDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getGrantedDirectories) } "getGrantedDirectories" -> ioScope.launch { safe(call, result, ::getGrantedDirectories) }
"getInaccessibleDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getInaccessibleDirectories) } "getInaccessibleDirectories" -> ioScope.launch { safe(call, result, ::getInaccessibleDirectories) }
"getRestrictedDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getRestrictedDirectories) } "getRestrictedDirectories" -> ioScope.launch { safe(call, result, ::getRestrictedDirectories) }
"revokeDirectoryAccess" -> safe(call, result, ::revokeDirectoryAccess) "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) "canRequestMediaFileBulkAccess" -> safe(call, result, ::canRequestMediaFileBulkAccess)
"canInsertMedia" -> safe(call, result, ::canInsertMedia) "canInsertMedia" -> safe(call, result, ::canInsertMedia)
else -> result.notImplemented() else -> result.notImplemented()

View file

@ -23,12 +23,11 @@ import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
import deckers.thibault.aves.utils.StorageUtils import deckers.thibault.aves.utils.StorageUtils
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.EventChannel.EventSink import io.flutter.plugin.common.EventChannel.EventSink
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.*
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.io.InputStream import java.io.InputStream
class ImageByteStreamHandler(private val context: Context, private val arguments: Any?) : EventChannel.StreamHandler { 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 eventSink: EventSink
private lateinit var handler: Handler private lateinit var handler: Handler
@ -36,7 +35,7 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
this.eventSink = eventSink this.eventSink = eventSink
handler = Handler(Looper.getMainLooper()) handler = Handler(Looper.getMainLooper())
GlobalScope.launch(Dispatchers.IO) { streamImage() } ioScope.launch { streamImage() }
} }
override fun onCancel(o: Any) {} 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.NameConflictStrategy
import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider 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.LogUtils
import deckers.thibault.aves.utils.StorageUtils import deckers.thibault.aves.utils.StorageUtils
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.EventChannel.EventSink import io.flutter.plugin.common.EventChannel.EventSink
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.* import java.io.File
class ImageOpStreamHandler(private val activity: Activity, private val arguments: Any?) : EventChannel.StreamHandler { 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 eventSink: EventSink
private lateinit var handler: Handler private lateinit var handler: Handler
@ -45,10 +48,10 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
handler = Handler(Looper.getMainLooper()) handler = Handler(Looper.getMainLooper())
when (op) { when (op) {
"delete" -> GlobalScope.launch(Dispatchers.IO) { delete() } "delete" -> ioScope.launch { delete() }
"export" -> GlobalScope.launch(Dispatchers.IO) { export() } "export" -> ioScope.launch { export() }
"move" -> GlobalScope.launch(Dispatchers.IO) { move() } "move" -> ioScope.launch { move() }
"rename" -> GlobalScope.launch(Dispatchers.IO) { rename() } "rename" -> ioScope.launch { rename() }
else -> endOfStream() else -> endOfStream()
} }
} }
@ -103,12 +106,16 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
val entries = entryMapList.map(::AvesEntry) val entries = entryMapList.map(::AvesEntry)
for (entry in entries) { for (entry in entries) {
val uri = entry.uri
val path = entry.path
val mimeType = entry.mimeType 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( 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()) { if (isCancelledOp()) {
result["skipped"] = true result["skipped"] = true
@ -160,30 +167,34 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
} }
private suspend fun move() { private suspend fun move() {
if (arguments !is Map<*, *> || entryMapList.isEmpty()) { if (arguments !is Map<*, *>) {
endOfStream() endOfStream()
return return
} }
val copy = arguments["copy"] as Boolean? val copy = arguments["copy"] as Boolean?
var destinationDir = arguments["destinationPath"] as String?
val nameConflictStrategy = NameConflictStrategy.get(arguments["nameConflictStrategy"] 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) error("move-args", "failed because of missing arguments", null)
return return
} }
// assume same provider for all entries val entriesByTargetDir = HashMap<String, List<AvesEntry>>()
val firstEntry = entryMapList.first() rawEntryMap.forEach {
val provider = (firstEntry["uri"] as String?)?.let { Uri.parse(it) }?.let { getProvider(it) } var destinationDir = it.key as String
if (provider == null) { if (destinationDir != StorageUtils.TRASH_PATH_PLACEHOLDER) {
error("move-provider", "failed to find provider for entry=$firstEntry", null) destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
return }
@Suppress("unchecked_cast")
val rawEntries = it.value as List<FieldMap>
entriesByTargetDir[destinationDir] = rawEntries.map(::AvesEntry)
} }
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir) // always use Media Store (as we move from or to it)
val entries = entryMapList.map(::AvesEntry) val provider = MediaStoreImageProvider()
provider.moveMultiple(activity, copy, destinationDir, nameConflictStrategy, entries, ::isCancelledOp, object : ImageOpCallback {
provider.moveMultiple(activity, copy, nameConflictStrategy, entriesByTargetDir, ::isCancelledOp, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = success(fields) override fun onSuccess(fields: FieldMap) = success(fields)
override fun onFailure(throwable: Throwable) = error("move-failure", "failed to move entries", throwable) 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 deckers.thibault.aves.utils.LogUtils
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.EventChannel.EventSink import io.flutter.plugin.common.EventChannel.EventSink
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : EventChannel.StreamHandler { 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 eventSink: EventSink
private lateinit var handler: Handler private lateinit var handler: Handler
private var knownEntries: Map<Int, Int?>? = null private var knownEntries: Map<Int?, Int?>? = null
init { init {
if (arguments is Map<*, *>) { if (arguments is Map<*, *>) {
@Suppress("unchecked_cast") @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 this.eventSink = eventSink
handler = Handler(Looper.getMainLooper()) handler = Handler(Looper.getMainLooper())
GlobalScope.launch(Dispatchers.IO) { fetchAll() } ioScope.launch { fetchAll() }
} }
override fun onCancel(arguments: Any?) {} override fun onCancel(arguments: Any?) {}

View file

@ -15,14 +15,13 @@ import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.PermissionManager import deckers.thibault.aves.utils.PermissionManager
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.EventChannel.EventSink import io.flutter.plugin.common.EventChannel.EventSink
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.*
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.io.FileOutputStream import java.io.FileOutputStream
// starting activity to give access with the native dialog // starting activity to give access with the native dialog
// breaks the regular `MethodChannel` so we use a stream channel instead // breaks the regular `MethodChannel` so we use a stream channel instead
class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?) : EventChannel.StreamHandler { 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 eventSink: EventSink
private lateinit var handler: Handler private lateinit var handler: Handler
@ -41,10 +40,10 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
handler = Handler(Looper.getMainLooper()) handler = Handler(Looper.getMainLooper())
when (op) { when (op) {
"requestDirectoryAccess" -> GlobalScope.launch(Dispatchers.IO) { requestDirectoryAccess() } "requestDirectoryAccess" -> ioScope.launch { requestDirectoryAccess() }
"requestMediaFileAccess" -> GlobalScope.launch(Dispatchers.IO) { requestMediaFileAccess() } "requestMediaFileAccess" -> ioScope.launch { requestMediaFileAccess() }
"createFile" -> GlobalScope.launch(Dispatchers.IO) { createFile() } "createFile" -> ioScope.launch { createFile() }
"openFile" -> GlobalScope.launch(Dispatchers.IO) { openFile() } "openFile" -> ioScope.launch { openFile() }
else -> endOfStream() else -> endOfStream()
} }
} }

View file

@ -11,4 +11,6 @@ class AvesEntry(map: FieldMap) {
val height = map["height"] as Int val height = map["height"] as Int
val rotationDegrees = map["rotationDegrees"] as Int val rotationDegrees = map["rotationDegrees"] as Int
val isFlipped = map["isFlipped"] as Boolean 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 package deckers.thibault.aves.model.provider
import android.app.Activity
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.util.Log
import deckers.thibault.aves.model.SourceEntry import deckers.thibault.aves.model.SourceEntry
import deckers.thibault.aves.utils.LogUtils
import java.io.File import java.io.File
internal class FileImageProvider : ImageProvider() { internal class FileImageProvider : ImageProvider() {
@ -33,4 +36,18 @@ internal class FileImageProvider : ImageProvider() {
callback.onFailure(Exception("entry has no size")) 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.IOException
import java.io.OutputStream import java.io.OutputStream
import java.util.* import java.util.*
import kotlin.collections.HashMap
abstract class ImageProvider { abstract class ImageProvider {
open fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) { open fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) {
@ -53,9 +52,8 @@ abstract class ImageProvider {
open suspend fun moveMultiple( open suspend fun moveMultiple(
activity: Activity, activity: Activity,
copy: Boolean, copy: Boolean,
targetDir: String,
nameConflictStrategy: NameConflictStrategy, nameConflictStrategy: NameConflictStrategy,
entries: List<AvesEntry>, entriesByTargetDir: Map<String, List<AvesEntry>>,
isCancelledOp: CancelCheck, isCancelledOp: CancelCheck,
callback: ImageOpCallback, callback: ImageOpCallback,
) { ) {
@ -245,7 +243,6 @@ abstract class ImageProvider {
// clearing Glide target should happen after effectively writing the bitmap // clearing Glide target should happen after effectively writing the bitmap
Glide.with(activity).clear(target) Glide.with(activity).clear(target)
} }
} }
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")

View file

@ -3,6 +3,7 @@ package deckers.thibault.aves.model.provider
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.app.RecoverableSecurityException import android.app.RecoverableSecurityException
import android.content.ContentResolver
import android.content.ContentUris import android.content.ContentUris
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
@ -31,13 +32,12 @@ import java.io.File
import java.io.OutputStream import java.io.OutputStream
import java.util.* import java.util.*
import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletableFuture
import kotlin.collections.ArrayList
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
class MediaStoreImageProvider : ImageProvider() { 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 isModified = fun(contentId: Int, dateModifiedSecs: Int): Boolean {
val knownDate = knownEntries[contentId] val knownDate = knownEntries[contentId]
return knownDate == null || knownDate < dateModifiedSecs 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>() val foundContentIds = HashSet<Int>()
fun check(context: Context, contentUri: Uri) { fun check(context: Context, contentUri: Uri) {
val projection = arrayOf(MediaStore.MediaColumns._ID) val projection = arrayOf(MediaStore.MediaColumns._ID)
@ -102,10 +102,10 @@ class MediaStoreImageProvider : ImageProvider() {
} }
check(context, IMAGE_CONTENT_URI) check(context, IMAGE_CONTENT_URI)
check(context, VIDEO_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>() val obsoleteIds = ArrayList<Int>()
fun check(context: Context, contentUri: Uri) { fun check(context: Context, contentUri: Uri) {
val projection = arrayOf(MediaStore.MediaColumns._ID, MediaColumns.PATH) val projection = arrayOf(MediaStore.MediaColumns._ID, MediaColumns.PATH)
@ -291,6 +291,10 @@ class MediaStoreImageProvider : ImageProvider() {
} }
throw Exception("failed to delete document with df=$df") 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 { try {
@ -329,84 +333,119 @@ class MediaStoreImageProvider : ImageProvider() {
override suspend fun moveMultiple( override suspend fun moveMultiple(
activity: Activity, activity: Activity,
copy: Boolean, copy: Boolean,
targetDir: String,
nameConflictStrategy: NameConflictStrategy, nameConflictStrategy: NameConflictStrategy,
entries: List<AvesEntry>, entriesByTargetDir: Map<String, List<AvesEntry>>,
isCancelledOp: CancelCheck, isCancelledOp: CancelCheck,
callback: ImageOpCallback, callback: ImageOpCallback,
) { ) {
val targetDirDocFile = StorageUtils.createDirectoryDocIfAbsent(activity, targetDir) entriesByTargetDir.forEach { kv ->
if (!File(targetDir).exists()) { val targetDir = kv.key
callback.onFailure(Exception("failed to create directory at path=$targetDir")) val entries = kv.value
return
}
for (entry in entries) { val toBin = targetDir == StorageUtils.TRASH_PATH_PLACEHOLDER
val sourceUri = entry.uri
val sourcePath = entry.path
val mimeType = entry.mimeType
val result: FieldMap = hashMapOf( var effectiveTargetDir: String? = null
"uri" to sourceUri.toString(), var targetDirDocFile: DocumentFileCompat? = null
"success" to false, if (!toBin) {
) effectiveTargetDir = targetDir
targetDirDocFile = StorageUtils.createDirectoryDocIfAbsent(activity, targetDir)
if (sourcePath != null) { if (!File(targetDir).exists()) {
// on API 30 we cannot get access granted directly to a volume root from its document tree, callback.onFailure(Exception("failed to create directory at path=$targetDir"))
// but it is still less constraining to use tree document files than to rely on the Media Store return
//
// Relying on `DocumentFile`, we can create an item via `DocumentFile.createFile()`, but:
// - we need to scan the file to get the Media Store content URI
// - the underlying document provider controls the new file name
//
// Relying on the Media Store, we can create an item via `ContentResolver.insert()`
// with a path, and retrieve its content URI, but:
// - the Media Store isolates content by storage volume (e.g. `MediaStore.Images.Media.getContentUri(volumeName)`)
// - the volume name should be lower case, not exactly as the `StorageVolume` UUID
// cf new method in API 30 `StorageVolume.getMediaStoreVolumeName()`
// - inserting on a removable volume works on API 29, but not on API 25 nor 26 (on which API/devices does it work?)
// - there is no documentation regarding support for usage with removable storage
// - the Media Store only allows inserting in specific primary directories ("DCIM", "Pictures") when using scoped storage
try {
val newFields = if (isCancelledOp()) skippedFieldMap else moveSingle(
activity = activity,
sourcePath = sourcePath,
sourceUri = sourceUri,
targetDir = targetDir,
targetDirDocFile = targetDirDocFile,
nameConflictStrategy = nameConflictStrategy,
mimeType = mimeType,
copy = copy,
)
result["newFields"] = newFields
result["success"] = true
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to move to targetDir=$targetDir entry with sourcePath=$sourcePath", e)
} }
} }
callback.onSuccess(result)
for (entry in entries) {
val mimeType = entry.mimeType
val trashed = entry.trashed
val sourceUri = if (trashed) Uri.fromFile(File(entry.trashPath!!)) else entry.uri
val sourcePath = if (trashed) entry.trashPath else entry.path
var desiredName: String? = null
if (trashed) {
entry.path?.let { desiredName = File(it).name }
}
val result: FieldMap = hashMapOf(
// `uri` should reference original content URI,
// so it is different with `sourceUri` when recycling trashed entries
"uri" to entry.uri.toString(),
"success" to false,
)
if (sourcePath != null) {
// on API 30 we cannot get access granted directly to a volume root from its document tree,
// but it is still less constraining to use tree document files than to rely on the Media Store
//
// Relying on `DocumentFile`, we can create an item via `DocumentFile.createFile()`, but:
// - we need to scan the file to get the Media Store content URI
// - the underlying document provider controls the new file name
//
// Relying on the Media Store, we can create an item via `ContentResolver.insert()`
// with a path, and retrieve its content URI, but:
// - the Media Store isolates content by storage volume (e.g. `MediaStore.Images.Media.getContentUri(volumeName)`)
// - the volume name should be lower case, not exactly as the `StorageVolume` UUID
// cf new method in API 30 `StorageVolume.getMediaStoreVolumeName()`
// - inserting on a removable volume works on API 29, but not on API 25 nor 26 (on which API/devices does it work?)
// - there is no documentation regarding support for usage with removable storage
// - the Media Store only allows inserting in specific primary directories ("DCIM", "Pictures") when using scoped storage
try {
if (toBin) {
val trashDir = StorageUtils.trashDirFor(activity, sourcePath)
if (trashDir != null) {
effectiveTargetDir = StorageUtils.ensureTrailingSeparator(trashDir.path)
targetDirDocFile = DocumentFileCompat.fromFile(trashDir)
}
}
if (effectiveTargetDir != null) {
val newFields = if (isCancelledOp()) skippedFieldMap else {
val sourceFile = File(sourcePath)
moveSingle(
activity = activity,
sourceFile = sourceFile,
sourceUri = sourceUri,
targetDir = effectiveTargetDir,
targetDirDocFile = targetDirDocFile,
desiredName = desiredName ?: sourceFile.name,
nameConflictStrategy = nameConflictStrategy,
mimeType = mimeType,
copy = copy,
toBin = toBin,
)
}
result["newFields"] = newFields
result["success"] = true
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to move to targetDir=$targetDir entry with sourcePath=$sourcePath", e)
}
}
callback.onSuccess(result)
}
} }
} }
private suspend fun moveSingle( private suspend fun moveSingle(
activity: Activity, activity: Activity,
sourcePath: String, sourceFile: File,
sourceUri: Uri, sourceUri: Uri,
targetDir: String, targetDir: String,
targetDirDocFile: DocumentFileCompat?, targetDirDocFile: DocumentFileCompat?,
desiredName: String,
nameConflictStrategy: NameConflictStrategy, nameConflictStrategy: NameConflictStrategy,
mimeType: String, mimeType: String,
copy: Boolean, copy: Boolean,
toBin: Boolean,
): FieldMap { ): FieldMap {
val sourceFile = File(sourcePath) val sourcePath = sourceFile.path
val sourceDir = sourceFile.parent?.let { StorageUtils.ensureTrailingSeparator(it) } val sourceDir = sourceFile.parent?.let { StorageUtils.ensureTrailingSeparator(it) }
if (sourceDir == targetDir && !(copy && nameConflictStrategy == NameConflictStrategy.RENAME)) { if (sourceDir == targetDir && !(copy && nameConflictStrategy == NameConflictStrategy.RENAME)) {
// nothing to do unless it's a renamed copy // nothing to do unless it's a renamed copy
return skippedFieldMap return skippedFieldMap
} }
val sourceFileName = sourceFile.name val desiredNameWithoutExtension = desiredName.replaceFirst(FILE_EXTENSION_PATTERN, "")
val desiredNameWithoutExtension = sourceFileName.replaceFirst(FILE_EXTENSION_PATTERN, "")
val targetNameWithoutExtension = resolveTargetFileNameWithoutExtension( val targetNameWithoutExtension = resolveTargetFileNameWithoutExtension(
activity = activity, activity = activity,
dir = targetDir, dir = targetDir,
@ -432,7 +471,12 @@ class MediaStoreImageProvider : ImageProvider() {
Log.w(LOG_TAG, "failed to delete entry with path=$sourcePath", e) 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) 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.model.FieldMap
import deckers.thibault.aves.utils.StorageUtils.PathSegments import deckers.thibault.aves.utils.StorageUtils.PathSegments
import java.io.File import java.io.File
import java.util.*
import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletableFuture
import kotlin.collections.ArrayList
object PermissionManager { object PermissionManager {
private val LOG_TAG = LogUtils.createTag<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>> { fun getInaccessibleDirectories(context: Context, dirPaths: List<String>): List<Map<String, String>> {
val concreteDirPaths = dirPaths.filter { it != StorageUtils.TRASH_PATH_PLACEHOLDER }
val accessibleDirs = getAccessibleDirs(context) val accessibleDirs = getAccessibleDirs(context)
// find set of inaccessible directories for each volume // find set of inaccessible directories for each volume
val dirsPerVolume = HashMap<String, MutableSet<String>>() 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) }) { if (accessibleDirs.none { dirPath.startsWith(it) }) {
// inaccessible dirs // inaccessible dirs
val segments = PathSegments(context, dirPath) val segments = PathSegments(context, dirPath)
@ -211,7 +210,8 @@ object PermissionManager {
) )
}) })
} else if (Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT } 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 // removable storage requires access permission, at the file level
// without directory access, we consider the whole volume restricted // without directory access, we consider the whole volume restricted
val primaryVolume = StorageUtils.getPrimaryVolumePath(context) 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 const val TREE_URI_ROOT = "content://com.android.externalstorage.documents/tree/"
private val TREE_URI_PATH_PATTERN = Pattern.compile("(.*?):(.*)") 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 * Volume paths
*/ */
@ -408,10 +434,11 @@ object StorageUtils {
fun canEditByFile(context: Context, path: String) = !requireAccessPermission(context, path) fun canEditByFile(context: Context, path: String) = !requireAccessPermission(context, path)
fun requireAccessPermission(context: Context, anyPath: String): Boolean { 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 // on Android R, we should always require access permission, even on primary volume
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) return true
return true
}
val onPrimaryVolume = anyPath.startsWith(getPrimaryVolumePath(context)) val onPrimaryVolume = anyPath.startsWith(getPrimaryVolumePath(context))
return !onPrimaryVolume return !onPrimaryVolume
} }

View file

@ -22,6 +22,12 @@
"minutes": {} "minutes": {}
} }
}, },
"timeDays": "{days, plural, =1{1 day} other{{days} days}}",
"@timeDays": {
"placeholders": {
"days": {}
}
},
"focalLength": "{length} mm", "focalLength": "{length} mm",
"@focalLength": { "@focalLength": {
"placeholders": { "placeholders": {
@ -72,6 +78,7 @@
"entryActionConvert": "Convert", "entryActionConvert": "Convert",
"entryActionExport": "Export", "entryActionExport": "Export",
"entryActionRename": "Rename", "entryActionRename": "Rename",
"entryActionRestore": "Restore",
"entryActionRotateCCW": "Rotate counterclockwise", "entryActionRotateCCW": "Rotate counterclockwise",
"entryActionRotateCW": "Rotate clockwise", "entryActionRotateCW": "Rotate clockwise",
"entryActionFlip": "Flip horizontally", "entryActionFlip": "Flip horizontally",
@ -254,6 +261,12 @@
"noMatchingAppDialogTitle": "No Matching App", "noMatchingAppDialogTitle": "No Matching App",
"noMatchingAppDialogMessage": "There are no apps that can handle this.", "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": "{count, plural, =1{Are you sure you want to delete this item?} other{Are you sure you want to delete these {count} items?}}",
"@deleteEntriesConfirmationDialogMessage": { "@deleteEntriesConfirmationDialogMessage": {
"placeholders": { "placeholders": {
@ -409,6 +422,7 @@
"collectionActionShowTitleSearch": "Show title filter", "collectionActionShowTitleSearch": "Show title filter",
"collectionActionHideTitleSearch": "Hide title filter", "collectionActionHideTitleSearch": "Hide title filter",
"collectionActionAddShortcut": "Add shortcut", "collectionActionAddShortcut": "Add shortcut",
"collectionActionEmptyBin": "Empty bin",
"collectionActionCopy": "Copy to album", "collectionActionCopy": "Copy to album",
"collectionActionMove": "Move to album", "collectionActionMove": "Move to album",
"collectionActionRescan": "Rescan", "collectionActionRescan": "Rescan",
@ -527,6 +541,8 @@
"tagPageTitle": "Tags", "tagPageTitle": "Tags",
"tagEmpty": "No tags", "tagEmpty": "No tags",
"binPageTitle": "Recycle Bin",
"searchCollectionFieldHint": "Search collection", "searchCollectionFieldHint": "Search collection",
"searchSectionRecent": "Recent", "searchSectionRecent": "Recent",
"searchSectionAlbums": "Albums", "searchSectionAlbums": "Albums",
@ -627,6 +643,8 @@
"settingsAllowInstalledAppAccessSubtitle": "Used to improve album display", "settingsAllowInstalledAppAccessSubtitle": "Used to improve album display",
"settingsAllowErrorReporting": "Allow anonymous error reporting", "settingsAllowErrorReporting": "Allow anonymous error reporting",
"settingsSaveSearchHistory": "Save search history", "settingsSaveSearchHistory": "Save search history",
"settingsEnableBin": "Use recycle bin",
"settingsEnableBinSubtitle": "Keep deleted items for 30 days",
"settingsHiddenItemsTile": "Hidden items", "settingsHiddenItemsTile": "Hidden items",
"settingsHiddenItemsTitle": "Hidden Items", "settingsHiddenItemsTitle": "Hidden Items",

View file

@ -7,6 +7,7 @@ enum EntryAction {
addShortcut, addShortcut,
copyToClipboard, copyToClipboard,
delete, delete,
restore,
convert, convert,
print, print,
rename, rename,
@ -65,6 +66,12 @@ class EntryActions {
EntryAction.rotateCW, EntryAction.rotateCW,
EntryAction.flip, EntryAction.flip,
]; ];
static const trashed = [
EntryAction.delete,
EntryAction.restore,
EntryAction.debug,
];
} }
extension ExtraEntryAction on EntryAction { extension ExtraEntryAction on EntryAction {
@ -76,6 +83,8 @@ extension ExtraEntryAction on EntryAction {
return context.l10n.entryActionCopyToClipboard; return context.l10n.entryActionCopyToClipboard;
case EntryAction.delete: case EntryAction.delete:
return context.l10n.entryActionDelete; return context.l10n.entryActionDelete;
case EntryAction.restore:
return context.l10n.entryActionRestore;
case EntryAction.convert: case EntryAction.convert:
return context.l10n.entryActionConvert; return context.l10n.entryActionConvert;
case EntryAction.print: case EntryAction.print:
@ -119,11 +128,8 @@ extension ExtraEntryAction on EntryAction {
} }
} }
Widget? getIcon() { Widget getIcon() {
final icon = getIconData(); final child = Icon(getIconData());
if (icon == null) return null;
final child = Icon(icon);
switch (this) { switch (this) {
case EntryAction.debug: case EntryAction.debug:
return ShaderMask( return ShaderMask(
@ -135,7 +141,7 @@ extension ExtraEntryAction on EntryAction {
} }
} }
IconData? getIconData() { IconData getIconData() {
switch (this) { switch (this) {
case EntryAction.addShortcut: case EntryAction.addShortcut:
return AIcons.addShortcut; return AIcons.addShortcut;
@ -143,6 +149,8 @@ extension ExtraEntryAction on EntryAction {
return AIcons.clipboard; return AIcons.clipboard;
case EntryAction.delete: case EntryAction.delete:
return AIcons.delete; return AIcons.delete;
case EntryAction.restore:
return AIcons.restore;
case EntryAction.convert: case EntryAction.convert:
return AIcons.convert; return AIcons.convert;
case EntryAction.print: case EntryAction.print:

View file

@ -1,4 +1,5 @@
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/theme/themes.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@ -11,6 +12,8 @@ enum EntryInfoAction {
removeMetadata, removeMetadata,
// motion photo // motion photo
viewMotionPhotoVideo, viewMotionPhotoVideo,
// debug
debug,
} }
class EntryInfoActions { class EntryInfoActions {
@ -41,11 +44,23 @@ extension ExtraEntryInfoAction on EntryInfoAction {
// motion photo // motion photo
case EntryInfoAction.viewMotionPhotoVideo: case EntryInfoAction.viewMotionPhotoVideo:
return context.l10n.entryActionViewMotionPhotoVideo; return context.l10n.entryActionViewMotionPhotoVideo;
// debug
case EntryInfoAction.debug:
return 'Debug';
} }
} }
Widget getIcon() { 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() { IconData _getIconData() {
@ -64,6 +79,9 @@ extension ExtraEntryInfoAction on EntryInfoAction {
// motion photo // motion photo
case EntryInfoAction.viewMotionPhotoVideo: case EntryInfoAction.viewMotionPhotoVideo:
return AIcons.motionPhoto; return AIcons.motionPhoto;
// debug
case EntryInfoAction.debug:
return AIcons.debug;
} }
} }
} }

View file

@ -12,6 +12,7 @@ enum EntrySetAction {
searchCollection, searchCollection,
toggleTitleSearch, toggleTitleSearch,
addShortcut, addShortcut,
emptyBin,
// browsing or selecting // browsing or selecting
map, map,
stats, stats,
@ -19,6 +20,7 @@ enum EntrySetAction {
// selecting // selecting
share, share,
delete, delete,
restore,
copy, copy,
move, move,
toggleFavourite, toggleFavourite,
@ -47,11 +49,13 @@ class EntrySetActions {
EntrySetAction.map, EntrySetAction.map,
EntrySetAction.stats, EntrySetAction.stats,
EntrySetAction.rescan, EntrySetAction.rescan,
EntrySetAction.emptyBin,
]; ];
static const selection = [ static const selection = [
EntrySetAction.share, EntrySetAction.share,
EntrySetAction.delete, EntrySetAction.delete,
EntrySetAction.restore,
EntrySetAction.copy, EntrySetAction.copy,
EntrySetAction.move, EntrySetAction.move,
EntrySetAction.toggleFavourite, EntrySetAction.toggleFavourite,
@ -60,6 +64,14 @@ class EntrySetActions {
EntrySetAction.rescan, EntrySetAction.rescan,
// editing actions are in their subsection // editing actions are in their subsection
]; ];
static const edit = [
EntrySetAction.editDate,
EntrySetAction.editLocation,
EntrySetAction.editRating,
EntrySetAction.editTags,
EntrySetAction.removeMetadata,
];
} }
extension ExtraEntrySetAction on EntrySetAction { extension ExtraEntrySetAction on EntrySetAction {
@ -82,6 +94,8 @@ extension ExtraEntrySetAction on EntrySetAction {
return context.l10n.collectionActionShowTitleSearch; return context.l10n.collectionActionShowTitleSearch;
case EntrySetAction.addShortcut: case EntrySetAction.addShortcut:
return context.l10n.collectionActionAddShortcut; return context.l10n.collectionActionAddShortcut;
case EntrySetAction.emptyBin:
return context.l10n.collectionActionEmptyBin;
// browsing or selecting // browsing or selecting
case EntrySetAction.map: case EntrySetAction.map:
return context.l10n.menuActionMap; return context.l10n.menuActionMap;
@ -94,6 +108,8 @@ extension ExtraEntrySetAction on EntrySetAction {
return context.l10n.entryActionShare; return context.l10n.entryActionShare;
case EntrySetAction.delete: case EntrySetAction.delete:
return context.l10n.entryActionDelete; return context.l10n.entryActionDelete;
case EntrySetAction.restore:
return context.l10n.entryActionRestore;
case EntrySetAction.copy: case EntrySetAction.copy:
return context.l10n.collectionActionCopy; return context.l10n.collectionActionCopy;
case EntrySetAction.move: case EntrySetAction.move:
@ -143,6 +159,8 @@ extension ExtraEntrySetAction on EntrySetAction {
return AIcons.filter; return AIcons.filter;
case EntrySetAction.addShortcut: case EntrySetAction.addShortcut:
return AIcons.addShortcut; return AIcons.addShortcut;
case EntrySetAction.emptyBin:
return AIcons.emptyBin;
// browsing or selecting // browsing or selecting
case EntrySetAction.map: case EntrySetAction.map:
return AIcons.map; return AIcons.map;
@ -155,6 +173,8 @@ extension ExtraEntrySetAction on EntrySetAction {
return AIcons.share; return AIcons.share;
case EntrySetAction.delete: case EntrySetAction.delete:
return AIcons.delete; return AIcons.delete;
case EntrySetAction.restore:
return AIcons.restore;
case EntrySetAction.copy: case EntrySetAction.copy:
return AIcons.copy; return AIcons.copy;
case EntrySetAction.move: 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); 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 // erase contextual properties from filters before saving them
if (filter is AlbumFilter) { if (filter is AlbumFilter) {
filter = AlbumFilter(filter.album, null); filter = AlbumFilter(filter.album, null);
} }
_rows.removeWhere((row) => row.filter == filter); _rows.removeWhere((row) => row.filter == filter);
if (contentId == null) { if (entryId == null) {
await metadataDb.removeCovers({filter}); await metadataDb.removeCovers({filter});
} else { } else {
final row = CoverRow(filter: filter, contentId: contentId); final row = CoverRow(filter: filter, entryId: entryId);
_rows.add(row); _rows.add(row);
await metadataDb.addCovers({row}); await metadataDb.addCovers({row});
} }
@ -43,28 +43,26 @@ class Covers with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> moveEntry(int oldContentId, AvesEntry entry) async { Future<void> moveEntry(AvesEntry entry, {required bool persist}) async {
final oldRows = _rows.where((row) => row.contentId == oldContentId).toSet(); final entryId = entry.id;
if (oldRows.isEmpty) return; final rows = _rows.where((row) => row.entryId == entryId).toSet();
for (final row in rows) {
for (final oldRow in oldRows) { final filter = row.filter;
final filter = oldRow.filter; if (!filter.test(entry)) {
_rows.remove(oldRow); _rows.remove(row);
if (filter.test(entry)) { if (persist) {
final newRow = CoverRow(filter: filter, contentId: entry.contentId!); await metadataDb.removeCovers({filter});
await metadataDb.updateCoverEntryId(oldRow.contentId, newRow); }
_rows.add(newRow);
} else {
await metadataDb.removeCovers({filter});
} }
} }
notifyListeners(); notifyListeners();
} }
Future<void> removeEntries(Set<AvesEntry> entries) async { Future<void> removeEntries(Set<AvesEntry> entries) => removeIds(entries.map((entry) => entry.id).toSet());
final contentIds = entries.map((entry) => entry.contentId).toSet();
final removedRows = _rows.where((row) => contentIds.contains(row.contentId)).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()); await metadataDb.removeCovers(removedRows.map((row) => row.filter).toSet());
_rows.removeAll(removedRows); _rows.removeAll(removedRows);
@ -85,8 +83,8 @@ class Covers with ChangeNotifier {
final visibleEntries = source.visibleEntries; final visibleEntries = source.visibleEntries;
final jsonList = covers.all final jsonList = covers.all
.map((row) { .map((row) {
final id = row.contentId; final entryId = row.entryId;
final path = visibleEntries.firstWhereOrNull((entry) => id == entry.contentId)?.path; final path = visibleEntries.firstWhereOrNull((entry) => entryId == entry.id)?.path;
if (path == null) return null; if (path == null) return null;
final volume = androidFileUtils.getStorageVolume(path)?.path; final volume = androidFileUtils.getStorageVolume(path)?.path;
@ -124,7 +122,7 @@ class Covers with ChangeNotifier {
final path = pContext.join(volume, relativePath); final path = pContext.join(volume, relativePath);
final entry = visibleEntries.firstWhereOrNull((entry) => entry.path == path && filter.test(entry)); final entry = visibleEntries.firstWhereOrNull((entry) => entry.path == path && filter.test(entry));
if (entry != null) { if (entry != null) {
covers.set(filter, entry.contentId); covers.set(filter, entry.id);
} else { } else {
debugPrint('failed to import cover for path=$path, filter=$filter'); debugPrint('failed to import cover for path=$path, filter=$filter');
} }
@ -138,14 +136,14 @@ class Covers with ChangeNotifier {
@immutable @immutable
class CoverRow extends Equatable { class CoverRow extends Equatable {
final CollectionFilter filter; final CollectionFilter filter;
final int contentId; final int entryId;
@override @override
List<Object?> get props => [filter, contentId]; List<Object?> get props => [filter, entryId];
const CoverRow({ const CoverRow({
required this.filter, required this.filter,
required this.contentId, required this.entryId,
}); });
static CoverRow? fromMap(Map map) { static CoverRow? fromMap(Map map) {
@ -153,12 +151,12 @@ class CoverRow extends Equatable {
if (filter == null) return null; if (filter == null) return null;
return CoverRow( return CoverRow(
filter: filter, filter: filter,
contentId: map['contentId'], entryId: map['entryId'],
); );
} }
Map<String, dynamic> toMap() => { Map<String, dynamic> toMap() => {
'filter': filter.toJson(), '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/filters/filters.dart';
import 'package:aves/model/metadata/address.dart'; import 'package:aves/model/metadata/address.dart';
import 'package:aves/model/metadata/catalog.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/model/video_playback.dart';
abstract class MetadataDb { abstract class MetadataDb {
int get nextId;
Future<void> init(); Future<void> init();
Future<int> dbFileSize(); Future<int> dbFileSize();
Future<void> reset(); Future<void> reset();
Future<void> removeIds(Set<int> contentIds, {Set<EntryDataType>? dataTypes}); Future<void> removeIds(Set<int> ids, {Set<EntryDataType>? dataTypes});
// entries // entries
@ -23,7 +26,7 @@ abstract class MetadataDb {
Future<void> saveEntries(Iterable<AvesEntry> entries); 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}); Future<Set<AvesEntry>> searchEntries(String query, {int? limit});
@ -43,17 +46,25 @@ abstract class MetadataDb {
Future<void> saveMetadata(Set<CatalogMetadata> metadataEntries); Future<void> saveMetadata(Set<CatalogMetadata> metadataEntries);
Future<void> updateMetadataId(int oldId, CatalogMetadata? metadata); Future<void> updateMetadata(int id, CatalogMetadata? metadata);
// address // address
Future<void> clearAddresses(); Future<void> clearAddresses();
Future<List<AddressDetails>> loadAllAddresses(); Future<Set<AddressDetails>> loadAllAddresses();
Future<void> saveAddresses(Set<AddressDetails> addresses); 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 // favourites
@ -63,7 +74,7 @@ abstract class MetadataDb {
Future<void> addFavourites(Iterable<FavouriteRow> rows); 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); Future<void> removeFavourites(Iterable<FavouriteRow> rows);
@ -75,7 +86,7 @@ abstract class MetadataDb {
Future<void> addCovers(Iterable<CoverRow> rows); 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); Future<void> removeCovers(Set<CollectionFilter> filters);
@ -85,11 +96,9 @@ abstract class MetadataDb {
Future<Set<VideoPlaybackRow>> loadAllVideoPlayback(); Future<Set<VideoPlaybackRow>> loadAllVideoPlayback();
Future<VideoPlaybackRow?> loadVideoPlayback(int? contentId); Future<VideoPlaybackRow?> loadVideoPlayback(int? id);
Future<void> addVideoPlayback(Set<VideoPlaybackRow> rows); Future<void> addVideoPlayback(Set<VideoPlaybackRow> rows);
Future<void> updateVideoPlaybackId(int oldId, int? newId); Future<void> removeVideoPlayback(Set<int> ids);
Future<void> removeVideoPlayback(Set<int> contentIds);
} }

View file

@ -8,6 +8,7 @@ import 'package:aves/model/favourites.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/metadata/address.dart'; import 'package:aves/model/metadata/address.dart';
import 'package:aves/model/metadata/catalog.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/model/video_playback.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
@ -15,7 +16,7 @@ import 'package:flutter/foundation.dart';
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
class SqfliteMetadataDb implements MetadataDb { class SqfliteMetadataDb implements MetadataDb {
late Future<Database> _database; late Database _db;
Future<String> get path async => pContext.join(await getDatabasesPath(), 'metadata.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 addressTable = 'address';
static const favouriteTable = 'favourites'; static const favouriteTable = 'favourites';
static const coverTable = 'covers'; static const coverTable = 'covers';
static const trashTable = 'trash';
static const videoPlaybackTable = 'videoPlayback'; static const videoPlaybackTable = 'videoPlayback';
static int _lastId = 0;
@override
int get nextId => ++_lastId;
@override @override
Future<void> init() async { Future<void> init() async {
_database = openDatabase( _db = await openDatabase(
await path, await path,
onCreate: (db, version) async { onCreate: (db, version) async {
await db.execute('CREATE TABLE $entryTable(' await db.execute('CREATE TABLE $entryTable('
'contentId INTEGER PRIMARY KEY' 'id INTEGER PRIMARY KEY'
', contentId INTEGER'
', uri TEXT' ', uri TEXT'
', path TEXT' ', path TEXT'
', sourceMimeType TEXT' ', sourceMimeType TEXT'
@ -45,13 +53,14 @@ class SqfliteMetadataDb implements MetadataDb {
', dateModifiedSecs INTEGER' ', dateModifiedSecs INTEGER'
', sourceDateTakenMillis INTEGER' ', sourceDateTakenMillis INTEGER'
', durationMillis INTEGER' ', durationMillis INTEGER'
', trashed INTEGER DEFAULT 0'
')'); ')');
await db.execute('CREATE TABLE $dateTakenTable(' await db.execute('CREATE TABLE $dateTakenTable('
'contentId INTEGER PRIMARY KEY' 'id INTEGER PRIMARY KEY'
', dateMillis INTEGER' ', dateMillis INTEGER'
')'); ')');
await db.execute('CREATE TABLE $metadataTable(' await db.execute('CREATE TABLE $metadataTable('
'contentId INTEGER PRIMARY KEY' 'id INTEGER PRIMARY KEY'
', mimeType TEXT' ', mimeType TEXT'
', dateMillis INTEGER' ', dateMillis INTEGER'
', flags INTEGER' ', flags INTEGER'
@ -63,7 +72,7 @@ class SqfliteMetadataDb implements MetadataDb {
', rating INTEGER' ', rating INTEGER'
')'); ')');
await db.execute('CREATE TABLE $addressTable(' await db.execute('CREATE TABLE $addressTable('
'contentId INTEGER PRIMARY KEY' 'id INTEGER PRIMARY KEY'
', addressLine TEXT' ', addressLine TEXT'
', countryCode TEXT' ', countryCode TEXT'
', countryName TEXT' ', countryName TEXT'
@ -71,21 +80,28 @@ class SqfliteMetadataDb implements MetadataDb {
', locality TEXT' ', locality TEXT'
')'); ')');
await db.execute('CREATE TABLE $favouriteTable(' await db.execute('CREATE TABLE $favouriteTable('
'contentId INTEGER PRIMARY KEY' 'id INTEGER PRIMARY KEY'
', path TEXT'
')'); ')');
await db.execute('CREATE TABLE $coverTable(' await db.execute('CREATE TABLE $coverTable('
'filter TEXT PRIMARY KEY' '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(' await db.execute('CREATE TABLE $videoPlaybackTable('
'contentId INTEGER PRIMARY KEY' 'id INTEGER PRIMARY KEY'
', resumeTimeMillis INTEGER' ', resumeTimeMillis INTEGER'
')'); ')');
}, },
onUpgrade: MetadataDbUpgrader.upgradeDb, 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 @override
@ -97,22 +113,22 @@ class SqfliteMetadataDb implements MetadataDb {
@override @override
Future<void> reset() async { Future<void> reset() async {
debugPrint('$runtimeType reset'); debugPrint('$runtimeType reset');
await (await _database).close(); await _db.close();
await deleteDatabase(await path); await deleteDatabase(await path);
await init(); await init();
} }
@override @override
Future<void> removeIds(Set<int> contentIds, {Set<EntryDataType>? dataTypes}) async { Future<void> removeIds(Set<int> ids, {Set<EntryDataType>? dataTypes}) async {
if (contentIds.isEmpty) return; if (ids.isEmpty) return;
final _dataTypes = dataTypes ?? EntryDataType.values.toSet(); final _dataTypes = dataTypes ?? EntryDataType.values.toSet();
final db = await _database; // using array in `whereArgs` and using it with `where id IN ?` is a pain, so we prefer `batch` instead
// using array in `whereArgs` and using it with `where contentId IN ?` is a pain, so we prefer `batch` instead final batch = _db.batch();
final batch = db.batch(); const where = 'id = ?';
const where = 'contentId = ?'; const coverWhere = 'entryId = ?';
contentIds.forEach((id) { ids.forEach((id) {
final whereArgs = [id]; final whereArgs = [id];
if (_dataTypes.contains(EntryDataType.basic)) { if (_dataTypes.contains(EntryDataType.basic)) {
batch.delete(entryTable, where: where, whereArgs: whereArgs); batch.delete(entryTable, where: where, whereArgs: whereArgs);
@ -126,7 +142,8 @@ class SqfliteMetadataDb implements MetadataDb {
} }
if (_dataTypes.contains(EntryDataType.references)) { if (_dataTypes.contains(EntryDataType.references)) {
batch.delete(favouriteTable, where: where, whereArgs: whereArgs); 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); batch.delete(videoPlaybackTable, where: where, whereArgs: whereArgs);
} }
}); });
@ -137,32 +154,28 @@ class SqfliteMetadataDb implements MetadataDb {
@override @override
Future<void> clearEntries() async { 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'); debugPrint('$runtimeType clearEntries deleted $count rows');
} }
@override @override
Future<Set<AvesEntry>> loadAllEntries() async { Future<Set<AvesEntry>> loadAllEntries() async {
final db = await _database; final rows = await _db.query(entryTable);
final maps = await db.query(entryTable); return rows.map(AvesEntry.fromMap).toSet();
final entries = maps.map(AvesEntry.fromMap).toSet();
return entries;
} }
@override @override
Future<Set<AvesEntry>> loadEntries(List<int> ids) async { Future<Set<AvesEntry>> loadEntries(List<int> ids) async {
if (ids.isEmpty) return {}; if (ids.isEmpty) return {};
final db = await _database;
final entries = <AvesEntry>{}; final entries = <AvesEntry>{};
await Future.forEach(ids, (id) async { await Future.forEach(ids, (id) async {
final maps = await db.query( final rows = await _db.query(
entryTable, entryTable,
where: 'contentId = ?', where: 'id = ?',
whereArgs: [id], whereArgs: [id],
); );
if (maps.isNotEmpty) { if (rows.isNotEmpty) {
entries.add(AvesEntry.fromMap(maps.first)); entries.add(AvesEntry.fromMap(rows.first));
} }
}); });
return entries; return entries;
@ -172,18 +185,16 @@ class SqfliteMetadataDb implements MetadataDb {
Future<void> saveEntries(Iterable<AvesEntry> entries) async { Future<void> saveEntries(Iterable<AvesEntry> entries) async {
if (entries.isEmpty) return; if (entries.isEmpty) return;
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
final db = await _database; final batch = _db.batch();
final batch = db.batch();
entries.forEach((entry) => _batchInsertEntry(batch, entry)); entries.forEach((entry) => _batchInsertEntry(batch, entry));
await batch.commit(noResult: true); await batch.commit(noResult: true);
debugPrint('$runtimeType saveEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries'); debugPrint('$runtimeType saveEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries');
} }
@override @override
Future<void> updateEntryId(int oldId, AvesEntry entry) async { Future<void> updateEntry(int id, AvesEntry entry) async {
final db = await _database; final batch = _db.batch();
final batch = db.batch(); batch.delete(entryTable, where: 'id = ?', whereArgs: [id]);
batch.delete(entryTable, where: 'contentId = ?', whereArgs: [oldId]);
_batchInsertEntry(batch, entry); _batchInsertEntry(batch, entry);
await batch.commit(noResult: true); await batch.commit(noResult: true);
} }
@ -198,49 +209,42 @@ class SqfliteMetadataDb implements MetadataDb {
@override @override
Future<Set<AvesEntry>> searchEntries(String query, {int? limit}) async { Future<Set<AvesEntry>> searchEntries(String query, {int? limit}) async {
final db = await _database; final rows = await _db.query(
final maps = await db.query(
entryTable, entryTable,
where: 'title LIKE ?', where: 'title LIKE ?',
whereArgs: ['%$query%'], whereArgs: ['%$query%'],
orderBy: 'sourceDateTakenMillis DESC', orderBy: 'sourceDateTakenMillis DESC',
limit: limit, limit: limit,
); );
return maps.map(AvesEntry.fromMap).toSet(); return rows.map(AvesEntry.fromMap).toSet();
} }
// date taken // date taken
@override @override
Future<void> clearDates() async { 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'); debugPrint('$runtimeType clearDates deleted $count rows');
} }
@override @override
Future<Map<int?, int?>> loadDates() async { Future<Map<int?, int?>> loadDates() async {
final db = await _database; final rows = await _db.query(dateTakenTable);
final maps = await db.query(dateTakenTable); return Map.fromEntries(rows.map((map) => MapEntry(map['id'] as int, (map['dateMillis'] ?? 0) as int)));
final metadataEntries = Map.fromEntries(maps.map((map) => MapEntry(map['contentId'] as int, (map['dateMillis'] ?? 0) as int)));
return metadataEntries;
} }
// catalog metadata // catalog metadata
@override @override
Future<void> clearMetadataEntries() async { 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'); debugPrint('$runtimeType clearMetadataEntries deleted $count rows');
} }
@override @override
Future<List<CatalogMetadata>> loadAllMetadataEntries() async { Future<List<CatalogMetadata>> loadAllMetadataEntries() async {
final db = await _database; final rows = await _db.query(metadataTable);
final maps = await db.query(metadataTable); return rows.map(CatalogMetadata.fromMap).toList();
final metadataEntries = maps.map(CatalogMetadata.fromMap).toList();
return metadataEntries;
} }
@override @override
@ -248,8 +252,7 @@ class SqfliteMetadataDb implements MetadataDb {
if (metadataEntries.isEmpty) return; if (metadataEntries.isEmpty) return;
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
try { try {
final db = await _database; final batch = _db.batch();
final batch = db.batch();
metadataEntries.forEach((metadata) => _batchInsertMetadata(batch, metadata)); metadataEntries.forEach((metadata) => _batchInsertMetadata(batch, metadata));
await batch.commit(noResult: true); await batch.commit(noResult: true);
debugPrint('$runtimeType saveMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries'); debugPrint('$runtimeType saveMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries');
@ -259,11 +262,10 @@ class SqfliteMetadataDb implements MetadataDb {
} }
@override @override
Future<void> updateMetadataId(int oldId, CatalogMetadata? metadata) async { Future<void> updateMetadata(int id, CatalogMetadata? metadata) async {
final db = await _database; final batch = _db.batch();
final batch = db.batch(); batch.delete(dateTakenTable, where: 'id = ?', whereArgs: [id]);
batch.delete(dateTakenTable, where: 'contentId = ?', whereArgs: [oldId]); batch.delete(metadataTable, where: 'id = ?', whereArgs: [id]);
batch.delete(metadataTable, where: 'contentId = ?', whereArgs: [oldId]);
_batchInsertMetadata(batch, metadata); _batchInsertMetadata(batch, metadata);
await batch.commit(noResult: true); await batch.commit(noResult: true);
} }
@ -274,7 +276,7 @@ class SqfliteMetadataDb implements MetadataDb {
batch.insert( batch.insert(
dateTakenTable, dateTakenTable,
{ {
'contentId': metadata.contentId, 'id': metadata.id,
'dateMillis': metadata.dateMillis, 'dateMillis': metadata.dateMillis,
}, },
conflictAlgorithm: ConflictAlgorithm.replace, conflictAlgorithm: ConflictAlgorithm.replace,
@ -291,35 +293,30 @@ class SqfliteMetadataDb implements MetadataDb {
@override @override
Future<void> clearAddresses() async { 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'); debugPrint('$runtimeType clearAddresses deleted $count rows');
} }
@override @override
Future<List<AddressDetails>> loadAllAddresses() async { Future<Set<AddressDetails>> loadAllAddresses() async {
final db = await _database; final rows = await _db.query(addressTable);
final maps = await db.query(addressTable); return rows.map(AddressDetails.fromMap).toSet();
final addresses = maps.map(AddressDetails.fromMap).toList();
return addresses;
} }
@override @override
Future<void> saveAddresses(Set<AddressDetails> addresses) async { Future<void> saveAddresses(Set<AddressDetails> addresses) async {
if (addresses.isEmpty) return; if (addresses.isEmpty) return;
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
final db = await _database; final batch = _db.batch();
final batch = db.batch();
addresses.forEach((address) => _batchInsertAddress(batch, address)); addresses.forEach((address) => _batchInsertAddress(batch, address));
await batch.commit(noResult: true); await batch.commit(noResult: true);
debugPrint('$runtimeType saveAddresses complete in ${stopwatch.elapsed.inMilliseconds}ms for ${addresses.length} entries'); debugPrint('$runtimeType saveAddresses complete in ${stopwatch.elapsed.inMilliseconds}ms for ${addresses.length} entries');
} }
@override @override
Future<void> updateAddressId(int oldId, AddressDetails? address) async { Future<void> updateAddress(int id, AddressDetails? address) async {
final db = await _database; final batch = _db.batch();
final batch = db.batch(); batch.delete(addressTable, where: 'id = ?', whereArgs: [id]);
batch.delete(addressTable, where: 'contentId = ?', whereArgs: [oldId]);
_batchInsertAddress(batch, address); _batchInsertAddress(batch, address);
await batch.commit(noResult: true); 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 // favourites
@override @override
Future<void> clearFavourites() async { 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'); debugPrint('$runtimeType clearFavourites deleted $count rows');
} }
@override @override
Future<Set<FavouriteRow>> loadAllFavourites() async { Future<Set<FavouriteRow>> loadAllFavourites() async {
final db = await _database; final rows = await _db.query(favouriteTable);
final maps = await db.query(favouriteTable); return rows.map(FavouriteRow.fromMap).toSet();
final rows = maps.map(FavouriteRow.fromMap).toSet();
return rows;
} }
@override @override
Future<void> addFavourites(Iterable<FavouriteRow> rows) async { Future<void> addFavourites(Iterable<FavouriteRow> rows) async {
if (rows.isEmpty) return; if (rows.isEmpty) return;
final db = await _database; final batch = _db.batch();
final batch = db.batch();
rows.forEach((row) => _batchInsertFavourite(batch, row)); rows.forEach((row) => _batchInsertFavourite(batch, row));
await batch.commit(noResult: true); await batch.commit(noResult: true);
} }
@override @override
Future<void> updateFavouriteId(int oldId, FavouriteRow row) async { Future<void> updateFavouriteId(int id, FavouriteRow row) async {
final db = await _database; final batch = _db.batch();
final batch = db.batch(); batch.delete(favouriteTable, where: 'id = ?', whereArgs: [id]);
batch.delete(favouriteTable, where: 'contentId = ?', whereArgs: [oldId]);
_batchInsertFavourite(batch, row); _batchInsertFavourite(batch, row);
await batch.commit(noResult: true); await batch.commit(noResult: true);
} }
@ -379,13 +402,12 @@ class SqfliteMetadataDb implements MetadataDb {
@override @override
Future<void> removeFavourites(Iterable<FavouriteRow> rows) async { Future<void> removeFavourites(Iterable<FavouriteRow> rows) async {
if (rows.isEmpty) return; if (rows.isEmpty) return;
final ids = rows.map((row) => row.contentId); final ids = rows.map((row) => row.entryId);
if (ids.isEmpty) return; if (ids.isEmpty) return;
final db = await _database; // using array in `whereArgs` and using it with `where id IN ?` is a pain, so we prefer `batch` instead
// using array in `whereArgs` and using it with `where contentId IN ?` is a pain, so we prefer `batch` instead final batch = _db.batch();
final batch = db.batch(); ids.forEach((id) => batch.delete(favouriteTable, where: 'id = ?', whereArgs: [id]));
ids.forEach((id) => batch.delete(favouriteTable, where: 'contentId = ?', whereArgs: [id]));
await batch.commit(noResult: true); await batch.commit(noResult: true);
} }
@ -393,34 +415,29 @@ class SqfliteMetadataDb implements MetadataDb {
@override @override
Future<void> clearCovers() async { 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'); debugPrint('$runtimeType clearCovers deleted $count rows');
} }
@override @override
Future<Set<CoverRow>> loadAllCovers() async { Future<Set<CoverRow>> loadAllCovers() async {
final db = await _database; final rows = await _db.query(coverTable);
final maps = await db.query(coverTable); return rows.map(CoverRow.fromMap).whereNotNull().toSet();
final rows = maps.map(CoverRow.fromMap).whereNotNull().toSet();
return rows;
} }
@override @override
Future<void> addCovers(Iterable<CoverRow> rows) async { Future<void> addCovers(Iterable<CoverRow> rows) async {
if (rows.isEmpty) return; if (rows.isEmpty) return;
final db = await _database; final batch = _db.batch();
final batch = db.batch();
rows.forEach((row) => _batchInsertCover(batch, row)); rows.forEach((row) => _batchInsertCover(batch, row));
await batch.commit(noResult: true); await batch.commit(noResult: true);
} }
@override @override
Future<void> updateCoverEntryId(int oldId, CoverRow row) async { Future<void> updateCoverEntryId(int id, CoverRow row) async {
final db = await _database; final batch = _db.batch();
final batch = db.batch(); batch.delete(coverTable, where: 'entryId = ?', whereArgs: [id]);
batch.delete(coverTable, where: 'contentId = ?', whereArgs: [oldId]);
_batchInsertCover(batch, row); _batchInsertCover(batch, row);
await batch.commit(noResult: true); await batch.commit(noResult: true);
} }
@ -437,9 +454,8 @@ class SqfliteMetadataDb implements MetadataDb {
Future<void> removeCovers(Set<CollectionFilter> filters) async { Future<void> removeCovers(Set<CollectionFilter> filters) async {
if (filters.isEmpty) return; 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 // 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()])); filters.forEach((filter) => batch.delete(coverTable, where: 'filter = ?', whereArgs: [filter.toJson()]));
await batch.commit(noResult: true); await batch.commit(noResult: true);
} }
@ -448,36 +464,31 @@ class SqfliteMetadataDb implements MetadataDb {
@override @override
Future<void> clearVideoPlayback() async { 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'); debugPrint('$runtimeType clearVideoPlayback deleted $count rows');
} }
@override @override
Future<Set<VideoPlaybackRow>> loadAllVideoPlayback() async { Future<Set<VideoPlaybackRow>> loadAllVideoPlayback() async {
final db = await _database; final rows = await _db.query(videoPlaybackTable);
final maps = await db.query(videoPlaybackTable); return rows.map(VideoPlaybackRow.fromMap).whereNotNull().toSet();
final rows = maps.map(VideoPlaybackRow.fromMap).whereNotNull().toSet();
return rows;
} }
@override @override
Future<VideoPlaybackRow?> loadVideoPlayback(int? contentId) async { Future<VideoPlaybackRow?> loadVideoPlayback(int? id) async {
if (contentId == null) return null; if (id == null) return null;
final db = await _database; final rows = await _db.query(videoPlaybackTable, where: 'id = ?', whereArgs: [id]);
final maps = await db.query(videoPlaybackTable, where: 'contentId = ?', whereArgs: [contentId]); if (rows.isEmpty) return null;
if (maps.isEmpty) return null;
return VideoPlaybackRow.fromMap(maps.first); return VideoPlaybackRow.fromMap(rows.first);
} }
@override @override
Future<void> addVideoPlayback(Set<VideoPlaybackRow> rows) async { Future<void> addVideoPlayback(Set<VideoPlaybackRow> rows) async {
if (rows.isEmpty) return; if (rows.isEmpty) return;
final db = await _database; final batch = _db.batch();
final batch = db.batch();
rows.forEach((row) => _batchInsertVideoPlayback(batch, row)); rows.forEach((row) => _batchInsertVideoPlayback(batch, row));
await batch.commit(noResult: true); await batch.commit(noResult: true);
} }
@ -491,23 +502,12 @@ class SqfliteMetadataDb implements MetadataDb {
} }
@override @override
Future<void> updateVideoPlaybackId(int oldId, int? newId) async { Future<void> removeVideoPlayback(Set<int> ids) async {
if (newId != null) { if (ids.isEmpty) return;
final db = await _database;
await db.update(videoPlaybackTable, {'contentId': newId}, where: 'contentId = ?', whereArgs: [oldId]);
} else {
await removeVideoPlayback({oldId});
}
}
@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 // 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();
contentIds.forEach((id) => batch.delete(videoPlaybackTable, where: 'contentId = ?', whereArgs: [id])); ids.forEach((id) => batch.delete(videoPlaybackTable, where: 'id = ?', whereArgs: [id]));
await batch.commit(noResult: true); await batch.commit(noResult: true);
} }
} }

View file

@ -4,8 +4,12 @@ import 'package:sqflite/sqflite.dart';
class MetadataDbUpgrader { class MetadataDbUpgrader {
static const entryTable = SqfliteMetadataDb.entryTable; static const entryTable = SqfliteMetadataDb.entryTable;
static const dateTakenTable = SqfliteMetadataDb.dateTakenTable;
static const metadataTable = SqfliteMetadataDb.metadataTable; static const metadataTable = SqfliteMetadataDb.metadataTable;
static const addressTable = SqfliteMetadataDb.addressTable;
static const favouriteTable = SqfliteMetadataDb.favouriteTable;
static const coverTable = SqfliteMetadataDb.coverTable; static const coverTable = SqfliteMetadataDb.coverTable;
static const trashTable = SqfliteMetadataDb.trashTable;
static const videoPlaybackTable = SqfliteMetadataDb.videoPlaybackTable; static const videoPlaybackTable = SqfliteMetadataDb.videoPlaybackTable;
// warning: "ALTER TABLE ... RENAME COLUMN ..." is not supported // warning: "ALTER TABLE ... RENAME COLUMN ..." is not supported
@ -28,6 +32,9 @@ class MetadataDbUpgrader {
case 5: case 5:
await _upgradeFrom5(db); await _upgradeFrom5(db);
break; break;
case 6:
await _upgradeFrom6(db);
break;
} }
oldVersion++; oldVersion++;
} }
@ -129,4 +136,137 @@ class MetadataDbUpgrader {
debugPrint('upgrading DB from v5'); debugPrint('upgrading DB from v5');
await db.execute('ALTER TABLE $metadataTable ADD COLUMN rating INTEGER;'); 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/favourites.dart';
import 'package:aves/model/metadata/address.dart'; import 'package:aves/model/metadata/address.dart';
import 'package:aves/model/metadata/catalog.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/multipage.dart';
import 'package:aves/model/source/trash.dart';
import 'package:aves/model/video/metadata.dart'; import 'package:aves/model/video/metadata.dart';
import 'package:aves/ref/mime_types.dart'; import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/common/service_policy.dart'; import 'package:aves/services/common/service_policy.dart';
@ -25,29 +27,27 @@ import 'package:latlong2/latlong.dart';
enum EntryDataType { basic, catalog, address, references } enum EntryDataType { basic, catalog, address, references }
class AvesEntry { class AvesEntry {
// `sizeBytes`, `dateModifiedSecs` can be missing in viewer mode
int id;
String uri; String uri;
String? _path, _directory, _filename, _extension; String? _path, _directory, _filename, _extension, _sourceTitle;
int? pageId, contentId; int? pageId, contentId;
final String sourceMimeType; final String sourceMimeType;
int width; int width, height, sourceRotationDegrees;
int height; int? sizeBytes, _dateModifiedSecs, sourceDateTakenMillis, _durationMillis;
int sourceRotationDegrees; bool trashed;
int? sizeBytes;
String? _sourceTitle;
// `dateModifiedSecs` can be missing in viewer mode
int? _dateModifiedSecs;
int? sourceDateTakenMillis;
int? _durationMillis;
int? _catalogDateMillis; int? _catalogDateMillis;
CatalogMetadata? _catalogMetadata; CatalogMetadata? _catalogMetadata;
AddressDetails? _addressDetails; AddressDetails? _addressDetails;
TrashDetails? trashDetails;
List<AvesEntry>? burstEntries; List<AvesEntry>? burstEntries;
final AChangeNotifier imageChangeNotifier = AChangeNotifier(), metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier(); final AChangeNotifier imageChangeNotifier = AChangeNotifier(), metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier();
AvesEntry({ AvesEntry({
required int? id,
required this.uri, required this.uri,
required String? path, required String? path,
required this.contentId, required this.contentId,
@ -61,8 +61,9 @@ class AvesEntry {
required int? dateModifiedSecs, required int? dateModifiedSecs,
required this.sourceDateTakenMillis, required this.sourceDateTakenMillis,
required int? durationMillis, required int? durationMillis,
required this.trashed,
this.burstEntries, this.burstEntries,
}) { }) : id = id ?? 0 {
this.path = path; this.path = path;
this.sourceTitle = sourceTitle; this.sourceTitle = sourceTitle;
this.dateModifiedSecs = dateModifiedSecs; this.dateModifiedSecs = dateModifiedSecs;
@ -74,6 +75,7 @@ class AvesEntry {
bool get canHaveAlpha => MimeTypes.alphaImages.contains(mimeType); bool get canHaveAlpha => MimeTypes.alphaImages.contains(mimeType);
AvesEntry copyWith({ AvesEntry copyWith({
int? id,
String? uri, String? uri,
String? path, String? path,
int? contentId, int? contentId,
@ -81,11 +83,12 @@ class AvesEntry {
int? dateModifiedSecs, int? dateModifiedSecs,
List<AvesEntry>? burstEntries, List<AvesEntry>? burstEntries,
}) { }) {
final copyContentId = contentId ?? this.contentId; final copyEntryId = id ?? this.id;
final copied = AvesEntry( final copied = AvesEntry(
id: copyEntryId,
uri: uri ?? this.uri, uri: uri ?? this.uri,
path: path ?? this.path, path: path ?? this.path,
contentId: copyContentId, contentId: contentId ?? this.contentId,
pageId: null, pageId: null,
sourceMimeType: sourceMimeType, sourceMimeType: sourceMimeType,
width: width, width: width,
@ -96,10 +99,12 @@ class AvesEntry {
dateModifiedSecs: dateModifiedSecs ?? this.dateModifiedSecs, dateModifiedSecs: dateModifiedSecs ?? this.dateModifiedSecs,
sourceDateTakenMillis: sourceDateTakenMillis, sourceDateTakenMillis: sourceDateTakenMillis,
durationMillis: durationMillis, durationMillis: durationMillis,
trashed: trashed,
burstEntries: burstEntries ?? this.burstEntries, burstEntries: burstEntries ?? this.burstEntries,
) )
..catalogMetadata = _catalogMetadata?.copyWith(contentId: copyContentId) ..catalogMetadata = _catalogMetadata?.copyWith(id: copyEntryId)
..addressDetails = _addressDetails?.copyWith(contentId: copyContentId); ..addressDetails = _addressDetails?.copyWith(id: copyEntryId)
..trashDetails = trashDetails?.copyWith(id: copyEntryId);
return copied; return copied;
} }
@ -107,6 +112,7 @@ class AvesEntry {
// from DB or platform source entry // from DB or platform source entry
factory AvesEntry.fromMap(Map map) { factory AvesEntry.fromMap(Map map) {
return AvesEntry( return AvesEntry(
id: map['id'] as int?,
uri: map['uri'] as String, uri: map['uri'] as String,
path: map['path'] as String?, path: map['path'] as String?,
pageId: null, pageId: null,
@ -120,12 +126,14 @@ class AvesEntry {
dateModifiedSecs: map['dateModifiedSecs'] as int?, dateModifiedSecs: map['dateModifiedSecs'] as int?,
sourceDateTakenMillis: map['sourceDateTakenMillis'] as int?, sourceDateTakenMillis: map['sourceDateTakenMillis'] as int?,
durationMillis: map['durationMillis'] as int?, durationMillis: map['durationMillis'] as int?,
trashed: (map['trashed'] as int? ?? 0) != 0,
); );
} }
// for DB only // for DB only
Map<String, dynamic> toMap() { Map<String, dynamic> toMap() {
return { return {
'id': id,
'uri': uri, 'uri': uri,
'path': path, 'path': path,
'contentId': contentId, 'contentId': contentId,
@ -138,6 +146,7 @@ class AvesEntry {
'dateModifiedSecs': dateModifiedSecs, 'dateModifiedSecs': dateModifiedSecs,
'sourceDateTakenMillis': sourceDateTakenMillis, 'sourceDateTakenMillis': sourceDateTakenMillis,
'durationMillis': durationMillis, '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 // so that we can reliably use instances in a `Set`, which requires consistent hash codes over time
@override @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) { set path(String? path) {
_path = path; _path = path;
@ -179,7 +188,10 @@ class AvesEntry {
return _extension; 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 // the MIME type reported by the Media Store is unreliable
// so we use the one found during cataloguing if possible // so we use the one found during cataloguing if possible
@ -233,7 +245,7 @@ class AvesEntry {
bool get is360 => _catalogMetadata?.is360 ?? false; bool get is360 => _catalogMetadata?.is360 ?? false;
bool get canEdit => path != null; bool get canEdit => path != null && !trashed;
bool get canEditDate => canEdit && (canEditExif || canEditXmp); bool get canEditDate => canEdit && (canEditExif || canEditXmp);
@ -408,6 +420,18 @@ class AvesEntry {
return _durationText!; 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 // returns whether this entry has GPS coordinates
// (0, 0) coordinates are considered invalid, as it is likely a default value // (0, 0) coordinates are considered invalid, as it is likely a default value
bool get hasGps => (_catalogMetadata?.latitude ?? 0) != 0 || (_catalogMetadata?.longitude ?? 0) != 0; bool get hasGps => (_catalogMetadata?.latitude ?? 0) != 0 || (_catalogMetadata?.longitude ?? 0) != 0;
@ -476,7 +500,7 @@ class AvesEntry {
}; };
await applyNewFields(fields, persist: persist); await applyNewFields(fields, persist: persist);
} }
catalogMetadata = CatalogMetadata(contentId: contentId); catalogMetadata = CatalogMetadata(id: id);
} else { } else {
if (isVideo && (!isSized || durationMillis == 0)) { if (isVideo && (!isSized || durationMillis == 0)) {
// exotic video that is not sized during loading // exotic video that is not sized during loading
@ -519,7 +543,7 @@ class AvesEntry {
void setCountry(CountryCode? countryCode) { void setCountry(CountryCode? countryCode) {
if (hasFineAddress || countryCode == null) return; if (hasFineAddress || countryCode == null) return;
addressDetails = AddressDetails( addressDetails = AddressDetails(
contentId: contentId, id: id,
countryCode: countryCode.alpha2, countryCode: countryCode.alpha2,
countryName: countryCode.alpha3, countryName: countryCode.alpha3,
); );
@ -542,7 +566,7 @@ class AvesEntry {
final cn = address.countryName; final cn = address.countryName;
final aa = address.adminArea; final aa = address.adminArea;
addressDetails = AddressDetails( addressDetails = AddressDetails(
contentId: contentId, id: id,
countryCode: cc, countryCode: cc,
countryName: cn, countryName: cn,
adminArea: aa, adminArea: aa,
@ -638,7 +662,7 @@ class AvesEntry {
_tags = null; _tags = null;
if (persist) { if (persist) {
await metadataDb.removeIds({contentId!}, dataTypes: dataTypes); await metadataDb.removeIds({id}, dataTypes: dataTypes);
} }
final updatedEntry = await mediaFileService.getEntry(uri, mimeType); final updatedEntry = await mediaFileService.getEntry(uri, mimeType);
@ -689,7 +713,7 @@ class AvesEntry {
Future<void> removeFromFavourites() async { Future<void> removeFromFavourites() async {
if (isFavourite) { if (isFavourite) {
await favourites.remove({this}); await favourites.removeEntries({this});
} }
} }
@ -720,7 +744,7 @@ class AvesEntry {
pages: burstEntries! pages: burstEntries!
.mapIndexed((index, entry) => SinglePageInfo( .mapIndexed((index, entry) => SinglePageInfo(
index: index, index: index,
pageId: entry.contentId!, pageId: entry.id,
isDefault: index == 0, isDefault: index == 0,
uri: entry.uri, uri: entry.uri,
mimeType: entry.mimeType, mimeType: entry.mimeType,

View file

@ -19,11 +19,11 @@ class Favourites with ChangeNotifier {
int get count => _rows.length; 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 { Future<void> add(Set<AvesEntry> entries) async {
final newRows = entries.map(_entryToRow); final newRows = entries.map(_entryToRow);
@ -34,9 +34,10 @@ class Favourites with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> remove(Set<AvesEntry> entries) async { Future<void> removeEntries(Set<AvesEntry> entries) => removeIds(entries.map((entry) => entry.id).toSet());
final contentIds = entries.map((entry) => entry.contentId).toSet();
final removedRows = _rows.where((row) => contentIds.contains(row.contentId)).toSet(); Future<void> removeIds(Set<int> entryIds) async {
final removedRows = _rows.where((row) => entryIds.contains(row.entryId)).toSet();
await metadataDb.removeFavourites(removedRows); await metadataDb.removeFavourites(removedRows);
removedRows.forEach(_rows.remove); removedRows.forEach(_rows.remove);
@ -44,19 +45,6 @@ class Favourites with ChangeNotifier {
notifyListeners(); 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 { Future<void> clear() async {
await metadataDb.clearFavourites(); await metadataDb.clearFavourites();
_rows.clear(); _rows.clear();
@ -69,7 +57,7 @@ class Favourites with ChangeNotifier {
Map<String, List<String>>? export(CollectionSource source) { Map<String, List<String>>? export(CollectionSource source) {
final visibleEntries = source.visibleEntries; final visibleEntries = source.visibleEntries;
final ids = favourites.all; 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 byVolume = groupBy<String, StorageVolume?>(paths, androidFileUtils.getStorageVolume);
final jsonMap = Map.fromEntries(byVolume.entries.map((kv) { final jsonMap = Map.fromEntries(byVolume.entries.map((kv) {
final volume = kv.key?.path; final volume = kv.key?.path;
@ -117,26 +105,22 @@ class Favourites with ChangeNotifier {
@immutable @immutable
class FavouriteRow extends Equatable { class FavouriteRow extends Equatable {
final int contentId; final int entryId;
final String path;
@override @override
List<Object?> get props => [contentId, path]; List<Object?> get props => [entryId];
const FavouriteRow({ const FavouriteRow({
required this.contentId, required this.entryId,
required this.path,
}); });
factory FavouriteRow.fromMap(Map map) { factory FavouriteRow.fromMap(Map map) {
return FavouriteRow( return FavouriteRow(
contentId: map['contentId'] ?? 0, entryId: map['id'] as int,
path: map['path'] ?? '',
); );
} }
Map<String, dynamic> toMap() => { Map<String, dynamic> toMap() => {
'contentId': contentId, 'id': entryId,
'path': path,
}; };
} }

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/query.dart';
import 'package:aves/model/filters/rating.dart'; import 'package:aves/model/filters/rating.dart';
import 'package:aves/model/filters/tag.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/model/filters/type.dart';
import 'package:aves/utils/color_utils.dart'; import 'package:aves/utils/color_utils.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
@ -20,6 +21,7 @@ import 'package:flutter/widgets.dart';
@immutable @immutable
abstract class CollectionFilter extends Equatable implements Comparable<CollectionFilter> { abstract class CollectionFilter extends Equatable implements Comparable<CollectionFilter> {
static const List<String> categoryOrder = [ static const List<String> categoryOrder = [
TrashFilter.type,
QueryFilter.type, QueryFilter.type,
MimeFilter.type, MimeFilter.type,
AlbumFilter.type, AlbumFilter.type,
@ -64,6 +66,8 @@ abstract class CollectionFilter extends Equatable implements Comparable<Collecti
return TagFilter.fromMap(jsonMap); return TagFilter.fromMap(jsonMap);
case TypeFilter.type: case TypeFilter.type:
return TypeFilter.fromMap(jsonMap); return TypeFilter.fromMap(jsonMap);
case TrashFilter.type:
return TrashFilter.instance;
} }
} }
} catch (error, stack) { } catch (error, stack) {

View file

@ -22,7 +22,7 @@ class QueryFilter extends CollectionFilter {
var upQuery = query.toUpperCase(); var upQuery = query.toUpperCase();
if (upQuery.startsWith('ID:')) { if (upQuery.startsWith('ID:')) {
final id = int.tryParse(upQuery.substring(3)); final id = int.tryParse(upQuery.substring(3));
_test = (entry) => entry.contentId == id; _test = (entry) => entry.id == id;
return; 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 @immutable
class AddressDetails extends Equatable { class AddressDetails extends Equatable {
final int? contentId; final int id;
final String? countryCode, countryName, adminArea, locality; final String? countryCode, countryName, adminArea, locality;
String? get place => locality != null && locality!.isNotEmpty ? locality : adminArea; String? get place => locality != null && locality!.isNotEmpty ? locality : adminArea;
@override @override
List<Object?> get props => [contentId, countryCode, countryName, adminArea, locality]; List<Object?> get props => [id, countryCode, countryName, adminArea, locality];
const AddressDetails({ const AddressDetails({
this.contentId, required this.id,
this.countryCode, this.countryCode,
this.countryName, this.countryName,
this.adminArea, this.adminArea,
@ -21,10 +21,10 @@ class AddressDetails extends Equatable {
}); });
AddressDetails copyWith({ AddressDetails copyWith({
int? contentId, int? id,
}) { }) {
return AddressDetails( return AddressDetails(
contentId: contentId ?? this.contentId, id: id ?? this.id,
countryCode: countryCode, countryCode: countryCode,
countryName: countryName, countryName: countryName,
adminArea: adminArea, adminArea: adminArea,
@ -34,7 +34,7 @@ class AddressDetails extends Equatable {
factory AddressDetails.fromMap(Map map) { factory AddressDetails.fromMap(Map map) {
return AddressDetails( return AddressDetails(
contentId: map['contentId'] as int?, id: map['id'] as int,
countryCode: map['countryCode'] as String?, countryCode: map['countryCode'] as String?,
countryName: map['countryName'] as String?, countryName: map['countryName'] as String?,
adminArea: map['adminArea'] as String?, adminArea: map['adminArea'] as String?,
@ -43,7 +43,7 @@ class AddressDetails extends Equatable {
} }
Map<String, dynamic> toMap() => { Map<String, dynamic> toMap() => {
'contentId': contentId, 'id': id,
'countryCode': countryCode, 'countryCode': countryCode,
'countryName': countryName, 'countryName': countryName,
'adminArea': adminArea, 'adminArea': adminArea,

View file

@ -2,7 +2,8 @@ import 'package:aves/services/geocoding_service.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
class CatalogMetadata { class CatalogMetadata {
final int? contentId, dateMillis; final int id;
final int? dateMillis;
final bool isAnimated, isGeotiff, is360, isMultiPage; final bool isAnimated, isGeotiff, is360, isMultiPage;
bool isFlipped; bool isFlipped;
int? rotationDegrees; int? rotationDegrees;
@ -19,7 +20,7 @@ class CatalogMetadata {
static const _isMultiPageMask = 1 << 4; static const _isMultiPageMask = 1 << 4;
CatalogMetadata({ CatalogMetadata({
this.contentId, required this.id,
this.mimeType, this.mimeType,
this.dateMillis, this.dateMillis,
this.isAnimated = false, this.isAnimated = false,
@ -49,14 +50,14 @@ class CatalogMetadata {
} }
CatalogMetadata copyWith({ CatalogMetadata copyWith({
int? contentId, int? id,
String? mimeType, String? mimeType,
int? dateMillis, int? dateMillis,
bool? isMultiPage, bool? isMultiPage,
int? rotationDegrees, int? rotationDegrees,
}) { }) {
return CatalogMetadata( return CatalogMetadata(
contentId: contentId ?? this.contentId, id: id ?? this.id,
mimeType: mimeType ?? this.mimeType, mimeType: mimeType ?? this.mimeType,
dateMillis: dateMillis ?? this.dateMillis, dateMillis: dateMillis ?? this.dateMillis,
isAnimated: isAnimated, isAnimated: isAnimated,
@ -76,7 +77,7 @@ class CatalogMetadata {
factory CatalogMetadata.fromMap(Map map) { factory CatalogMetadata.fromMap(Map map) {
final flags = map['flags'] ?? 0; final flags = map['flags'] ?? 0;
return CatalogMetadata( return CatalogMetadata(
contentId: map['contentId'], id: map['id'],
mimeType: map['mimeType'], mimeType: map['mimeType'],
dateMillis: map['dateMillis'] ?? 0, dateMillis: map['dateMillis'] ?? 0,
isAnimated: flags & _isAnimatedMask != 0, isAnimated: flags & _isAnimatedMask != 0,
@ -95,7 +96,7 @@ class CatalogMetadata {
} }
Map<String, dynamic> toMap() => { Map<String, dynamic> toMap() => {
'contentId': contentId, 'id': id,
'mimeType': mimeType, 'mimeType': mimeType,
'dateMillis': dateMillis, 'dateMillis': dateMillis,
'flags': (isAnimated ? _isAnimatedMask : 0) | (isFlipped ? _isFlippedMask : 0) | (isGeotiff ? _isGeotiffMask : 0) | (is360 ? _is360Mask : 0) | (isMultiPage ? _isMultiPageMask : 0), 'flags': (isAnimated ? _isAnimatedMask : 0) | (isFlipped ? _isFlippedMask : 0) | (isGeotiff ? _isGeotiffMask : 0) | (is360 ? _is360Mask : 0) | (isMultiPage ? _isMultiPageMask : 0),
@ -108,5 +109,5 @@ class CatalogMetadata {
}; };
@override @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 // and retrieve cached images for it
final pageId = eraseDefaultPageId && pageInfo.isDefault ? null : pageInfo.pageId; 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( return AvesEntry(
id: mainEntry.id,
uri: pageInfo.uri ?? mainEntry.uri, uri: pageInfo.uri ?? mainEntry.uri,
path: mainEntry.path, path: mainEntry.path,
contentId: mainEntry.contentId, contentId: mainEntry.contentId,
@ -101,13 +105,15 @@ class MultiPageInfo {
dateModifiedSecs: mainEntry.dateModifiedSecs, dateModifiedSecs: mainEntry.dateModifiedSecs,
sourceDateTakenMillis: mainEntry.sourceDateTakenMillis, sourceDateTakenMillis: mainEntry.sourceDateTakenMillis,
durationMillis: pageInfo.durationMillis ?? mainEntry.durationMillis, durationMillis: pageInfo.durationMillis ?? mainEntry.durationMillis,
trashed: trashed,
) )
..catalogMetadata = mainEntry.catalogMetadata?.copyWith( ..catalogMetadata = mainEntry.catalogMetadata?.copyWith(
mimeType: pageInfo.mimeType, mimeType: pageInfo.mimeType,
isMultiPage: false, isMultiPage: false,
rotationDegrees: pageInfo.rotationDegrees, rotationDegrees: pageInfo.rotationDegrees,
) )
..addressDetails = mainEntry.addressDetails?.copyWith(); ..addressDetails = mainEntry.addressDetails?.copyWith()
..trashDetails = trashed ? mainEntry.trashDetails : null;
} }
@override @override

View file

@ -100,6 +100,9 @@ class SettingsDefaults {
// search // search
static const saveSearchHistory = true; static const saveSearchHistory = true;
// bin
static const enableBin = true;
// accessibility // accessibility
static const accessibilityAnimations = AccessibilityAnimations.system; static const accessibilityAnimations = AccessibilityAnimations.system;
static const timeToTakeAction = AccessibilityTimeout.appDefault; // `timeToTakeAction` has a contextual default value 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 saveSearchHistoryKey = 'save_search_history';
static const searchHistoryKey = 'search_history'; static const searchHistoryKey = 'search_history';
// bin
static const enableBinKey = 'enable_bin';
// accessibility // accessibility
static const accessibilityAnimationsKey = 'accessibility_animations'; static const accessibilityAnimationsKey = 'accessibility_animations';
static const timeToTakeActionKey = 'time_to_take_action'; 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()); 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 // accessibility
AccessibilityAnimations get accessibilityAnimations => getEnumOrDefault(accessibilityAnimationsKey, SettingsDefaults.accessibilityAnimations, AccessibilityAnimations.values); 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:aves/model/settings/store/store.dart';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
class SharedPrefSettingsStore implements SettingsStore { class SharedPrefSettingsStore implements SettingsStore {
@ -9,7 +10,11 @@ class SharedPrefSettingsStore implements SettingsStore {
@override @override
Future<void> init() async { Future<void> init() async {
_prefs = await SharedPreferences.getInstance(); try {
_prefs = await SharedPreferences.getInstance();
} catch (error, stack) {
debugPrint('$runtimeType init error=$error\n$stack');
}
} }
@override @override

View file

@ -2,12 +2,12 @@ import 'package:flutter/foundation.dart';
class AnalysisController { class AnalysisController {
final bool canStartService, force; final bool canStartService, force;
final List<int>? contentIds; final List<int>? entryIds;
final ValueNotifier<bool> stopSignal; final ValueNotifier<bool> stopSignal;
AnalysisController({ AnalysisController({
this.canStartService = true, this.canStartService = true,
this.contentIds, this.entryIds,
this.force = false, this.force = false,
ValueNotifier<bool>? stopSignal, ValueNotifier<bool>? stopSignal,
}) : stopSignal = stopSignal ?? ValueNotifier(false); }) : 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/location.dart';
import 'package:aves/model/filters/query.dart'; import 'package:aves/model/filters/query.dart';
import 'package:aves/model/filters/rating.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/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/events.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<EntryAddedEvent>().listen((e) => _onEntryAdded(e.entries)));
_subscriptions.add(sourceEvents.on<EntryRemovedEvent>().listen((e) => _onEntryRemoved(e.entries))); _subscriptions.add(sourceEvents.on<EntryRemovedEvent>().listen((e) => _onEntryRemoved(e.entries)));
_subscriptions.add(sourceEvents.on<EntryMovedEvent>().listen((e) { _subscriptions.add(sourceEvents.on<EntryMovedEvent>().listen((e) {
if (e.type == MoveType.move) { switch (e.type) {
// refreshing copied items is already handled via `EntryAddedEvent`s case MoveType.copy:
_refresh(); 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())); _subscriptions.add(sourceEvents.on<EntryRefreshedEvent>().listen((e) => _refresh()));
@ -167,7 +177,7 @@ class CollectionLens with ChangeNotifier {
final bool groupBursts = true; final bool groupBursts = true;
void _applyFilters() { 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)))); _filteredSortedEntries = List.of(filters.isEmpty ? entries : entries.where((entry) => filters.every((filter) => filter.test(entry))));
if (groupBursts) { 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/filters.dart';
import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/location.dart';
import 'package:aves/model/filters/tag.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/settings/settings.dart';
import 'package:aves/model/source/album.dart'; import 'package:aves/model/source/album.dart';
import 'package:aves/model/source/analysis_controller.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/events.dart';
import 'package:aves/model/source/location.dart'; import 'package:aves/model/source/location.dart';
import 'package:aves/model/source/tag.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/analysis_service.dart';
import 'package:aves/services/common/image_op_events.dart'; import 'package:aves/services/common/image_op_events.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
@ -25,10 +28,12 @@ import 'package:flutter/foundation.dart';
mixin SourceBase { mixin SourceBase {
EventBus get eventBus; EventBus get eventBus;
Map<int?, AvesEntry> get entryById; Map<int, AvesEntry> get entryById;
Set<AvesEntry> get visibleEntries; Set<AvesEntry> get visibleEntries;
Set<AvesEntry> get trashedEntries;
List<AvesEntry> get sortedEntriesByDate; List<AvesEntry> get sortedEntriesByDate;
ValueNotifier<SourceState> stateNotifier = ValueNotifier(SourceState.ready); 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); 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() { CollectionSource() {
settings.updateStream.where((key) => key == Settings.localeKey).listen((_) => invalidateAlbumDisplayNames()); settings.updateStream.where((key) => key == Settings.localeKey).listen((_) => invalidateAlbumDisplayNames());
} }
@ -48,16 +53,16 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
@override @override
EventBus get eventBus => _eventBus; EventBus get eventBus => _eventBus;
final Map<int?, AvesEntry> _entryById = {}; final Map<int, AvesEntry> _entryById = {};
@override @override
Map<int?, AvesEntry> get entryById => Map.unmodifiable(_entryById); Map<int, AvesEntry> get entryById => Map.unmodifiable(_entryById);
final Set<AvesEntry> _rawEntries = {}; final Set<AvesEntry> _rawEntries = {};
Set<AvesEntry> get allEntries => Set.unmodifiable(_rawEntries); Set<AvesEntry> get allEntries => Set.unmodifiable(_rawEntries);
Set<AvesEntry>? _visibleEntries; Set<AvesEntry>? _visibleEntries, _trashedEntries;
@override @override
Set<AvesEntry> get visibleEntries { Set<AvesEntry> get visibleEntries {
@ -65,6 +70,12 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
return _visibleEntries!; return _visibleEntries!;
} }
@override
Set<AvesEntry> get trashedEntries {
_trashedEntries ??= Set.unmodifiable(_applyTrashFilter(_rawEntries));
return _trashedEntries!;
}
List<AvesEntry>? _sortedEntriesByDate; List<AvesEntry>? _sortedEntriesByDate;
@override @override
@ -73,6 +84,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
return _sortedEntriesByDate!; return _sortedEntriesByDate!;
} }
// known date by entry ID
late Map<int?, int?> _savedDates; late Map<int?, int?> _savedDates;
Future<void> loadDates() async { Future<void> loadDates() async {
@ -80,12 +92,20 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
} }
Iterable<AvesEntry> _applyHiddenFilters(Iterable<AvesEntry> entries) { Iterable<AvesEntry> _applyHiddenFilters(Iterable<AvesEntry> entries) {
final hiddenFilters = settings.hiddenFilters; final hiddenFilters = {
return hiddenFilters.isEmpty ? entries : entries.where((entry) => !hiddenFilters.any((filter) => filter.test(entry))); 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]) { void _invalidate([Set<AvesEntry>? entries]) {
_visibleEntries = null; _visibleEntries = null;
_trashedEntries = null;
_sortedEntriesByDate = null; _sortedEntriesByDate = null;
invalidateAlbumFilterSummary(entries: entries); invalidateAlbumFilterSummary(entries: entries);
invalidateCountryFilterSummary(entries: entries); invalidateCountryFilterSummary(entries: entries);
@ -104,14 +124,14 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
void addEntries(Set<AvesEntry> entries) { void addEntries(Set<AvesEntry> entries) {
if (entries.isEmpty) return; 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) { if (_rawEntries.isNotEmpty) {
final newContentIds = newIdMapEntries.keys.toSet(); final newIds = newIdMapEntries.keys.toSet();
_rawEntries.removeWhere((entry) => newContentIds.contains(entry.contentId)); _rawEntries.removeWhere((entry) => newIds.contains(entry.id));
} }
entries.where((entry) => entry.catalogDateMillis == null).forEach((entry) { entries.where((entry) => entry.catalogDateMillis == null).forEach((entry) {
entry.catalogDateMillis = _savedDates[entry.contentId]; entry.catalogDateMillis = _savedDates[entry.id];
}); });
_entryById.addAll(newIdMapEntries); _entryById.addAll(newIdMapEntries);
@ -122,14 +142,21 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
eventBus.fire(EntryAddedEvent(entries)); 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; 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); _rawEntries.removeAll(entries);
updateDerivedFilters(entries); updateDerivedFilters(entries);
eventBus.fire(EntryRemovedEvent(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 { Future<void> _moveEntry(AvesEntry entry, Map newFields, {required bool persist}) async {
final oldContentId = entry.contentId!; newFields.keys.forEach((key) {
final newContentId = newFields['contentId'] as int?; switch (key) {
case 'contentId':
entry.contentId = newFields['contentId'] as int?;
break;
case 'dateModifiedSecs':
// `dateModifiedSecs` changes when moving entries to another directory,
// but it does not change when renaming the containing directory
entry.dateModifiedSecs = newFields['dateModifiedSecs'] as int?;
break;
case 'path':
entry.path = newFields['path'] as String?;
break;
case 'title':
entry.sourceTitle = newFields['title'] as String?;
break;
case 'trashed':
final trashed = newFields['trashed'] as bool;
entry.trashed = trashed;
entry.trashDetails = trashed
? TrashDetails(
id: entry.id,
path: newFields['trashPath'] as String,
dateMillis: DateTime.now().millisecondsSinceEpoch,
)
: null;
break;
case 'uri':
entry.uri = newFields['uri'] as String;
break;
}
});
if (entry.trashed) {
entry.contentId = null;
entry.uri = 'file://${entry.trashDetails?.path}';
}
entry.contentId = newContentId; await covers.moveEntry(entry, persist: persist);
// `dateModifiedSecs` changes when moving entries to another directory,
// but it does not change when renaming the containing directory
if (newFields.containsKey('dateModifiedSecs')) entry.dateModifiedSecs = newFields['dateModifiedSecs'] as int?;
if (newFields.containsKey('path')) entry.path = newFields['path'] as String?;
if (newFields.containsKey('uri')) entry.uri = newFields['uri'] as String;
if (newFields.containsKey('title')) entry.sourceTitle = newFields['title'] as String?;
entry.catalogMetadata = entry.catalogMetadata?.copyWith(contentId: newContentId);
entry.addressDetails = entry.addressDetails?.copyWith(contentId: newContentId);
if (persist) { if (persist) {
await metadataDb.updateEntryId(oldContentId, entry); final id = entry.id;
await metadataDb.updateMetadataId(oldContentId, entry.catalogMetadata); await metadataDb.updateEntry(id, entry);
await metadataDb.updateAddressId(oldContentId, entry.addressDetails); await metadataDb.updateMetadata(id, entry.catalogMetadata);
await favourites.moveEntry(oldContentId, entry); await metadataDb.updateAddress(id, entry.addressDetails);
await covers.moveEntry(oldContentId, entry); await metadataDb.updateTrash(id, entry.trashDetails);
await metadataDb.updateVideoPlaybackId(oldContentId, entry.contentId);
} }
} }
@ -202,42 +253,40 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
return success; 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 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 pinned = settings.pinnedFilters.contains(oldFilter);
final oldCoverContentId = covers.coverContentId(oldFilter); await covers.set(newFilter, covers.coverEntryId(oldFilter));
final coverEntry = oldCoverContentId != null ? todoEntries.firstWhereOrNull((entry) => entry.contentId == oldCoverContentId) : null;
renameNewAlbum(sourceAlbum, destinationAlbum); renameNewAlbum(sourceAlbum, destinationAlbum);
await updateAfterMove( await updateAfterMove(
todoEntries: todoEntries, todoEntries: entries,
copy: false, moveType: MoveType.move,
destinationAlbum: destinationAlbum, destinationAlbums: {destinationAlbum},
movedOps: movedOps, movedOps: movedOps,
); );
// restore bookmark, pin and cover, as the obsolete album got removed and its associated state cleaned // restore bookmark and pin, as the obsolete album got removed and its associated state cleaned
final newFilter = AlbumFilter(destinationAlbum, null); if (bookmark != null && bookmark != -1) {
if (bookmarked) { settings.drawerAlbumBookmarks = settings.drawerAlbumBookmarks?..insert(bookmark, destinationAlbum);
settings.drawerAlbumBookmarks = settings.drawerAlbumBookmarks?..add(destinationAlbum);
} }
if (pinned) { if (pinned) {
settings.pinnedFilters = settings.pinnedFilters..add(newFilter); settings.pinnedFilters = settings.pinnedFilters..add(newFilter);
} }
if (coverEntry != null) {
await covers.set(newFilter, coverEntry.contentId);
}
} }
Future<void> updateAfterMove({ Future<void> updateAfterMove({
required Set<AvesEntry> todoEntries, required Set<AvesEntry> todoEntries,
required bool copy, required MoveType moveType,
required String destinationAlbum, required Set<String> destinationAlbums,
required Set<MoveOpEvent> movedOps, required Set<MoveOpEvent> movedOps,
}) async { }) async {
if (movedOps.isEmpty) return; if (movedOps.isEmpty) return;
final fromAlbums = <String?>{}; final fromAlbums = <String?>{};
final movedEntries = <AvesEntry>{}; final movedEntries = <AvesEntry>{};
final copy = moveType == MoveType.copy;
if (copy) { if (copy) {
movedOps.forEach((movedOp) { movedOps.forEach((movedOp) {
final sourceUri = movedOp.uri; final sourceUri = movedOp.uri;
@ -246,6 +295,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
if (sourceEntry != null) { if (sourceEntry != null) {
fromAlbums.add(sourceEntry.directory); fromAlbums.add(sourceEntry.directory);
movedEntries.add(sourceEntry.copyWith( movedEntries.add(sourceEntry.copyWith(
id: metadataDb.nextId,
uri: newFields['uri'] as String?, uri: newFields['uri'] as String?,
path: newFields['path'] as String?, path: newFields['path'] as String?,
contentId: newFields['contentId'] as int?, contentId: newFields['contentId'] as int?,
@ -267,7 +317,11 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
final sourceUri = movedOp.uri; final sourceUri = movedOp.uri;
final entry = todoEntries.firstWhereOrNull((entry) => entry.uri == sourceUri); final entry = todoEntries.firstWhereOrNull((entry) => entry.uri == sourceUri);
if (entry != null) { if (entry != null) {
fromAlbums.add(entry.directory); if (moveType == MoveType.fromBin) {
newFields['trashed'] = false;
} else {
fromAlbums.add(entry.directory);
}
movedEntries.add(entry); movedEntries.add(entry);
await _moveEntry(entry, newFields, persist: true); await _moveEntry(entry, newFields, persist: true);
} }
@ -279,11 +333,13 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
addEntries(movedEntries); addEntries(movedEntries);
} else { } else {
cleanEmptyAlbums(fromAlbums); cleanEmptyAlbums(fromAlbums);
addDirectories({destinationAlbum}); if (moveType != MoveType.toBin) {
addDirectories(destinationAlbums);
}
} }
invalidateAlbumFilterSummary(directories: fromAlbums); invalidateAlbumFilterSummary(directories: fromAlbums);
_invalidate(movedEntries); _invalidate(movedEntries);
eventBus.fire(EntryMovedEvent(copy ? MoveType.copy : MoveType.move, movedEntries)); eventBus.fire(EntryMovedEvent(moveType, movedEntries));
} }
bool get initialized => false; 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); await entry.refresh(background: false, persist: true, dataTypes: dataTypes, geocoderLocale: settings.appliedLocale);
// update/delete in DB // update/delete in DB
final contentId = entry.contentId!; final id = entry.id;
if (dataTypes.contains(EntryDataType.catalog)) { if (dataTypes.contains(EntryDataType.catalog)) {
await metadataDb.updateMetadataId(contentId, entry.catalogMetadata); await metadataDb.updateMetadata(id, entry.catalogMetadata);
onCatalogMetadataChanged(); onCatalogMetadataChanged();
} }
if (dataTypes.contains(EntryDataType.address)) { if (dataTypes.contains(EntryDataType.address)) {
await metadataDb.updateAddressId(contentId, entry.addressDetails); await metadataDb.updateAddress(id, entry.addressDetails);
onAddressMetadataChanged(); onAddressMetadataChanged();
} }
@ -338,7 +394,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
if (startAnalysisService) { if (startAnalysisService) {
await AnalysisService.startService( await AnalysisService.startService(
force: force, force: force,
contentIds: entries?.map((entry) => entry.contentId).whereNotNull().toList(), entryIds: entries?.map((entry) => entry.id).toList(),
); );
} else { } else {
await catalogEntries(_analysisController, todoEntries); await catalogEntries(_analysisController, todoEntries);
@ -377,9 +433,9 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
} }
AvesEntry? coverEntry(CollectionFilter filter) { AvesEntry? coverEntry(CollectionFilter filter) {
final contentId = covers.coverContentId(filter); final id = covers.coverEntryId(filter);
if (contentId != null) { if (id != null) {
final entry = visibleEntries.firstWhereOrNull((entry) => entry.contentId == contentId); final entry = visibleEntries.firstWhereOrNull((entry) => entry.id == id);
if (entry != null) return entry; if (entry != null) return entry;
} }
return recentEntry(filter); return recentEntry(filter);

View file

@ -23,7 +23,7 @@ mixin LocationMixin on SourceBase {
Future<void> loadAddresses() async { Future<void> loadAddresses() async {
final saved = await metadataDb.loadAllAddresses(); final saved = await metadataDb.loadAllAddresses();
final idMap = entryById; final idMap = entryById;
saved.forEach((metadata) => idMap[metadata.contentId]?.addressDetails = metadata); saved.forEach((metadata) => idMap[metadata.id]?.addressDetails = metadata);
onAddressMetadataChanged(); onAddressMetadataChanged();
} }
@ -31,7 +31,7 @@ mixin LocationMixin on SourceBase {
await _locateCountries(controller, candidateEntries); await _locateCountries(controller, candidateEntries);
await _locatePlaces(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) { if (unlocatedIds.isNotEmpty) {
await metadataDb.removeIds(unlocatedIds, dataTypes: {EntryDataType.address}); await metadataDb.removeIds(unlocatedIds, dataTypes: {EntryDataType.address});
onAddressMetadataChanged(); onAddressMetadataChanged();
@ -115,7 +115,7 @@ mixin LocationMixin on SourceBase {
for (final entry in todo) { for (final entry in todo) {
final latLng = approximateLatLng(entry); final latLng = approximateLatLng(entry);
if (knownLocations.containsKey(latLng)) { if (knownLocations.containsKey(latLng)) {
entry.addressDetails = knownLocations[latLng]?.copyWith(contentId: entry.contentId); entry.addressDetails = knownLocations[latLng]?.copyWith(id: entry.id);
} else { } else {
await entry.locatePlace(background: true, force: force, geocoderLocale: settings.appliedLocale); await entry.locatePlace(background: true, force: force, geocoderLocale: settings.appliedLocale);
// it is intended to insert `null` if the geocoder failed, // 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/covers.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/favourites.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/settings/settings.dart';
import 'package:aves/model/source/analysis_controller.dart'; import 'package:aves/model/source/analysis_controller.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
@ -50,31 +51,33 @@ class MediaStoreSource extends CollectionSource {
stateNotifier.value = SourceState.loading; stateNotifier.value = SourceState.loading;
clearEntries(); clearEntries();
final topIds = settings.topEntryIds; final Set<AvesEntry> topEntries = {};
late final Set<AvesEntry> topEntries; if (settings.homePage == HomePageSetting.collection) {
if (topIds != null) { final topIds = settings.topEntryIds;
debugPrint('$runtimeType refresh ${stopwatch.elapsed} load ${topIds.length} top entries'); if (topIds != null) {
topEntries = await metadataDb.loadEntries(topIds); debugPrint('$runtimeType refresh ${stopwatch.elapsed} load ${topIds.length} top entries');
addEntries(topEntries); topEntries.addAll(await metadataDb.loadEntries(topIds));
} else { addEntries(topEntries);
topEntries = {}; }
} }
debugPrint('$runtimeType refresh ${stopwatch.elapsed} fetch known entries'); debugPrint('$runtimeType refresh ${stopwatch.elapsed} fetch known entries');
final oldEntries = await metadataDb.loadAllEntries(); final knownEntries = await metadataDb.loadAllEntries();
debugPrint('$runtimeType refresh ${stopwatch.elapsed} check obsolete entries'); final knownLiveEntries = knownEntries.where((entry) => !entry.trashed).toSet();
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));
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) { if (topEntries.isNotEmpty) {
final obsoleteTopEntries = topEntries.where((entry) => obsoleteContentIds.contains(entry.contentId)); 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 // show known entries
debugPrint('$runtimeType refresh ${stopwatch.elapsed} add known entries'); debugPrint('$runtimeType refresh ${stopwatch.elapsed} add known entries');
addEntries(oldEntries); addEntries(knownEntries);
debugPrint('$runtimeType refresh ${stopwatch.elapsed} load metadata'); debugPrint('$runtimeType refresh ${stopwatch.elapsed} load metadata');
await loadCatalogMetadata(); await loadCatalogMetadata();
await loadAddresses(); await loadAddresses();
@ -84,16 +87,28 @@ class MediaStoreSource extends CollectionSource {
debugPrint('$runtimeType refresh ${stopwatch.elapsed} remove obsolete entries'); debugPrint('$runtimeType refresh ${stopwatch.elapsed} remove obsolete entries');
await metadataDb.removeIds(obsoleteContentIds); 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` // verify paths because some apps move files without updating their `last modified date`
debugPrint('$runtimeType refresh ${stopwatch.elapsed} check obsolete paths'); debugPrint('$runtimeType refresh ${stopwatch.elapsed} check obsolete paths');
final knownPathById = Map.fromEntries(allEntries.map((entry) => MapEntry(entry.contentId!, entry.path))); final knownPathByContentId = Map.fromEntries(knownLiveEntries.map((entry) => MapEntry(entry.contentId, entry.path)));
final movedContentIds = (await mediaStoreService.checkObsoletePaths(knownPathById)).toSet(); final movedContentIds = (await mediaStoreService.checkObsoletePaths(knownPathByContentId)).toSet();
movedContentIds.forEach((contentId) { movedContentIds.forEach((contentId) {
// make obsolete by resetting its modified date // 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'); debugPrint('$runtimeType refresh ${stopwatch.elapsed} fetch new entries');
// refresh after the first 10 entries, then after 100 more, then every 1000 entries // refresh after the first 10 entries, then after 100 more, then every 1000 entries
var refreshCount = 10; var refreshCount = 10;
@ -105,8 +120,9 @@ class MediaStoreSource extends CollectionSource {
pendingNewEntries.clear(); pendingNewEntries.clear();
} }
mediaStoreService.getEntries(knownDateById).listen( mediaStoreService.getEntries(knownDateByContentId).listen(
(entry) { (entry) {
entry.id = metadataDb.nextId;
pendingNewEntries.add(entry); pendingNewEntries.add(entry);
if (pendingNewEntries.length >= refreshCount) { if (pendingNewEntries.length >= refreshCount) {
refreshCount = min(refreshCount * 10, refreshCountMax); refreshCount = min(refreshCount * 10, refreshCountMax);
@ -127,13 +143,13 @@ class MediaStoreSource extends CollectionSource {
} }
Set<AvesEntry>? analysisEntries; Set<AvesEntry>? analysisEntries;
final analysisIds = analysisController?.contentIds; final analysisIds = analysisController?.entryIds;
if (analysisIds != null) { 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); 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'), onError: (error) => debugPrint('$runtimeType stream error=$error'),
); );
@ -162,7 +178,7 @@ class MediaStoreSource extends CollectionSource {
// clean up obsolete entries // clean up obsolete entries
final obsoleteContentIds = (await mediaStoreService.checkObsoleteContentIds(uriByContentId.keys.toList())).toSet(); final obsoleteContentIds = (await mediaStoreService.checkObsoleteContentIds(uriByContentId.keys.toList())).toSet();
final obsoleteUris = obsoleteContentIds.map((contentId) => uriByContentId[contentId]).whereNotNull().toSet(); final obsoleteUris = obsoleteContentIds.map((contentId) => uriByContentId[contentId]).whereNotNull().toSet();
await removeEntries(obsoleteUris); await removeEntries(obsoleteUris, includeTrash: false);
obsoleteContentIds.forEach(uriByContentId.remove); obsoleteContentIds.forEach(uriByContentId.remove);
// fetch new entries // fetch new entries
@ -180,6 +196,7 @@ class MediaStoreSource extends CollectionSource {
final newPath = sourceEntry.path; final newPath = sourceEntry.path;
final volume = newPath != null ? androidFileUtils.getStorageVolume(newPath) : null; final volume = newPath != null ? androidFileUtils.getStorageVolume(newPath) : null;
if (volume != null) { if (volume != null) {
sourceEntry.id = existingEntry?.id ?? metadataDb.nextId;
newEntries.add(sourceEntry); newEntries.add(sourceEntry);
final existingDirectory = existingEntry?.directory; final existingDirectory = existingEntry?.directory;
if (existingDirectory != null) { if (existingDirectory != null) {

View file

@ -17,7 +17,7 @@ mixin TagMixin on SourceBase {
Future<void> loadCatalogMetadata() async { Future<void> loadCatalogMetadata() async {
final saved = await metadataDb.loadAllMetadataEntries(); final saved = await metadataDb.loadAllMetadataEntries();
final idMap = entryById; final idMap = entryById;
saved.forEach((metadata) => idMap[metadata.contentId]?.catalogMetadata = metadata); saved.forEach((metadata) => idMap[metadata.id]?.catalogMetadata = metadata);
onCatalogMetadataChanged(); 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) { if (dateMillis != null) {
return (entry.catalogMetadata ?? CatalogMetadata(contentId: entry.contentId)).copyWith( return (entry.catalogMetadata ?? CatalogMetadata(id: entry.id)).copyWith(
dateMillis: dateMillis, dateMillis: dateMillis,
); );
} }

View file

@ -3,25 +3,25 @@ import 'package:flutter/foundation.dart';
@immutable @immutable
class VideoPlaybackRow extends Equatable { class VideoPlaybackRow extends Equatable {
final int contentId, resumeTimeMillis; final int entryId, resumeTimeMillis;
@override @override
List<Object?> get props => [contentId, resumeTimeMillis]; List<Object?> get props => [entryId, resumeTimeMillis];
const VideoPlaybackRow({ const VideoPlaybackRow({
required this.contentId, required this.entryId,
required this.resumeTimeMillis, required this.resumeTimeMillis,
}); });
static VideoPlaybackRow? fromMap(Map map) { static VideoPlaybackRow? fromMap(Map map) {
return VideoPlaybackRow( return VideoPlaybackRow(
contentId: map['contentId'], entryId: map['id'],
resumeTimeMillis: map['resumeTimeMillis'], resumeTimeMillis: map['resumeTimeMillis'],
); );
} }
Map<String, dynamic> toMap() => { Map<String, dynamic> toMap() => {
'contentId': contentId, 'id': entryId,
'resumeTimeMillis': resumeTimeMillis, '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 { try {
await platform.invokeMethod('startService', <String, dynamic>{ await platform.invokeMethod('startService', <String, dynamic>{
'contentIds': contentIds, 'entryIds': entryIds,
'force': force, 'force': force,
}); });
} on PlatformException catch (e, stack) { } on PlatformException catch (e, stack) {
@ -98,16 +98,16 @@ class Analyzer {
} }
Future<void> start(dynamic args) async { Future<void> start(dynamic args) async {
debugPrint('$runtimeType start'); List<int>? entryIds;
List<int>? contentIds;
var force = false; var force = false;
if (args is Map) { if (args is Map) {
contentIds = (args['contentIds'] as List?)?.cast<int>(); entryIds = (args['entryIds'] as List?)?.cast<int>();
force = args['force'] ?? false; force = args['force'] ?? false;
} }
debugPrint('$runtimeType start for ${entryIds?.length ?? 'all'} entries');
_controller = AnalysisController( _controller = AnalysisController(
canStartService: false, canStartService: false,
contentIds: contentIds, entryIds: entryIds,
force: force, force: force,
stopSignal: ValueNotifier(false), stopSignal: ValueNotifier(false),
); );

View file

@ -80,9 +80,8 @@ abstract class MediaFileService {
Stream<MoveOpEvent> move({ Stream<MoveOpEvent> move({
String? opId, String? opId,
required Iterable<AvesEntry> entries, required Map<String, Iterable<AvesEntry>> entriesByDestination,
required bool copy, required bool copy,
required String destinationAlbum,
required NameConflictStrategy nameConflictStrategy, required NameConflictStrategy nameConflictStrategy,
}); });
@ -126,6 +125,8 @@ class PlatformMediaFileService implements MediaFileService {
'isFlipped': entry.isFlipped, 'isFlipped': entry.isFlipped,
'dateModifiedSecs': entry.dateModifiedSecs, 'dateModifiedSecs': entry.dateModifiedSecs,
'sizeBytes': entry.sizeBytes, 'sizeBytes': entry.sizeBytes,
'trashed': entry.trashed,
'trashPath': entry.trashDetails?.path,
}; };
} }
@ -343,9 +344,8 @@ class PlatformMediaFileService implements MediaFileService {
@override @override
Stream<MoveOpEvent> move({ Stream<MoveOpEvent> move({
String? opId, String? opId,
required Iterable<AvesEntry> entries, required Map<String, Iterable<AvesEntry>> entriesByDestination,
required bool copy, required bool copy,
required String destinationAlbum,
required NameConflictStrategy nameConflictStrategy, required NameConflictStrategy nameConflictStrategy,
}) { }) {
try { try {
@ -353,9 +353,8 @@ class PlatformMediaFileService implements MediaFileService {
.receiveBroadcastStream(<String, dynamic>{ .receiveBroadcastStream(<String, dynamic>{
'op': 'move', 'op': 'move',
'id': opId, 'id': opId,
'entries': entries.map(_toPlatformEntryMap).toList(), 'entriesByDestination': entriesByDestination.map((destination, entries) => MapEntry(destination, entries.map(_toPlatformEntryMap).toList())),
'copy': copy, 'copy': copy,
'destinationPath': destinationAlbum,
'nameConflictStrategy': nameConflictStrategy.toPlatform(), 'nameConflictStrategy': nameConflictStrategy.toPlatform(),
}) })
.where((event) => event is Map) .where((event) => event is Map)

View file

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

View file

@ -79,7 +79,7 @@ class PlatformMetadataFetchService implements MetadataFetchService {
'path': entry.path, 'path': entry.path,
'sizeBytes': entry.sizeBytes, 'sizeBytes': entry.sizeBytes,
}) as Map; }) as Map;
result['contentId'] = entry.contentId; result['id'] = entry.id;
return CatalogMetadata.fromMap(result); return CatalogMetadata.fromMap(result);
} on PlatformException catch (e, stack) { } on PlatformException catch (e, stack) {
if (!entry.isMissingAtPath) { if (!entry.isMissingAtPath) {

View file

@ -9,6 +9,7 @@ class AIcons {
static const IconData accessibility = Icons.accessibility_new_outlined; static const IconData accessibility = Icons.accessibility_new_outlined;
static const IconData android = Icons.android; static const IconData android = Icons.android;
static const IconData bin = Icons.delete_outlined;
static const IconData broken = Icons.broken_image_outlined; static const IconData broken = Icons.broken_image_outlined;
static const IconData checked = Icons.done_outlined; static const IconData checked = Icons.done_outlined;
static const IconData date = Icons.calendar_today_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 add = Icons.add_circle_outline;
static const IconData addShortcut = Icons.add_to_home_screen_outlined; static const IconData addShortcut = Icons.add_to_home_screen_outlined;
static const IconData cancel = Icons.cancel_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 captureFrame = Icons.screenshot_outlined;
static const IconData clear = Icons.clear_outlined; static const IconData clear = Icons.clear_outlined;
static const IconData clipboard = Icons.content_copy_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 edit = Icons.edit_outlined;
static const IconData editRating = MdiIcons.starPlusOutline; static const IconData editRating = MdiIcons.starPlusOutline;
static const IconData editTags = MdiIcons.tagPlusOutline; 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 export = Icons.open_with_outlined;
static const IconData fileExport = MdiIcons.fileExportOutline; static const IconData fileExport = MdiIcons.fileExportOutline;
static const IconData fileImport = MdiIcons.fileImportOutline; static const IconData fileImport = MdiIcons.fileImportOutline;
@ -81,7 +81,10 @@ class AIcons {
static const IconData print = Icons.print_outlined; static const IconData print = Icons.print_outlined;
static const IconData refresh = Icons.refresh_outlined; static const IconData refresh = Icons.refresh_outlined;
static const IconData rename = Icons.title_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 reset = Icons.restart_alt_outlined;
static const IconData restore = Icons.restore_outlined;
static const IconData rotateLeft = Icons.rotate_left_outlined; static const IconData rotateLeft = Icons.rotate_left_outlined;
static const IconData rotateRight = Icons.rotate_right_outlined; static const IconData rotateRight = Icons.rotate_right_outlined;
static const IconData rotateScreen = Icons.screen_rotation_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(); final AndroidFileUtils androidFileUtils = AndroidFileUtils._private();
class AndroidFileUtils { class AndroidFileUtils {
static const String trashDirPath = '#trash';
late final String separator, primaryStorage, dcimPath, downloadPath, moviesPath, picturesPath, avesVideoCapturesPath; late final String separator, primaryStorage, dcimPath, downloadPath, moviesPath, picturesPath, avesVideoCapturesPath;
late final Set<String> videoCapturesPaths; late final Set<String> videoCapturesPaths;
Set<StorageVolume> storageVolumes = {}; 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/common/providers/highlight_info_provider.dart';
import 'package:aves/widgets/home_page.dart'; import 'package:aves/widgets/home_page.dart';
import 'package:aves/widgets/welcome_page.dart'; import 'package:aves/widgets/welcome_page.dart';
import 'package:collection/collection.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:fijkplayer/fijkplayer.dart'; import 'package:fijkplayer/fijkplayer.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -190,7 +189,7 @@ class _AvesAppState extends State<AvesApp> with WidgetsBindingObserver {
final columns = (screenSize.width / tileExtent).ceil(); final columns = (screenSize.width / tileExtent).ceil();
final count = rows * columns; final count = rows * columns;
final collection = CollectionLens(source: _mediaStoreSource, listenToSource: false); 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(); collection.dispose();
debugPrint('Saved $count top entries in ${stopwatch.elapsed.inMilliseconds}ms'); 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/app_mode.dart';
import 'package:aves/model/actions/entry_set_actions.dart'; import 'package:aves/model/actions/entry_set_actions.dart';
import 'package:aves/model/entry.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/query.dart';
import 'package:aves/model/filters/trash.dart';
import 'package:aves/model/query.dart'; import 'package:aves/model/query.dart';
import 'package:aves/model/selection.dart'; import 'package:aves/model/selection.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
@ -54,9 +56,13 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
CollectionLens get collection => widget.collection; CollectionLens get collection => widget.collection;
bool get isTrash => collection.filters.contains(TrashFilter.instance);
CollectionSource get source => collection.source; 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 @override
void initState() { void initState() {
@ -126,7 +132,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
children: [ children: [
if (showFilterBar) if (showFilterBar)
FilterBar( FilterBar(
filters: collection.filters.where((v) => !(v is QueryFilter && v.live)).toSet(), filters: visibleFilters,
removable: removableFilters, removable: removableFilters,
onTap: removableFilters ? collection.removeFilter : null, onTap: removableFilters ? collection.removeFilter : null,
), ),
@ -185,7 +191,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
); );
} else { } else {
final appMode = context.watch<ValueNotifier<AppMode>>().value; 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) { if (appMode == AppMode.main) {
title = SourceStateAwareAppBarTitle( title = SourceStateAwareAppBarTitle(
title: title, title: title,
@ -210,6 +216,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
isSelecting: isSelecting, isSelecting: isSelecting,
itemCount: collection.entryCount, itemCount: collection.entryCount,
selectedItemCount: selectedItemCount, selectedItemCount: selectedItemCount,
isTrash: isTrash,
); );
bool canApply(EntrySetAction action) => _actionDelegate.canApply( bool canApply(EntrySetAction action) => _actionDelegate.canApply(
action, action,
@ -220,7 +227,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
final canApplyEditActions = selectedItemCount > 0; final canApplyEditActions = selectedItemCount > 0;
final browsingQuickActions = settings.collectionBrowsingQuickActions; 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( final quickActionButtons = (isSelecting ? selectionQuickActions : browsingQuickActions).where(isVisible).map(
(action) => _toActionButton(action, enabled: canApply(action), selection: selection), (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( ...(isSelecting ? selectionMenuActions : browsingMenuActions).where(isVisible).map(
(action) => _toMenuItem(action, enabled: canApply(action), selection: selection), (action) => _toMenuItem(action, enabled: canApply(action), selection: selection),
), ),
if (isSelecting) if (isSelecting && !isTrash)
PopupMenuItem<EntrySetAction>( PopupMenuItem<EntrySetAction>(
enabled: canApplyEditActions, enabled: canApplyEditActions,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
@ -252,13 +259,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
title: context.l10n.collectionActionEdit, title: context.l10n.collectionActionEdit,
items: [ items: [
_buildRotateAndFlipMenuItems(context, canApply: canApply), _buildRotateAndFlipMenuItems(context, canApply: canApply),
...[ ...EntrySetActions.edit.where(isVisible).map((action) => _toMenuItem(action, enabled: canApply(action), selection: selection)),
EntrySetAction.editDate,
EntrySetAction.editLocation,
EntrySetAction.editRating,
EntrySetAction.editTags,
EntrySetAction.removeMetadata,
].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.map:
case EntrySetAction.stats: case EntrySetAction.stats:
case EntrySetAction.rescan: case EntrySetAction.rescan:
case EntrySetAction.emptyBin:
// selecting // selecting
case EntrySetAction.share: case EntrySetAction.share:
case EntrySetAction.delete: case EntrySetAction.delete:
case EntrySetAction.restore:
case EntrySetAction.copy: case EntrySetAction.copy:
case EntrySetAction.move: case EntrySetAction.move:
case EntrySetAction.toggleFavourite: case EntrySetAction.toggleFavourite:

View file

@ -37,7 +37,7 @@ import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';
class CollectionGrid extends StatefulWidget { class CollectionGrid extends StatefulWidget {
final String? settingsRouteKey; final String settingsRouteKey;
static const int columnCountDefault = 4; static const int columnCountDefault = 4;
static const double extentMin = 46; static const double extentMin = 46;
@ -46,7 +46,7 @@ class CollectionGrid extends StatefulWidget {
const CollectionGrid({ const CollectionGrid({
Key? key, Key? key,
this.settingsRouteKey, required this.settingsRouteKey,
}) : super(key: key); }) : super(key: key);
@override @override
@ -65,7 +65,7 @@ class _CollectionGridState extends State<CollectionGrid> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
_tileExtentController ??= TileExtentController( _tileExtentController ??= TileExtentController(
settingsRouteKey: widget.settingsRouteKey ?? context.currentRouteName!, settingsRouteKey: widget.settingsRouteKey,
columnCountDefault: CollectionGrid.columnCountDefault, columnCountDefault: CollectionGrid.columnCountDefault,
extentMin: CollectionGrid.extentMin, extentMin: CollectionGrid.extentMin,
extentMax: CollectionGrid.extentMax, extentMax: CollectionGrid.extentMax,
@ -114,7 +114,7 @@ class _CollectionGridContent extends StatelessWidget {
animation: favourites, animation: favourites,
builder: (context, child) { builder: (context, child) {
return InteractiveTile( return InteractiveTile(
key: ValueKey(entry.contentId), key: ValueKey(entry.id),
collection: collection, collection: collection,
entry: entry, entry: entry,
thumbnailExtent: thumbnailExtent, thumbnailExtent: thumbnailExtent,

View file

@ -1,6 +1,10 @@
import 'dart:async';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/query.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/selection.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/collection/collection_grid.dart'; import 'package:aves/widgets/collection/collection_grid.dart';
import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/basic/insets.dart';
@ -29,10 +33,25 @@ class CollectionPage extends StatefulWidget {
} }
class _CollectionPageState extends State<CollectionPage> { class _CollectionPageState extends State<CollectionPage> {
final List<StreamSubscription> _subscriptions = [];
CollectionLens get collection => widget.collection; 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 @override
void dispose() { void dispose() {
_subscriptions
..forEach((sub) => sub.cancel())
..clear();
collection.dispose(); collection.dispose();
super.dispose(); super.dispose();
} }
@ -64,6 +83,7 @@ class _CollectionPageState extends State<CollectionPage> {
child: const CollectionGrid( child: const CollectionGrid(
// key is expected by test driver // key is expected by test driver
key: Key('collection-grid'), 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, 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/filters/filters.dart';
import 'package:aves/model/query.dart'; import 'package:aves/model/query.dart';
import 'package:aves/model/selection.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/analysis_controller.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
@ -41,6 +42,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
required bool isSelecting, required bool isSelecting,
required int itemCount, required int itemCount,
required int selectedItemCount, required int selectedItemCount,
required bool isTrash,
}) { }) {
switch (action) { switch (action) {
// general // general
@ -58,15 +60,19 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
case EntrySetAction.toggleTitleSearch: case EntrySetAction.toggleTitleSearch:
return !isSelecting; return !isSelecting;
case EntrySetAction.addShortcut: 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 // browsing or selecting
case EntrySetAction.map: case EntrySetAction.map:
case EntrySetAction.stats: case EntrySetAction.stats:
case EntrySetAction.rescan:
return appMode == AppMode.main; return appMode == AppMode.main;
case EntrySetAction.rescan:
return appMode == AppMode.main && !isTrash;
// selecting // selecting
case EntrySetAction.share:
case EntrySetAction.delete: case EntrySetAction.delete:
return appMode == AppMode.main && isSelecting;
case EntrySetAction.share:
case EntrySetAction.copy: case EntrySetAction.copy:
case EntrySetAction.move: case EntrySetAction.move:
case EntrySetAction.toggleFavourite: case EntrySetAction.toggleFavourite:
@ -78,7 +84,9 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
case EntrySetAction.editRating: case EntrySetAction.editRating:
case EntrySetAction.editTags: case EntrySetAction.editTags:
case EntrySetAction.removeMetadata: 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.toggleTitleSearch:
case EntrySetAction.addShortcut: case EntrySetAction.addShortcut:
return true; return true;
case EntrySetAction.emptyBin:
return !isSelecting && hasItems;
case EntrySetAction.map: case EntrySetAction.map:
case EntrySetAction.stats: case EntrySetAction.stats:
case EntrySetAction.rescan: case EntrySetAction.rescan:
@ -111,6 +121,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
// selecting // selecting
case EntrySetAction.share: case EntrySetAction.share:
case EntrySetAction.delete: case EntrySetAction.delete:
case EntrySetAction.restore:
case EntrySetAction.copy: case EntrySetAction.copy:
case EntrySetAction.move: case EntrySetAction.move:
case EntrySetAction.toggleFavourite: case EntrySetAction.toggleFavourite:
@ -159,8 +170,12 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
_share(context); _share(context);
break; break;
case EntrySetAction.delete: case EntrySetAction.delete:
case EntrySetAction.emptyBin:
_delete(context); _delete(context);
break; break;
case EntrySetAction.restore:
_move(context, moveType: MoveType.fromBin);
break;
case EntrySetAction.copy: case EntrySetAction.copy:
_move(context, moveType: MoveType.copy); _move(context, moveType: MoveType.copy);
break; break;
@ -197,53 +212,61 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
} }
} }
Set<AvesEntry> _getExpandedSelectedItems(Selection<AvesEntry> selection) { Set<AvesEntry> _getTargetItems(BuildContext context) {
return selection.selectedItems.expand((entry) => entry.burstEntries ?? {entry}).toSet(); 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) { void _share(BuildContext context) {
final selection = context.read<Selection<AvesEntry>>(); final entries = _getTargetItems(context);
final selectedItems = _getExpandedSelectedItems(selection); androidAppService.shareEntries(entries).then((success) {
androidAppService.shareEntries(selectedItems).then((success) {
if (!success) showNoMatchingAppDialog(context); if (!success) showNoMatchingAppDialog(context);
}); });
} }
void _rescan(BuildContext context) { void _rescan(BuildContext context) {
final selection = context.read<Selection<AvesEntry>>(); final entries = _getTargetItems(context);
final collection = context.read<CollectionLens>();
final entries = (selection.isSelecting ? _getExpandedSelectedItems(selection) : collection.sortedEntries.toSet());
final controller = AnalysisController(canStartService: true, force: true); final controller = AnalysisController(canStartService: true, force: true);
final collection = context.read<CollectionLens>();
collection.source.analyze(controller, entries: entries); collection.source.analyze(controller, entries: entries);
final selection = context.read<Selection<AvesEntry>>();
selection.browse(); selection.browse();
} }
Future<void> _toggleFavourite(BuildContext context) async { Future<void> _toggleFavourite(BuildContext context) async {
final selection = context.read<Selection<AvesEntry>>(); final entries = _getTargetItems(context);
final selectedItems = _getExpandedSelectedItems(selection); if (entries.every((entry) => entry.isFavourite)) {
if (selectedItems.every((entry) => entry.isFavourite)) { await favourites.removeEntries(entries);
await favourites.remove(selectedItems);
} else { } else {
await favourites.add(selectedItems); await favourites.add(entries);
} }
final selection = context.read<Selection<AvesEntry>>();
selection.browse(); selection.browse();
} }
Future<void> _delete(BuildContext context) async { 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 source = context.read<CollectionSource>();
final selection = context.read<Selection<AvesEntry>>(); final selectionDirs = entries.map((e) => e.directory).whereNotNull().toSet();
final selectedItems = _getExpandedSelectedItems(selection); final todoCount = entries.length;
final selectionDirs = selectedItems.map((e) => e.directory).whereNotNull().toSet();
final todoCount = selectedItems.length;
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (context) { builder: (context) {
return AvesDialog( return AvesDialog(
content: Text(context.l10n.deleteEntriesConfirmationDialogMessage(todoCount)), content: Text(l10n.deleteEntriesConfirmationDialogMessage(todoCount)),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
@ -251,7 +274,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
), ),
TextButton( TextButton(
onPressed: () => Navigator.pop(context, true), 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 (confirmed == null || !confirmed) return;
if (!await checkStoragePermissionForAlbums(context, selectionDirs, entries: selectedItems)) return; if (!pureTrash && !await checkStoragePermissionForAlbums(context, selectionDirs, entries: entries)) return;
source.pauseMonitoring(); source.pauseMonitoring();
final opId = mediaFileService.newOpId; final opId = mediaFileService.newOpId;
await showOpReport<ImageOpEvent>( await showOpReport<ImageOpEvent>(
context: context, context: context,
opStream: mediaFileService.delete(opId: opId, entries: selectedItems), opStream: mediaFileService.delete(opId: opId, entries: entries),
itemCount: todoCount, itemCount: todoCount,
onCancel: () => mediaFileService.cancelFileOp(opId), onCancel: () => mediaFileService.cancelFileOp(opId),
onDone: (processed) async { onDone: (processed) async {
final successOps = processed.where((e) => e.success).toSet(); final successOps = processed.where((e) => e.success).toSet();
final deletedOps = successOps.where((e) => !e.skipped).toSet(); final deletedOps = successOps.where((e) => !e.skipped).toSet();
final deletedUris = deletedOps.map((event) => event.uri).toSet(); final deletedUris = deletedOps.map((event) => event.uri).toSet();
await source.removeEntries(deletedUris); await source.removeEntries(deletedUris, includeTrash: true);
selection.browse();
source.resumeMonitoring(); source.resumeMonitoring();
final successCount = successOps.length; final successCount = successOps.length;
@ -286,18 +308,21 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
await storageService.deleteEmptyDirectories(selectionDirs); await storageService.deleteEmptyDirectories(selectionDirs);
}, },
); );
final selection = context.read<Selection<AvesEntry>>();
selection.browse();
} }
Future<void> _move(BuildContext context, {required MoveType moveType}) async { 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 selection = context.read<Selection<AvesEntry>>();
final selectedItems = _getExpandedSelectedItems(selection);
await move(context, moveType: moveType, selectedItems: selectedItems);
selection.browse(); selection.browse();
} }
Future<void> _edit( Future<void> _edit(
BuildContext context, BuildContext context,
Selection<AvesEntry> selection,
Set<AvesEntry> todoItems, Set<AvesEntry> todoItems,
Future<Set<EntryDataType>> Function(AvesEntry entry) op, Future<Set<EntryDataType>> Function(AvesEntry entry) op,
) async { ) async {
@ -327,7 +352,6 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
onDone: (processed) async { onDone: (processed) async {
final successOps = processed.where((e) => e.success).toSet(); final successOps = processed.where((e) => e.success).toSet();
final editedOps = successOps.where((e) => !e.skipped).toSet(); final editedOps = successOps.where((e) => !e.skipped).toSet();
selection.browse();
source.resumeMonitoring(); source.resumeMonitoring();
unawaited(source.refreshUris(editedOps.map((v) => v.uri).toSet()).then((_) { 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, { BuildContext context, {
required Set<AvesEntry> selectedItems,
required bool Function(AvesEntry entry) canEdit, required bool Function(AvesEntry entry) canEdit,
}) async { }) async {
final bySupported = groupBy<AvesEntry, bool>(selectedItems, canEdit); final bySupported = groupBy<AvesEntry, bool>(_getTargetItems(context), canEdit);
final supported = (bySupported[true] ?? []).toSet(); final supported = (bySupported[true] ?? []).toSet();
final unsupported = (bySupported[false] ?? []).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 { Future<void> _rotate(BuildContext context, {required bool clockwise}) async {
final selection = context.read<Selection<AvesEntry>>(); final todoItems = await _getEditableTargetItems(context, canEdit: (entry) => entry.canRotateAndFlip);
final selectedItems = _getExpandedSelectedItems(selection);
final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canRotateAndFlip);
if (todoItems == null || todoItems.isEmpty) return; 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 { Future<void> _flip(BuildContext context) async {
final selection = context.read<Selection<AvesEntry>>(); final todoItems = await _getEditableTargetItems(context, canEdit: (entry) => entry.canRotateAndFlip);
final selectedItems = _getExpandedSelectedItems(selection);
final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canRotateAndFlip);
if (todoItems == null || todoItems.isEmpty) return; 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 { Future<void> _editDate(BuildContext context) async {
final selection = context.read<Selection<AvesEntry>>(); final todoItems = await _getEditableTargetItems(context, canEdit: (entry) => entry.canEditDate);
final selectedItems = _getExpandedSelectedItems(selection);
final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canEditDate);
if (todoItems == null || todoItems.isEmpty) return; if (todoItems == null || todoItems.isEmpty) return;
final modifier = await selectDateModifier(context, todoItems); final modifier = await selectDateModifier(context, todoItems);
if (modifier == null) return; 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 { Future<void> _editLocation(BuildContext context) async {
final collection = context.read<CollectionLens>(); final todoItems = await _getEditableTargetItems(context, canEdit: (entry) => entry.canEditLocation);
final selection = context.read<Selection<AvesEntry>>();
final selectedItems = _getExpandedSelectedItems(selection);
final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canEditLocation);
if (todoItems == null || todoItems.isEmpty) return; if (todoItems == null || todoItems.isEmpty) return;
final collection = context.read<CollectionLens>();
final location = await selectLocation(context, todoItems, collection); final location = await selectLocation(context, todoItems, collection);
if (location == null) return; 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 { Future<void> _editRating(BuildContext context) async {
final selection = context.read<Selection<AvesEntry>>(); final todoItems = await _getEditableTargetItems(context, canEdit: (entry) => entry.canEditRating);
final selectedItems = _getExpandedSelectedItems(selection);
final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canEditRating);
if (todoItems == null || todoItems.isEmpty) return; if (todoItems == null || todoItems.isEmpty) return;
final rating = await selectRating(context, todoItems); final rating = await selectRating(context, todoItems);
if (rating == null) return; 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 { Future<void> _editTags(BuildContext context) async {
final selection = context.read<Selection<AvesEntry>>(); final todoItems = await _getEditableTargetItems(context, canEdit: (entry) => entry.canEditTags);
final selectedItems = _getExpandedSelectedItems(selection);
final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canEditTags);
if (todoItems == null || todoItems.isEmpty) return; if (todoItems == null || todoItems.isEmpty) return;
final newTagsByEntry = await selectTags(context, todoItems); final newTagsByEntry = await selectTags(context, todoItems);
@ -474,26 +481,22 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
if (todoItems.isEmpty) return; 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 { Future<void> _removeMetadata(BuildContext context) async {
final selection = context.read<Selection<AvesEntry>>(); final todoItems = await _getEditableTargetItems(context, canEdit: (entry) => entry.canRemoveMetadata);
final selectedItems = _getExpandedSelectedItems(selection);
final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canRemoveMetadata);
if (todoItems == null || todoItems.isEmpty) return; if (todoItems == null || todoItems.isEmpty) return;
final types = await selectMetadataToRemove(context, todoItems); final types = await selectMetadataToRemove(context, todoItems);
if (types == null || types.isEmpty) return; 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) { void _goToMap(BuildContext context) {
final selection = context.read<Selection<AvesEntry>>();
final collection = context.read<CollectionLens>(); final collection = context.read<CollectionLens>();
final entries = (selection.isSelecting ? _getExpandedSelectedItems(selection) : collection.sortedEntries); final entries = _getTargetItems(context);
Navigator.push( Navigator.push(
context, context,
@ -512,9 +515,8 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
} }
void _goToStats(BuildContext context) { void _goToStats(BuildContext context) {
final selection = context.read<Selection<AvesEntry>>();
final collection = context.read<CollectionLens>(); final collection = context.read<CollectionLens>();
final entries = selection.isSelecting ? _getExpandedSelectedItems(selection) : collection.sortedEntries.toSet(); final entries = _getTargetItems(context);
Navigator.push( Navigator.push(
context, context,

View file

@ -66,7 +66,7 @@ class InteractiveTile extends StatelessWidget {
// hero tag should include a collection identifier, so that it animates // 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) // 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) // 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( final viewerCollection = collection.copyWith(
listenToSource: false, listenToSource: false,
); );
assert(viewerCollection.sortedEntries.map((e) => e.contentId).contains(entry.contentId)); assert(viewerCollection.sortedEntries.map((entry) => entry.id).contains(entry.id));
return EntryViewerPage( return EntryViewerPage(
collection: viewerCollection, collection: viewerCollection,
initialEntry: entry, 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/actions/move_type.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/album.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/highlight.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.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/common/services.dart';
import 'package:aves/services/media/enums.dart'; import 'package:aves/services/media/enums.dart';
import 'package:aves/theme/durations.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/collection/collection_page.dart';
import 'package:aves/widgets/common/action_mixins/feedback.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/permission_aware.dart';
import 'package:aves/widgets/common/action_mixins/size_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/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/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/filter_grids/album_pick.dart'; import 'package:aves/widgets/filter_grids/album_pick.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
@ -27,9 +30,38 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
Future<void> move( Future<void> move(
BuildContext context, { BuildContext context, {
required MoveType moveType, required MoveType moveType,
required Set<AvesEntry> selectedItems, required Set<AvesEntry> entries,
VoidCallback? onSuccess, VoidCallback? onSuccess,
}) async { }) 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>(); final source = context.read<CollectionSource>();
if (!source.initialized) { if (!source.initialized) {
// source may be uninitialized in viewer mode // source may be uninitialized in viewer mode
@ -37,48 +69,64 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
unawaited(source.refresh()); unawaited(source.refresh());
} }
final l10n = context.l10n; final entriesByDestination = <String, Set<AvesEntry>>{};
final selectionDirs = selectedItems.map((e) => e.directory).whereNotNull().toSet(); switch (moveType) {
case MoveType.copy:
case MoveType.move:
case MoveType.export:
final destinationAlbum = await pickAlbum(context: context, moveType: moveType);
if (destinationAlbum == null) return;
entriesByDestination[destinationAlbum] = entries;
break;
case MoveType.toBin:
entriesByDestination[AndroidFileUtils.trashDirPath] = entries;
break;
case MoveType.fromBin:
groupBy<AvesEntry, String?>(entries, (e) => e.directory).forEach((originAlbum, dirEntries) {
if (originAlbum != null) {
entriesByDestination[originAlbum] = dirEntries.toSet();
}
});
break;
}
final destinationAlbum = await pickAlbum(context: context, moveType: moveType); // permission for modification at destinations
if (destinationAlbum == null) return; final destinationAlbums = entriesByDestination.keys.toSet();
if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return; if (!await checkStoragePermissionForAlbums(context, destinationAlbums)) return;
if (moveType == MoveType.move && !await checkStoragePermissionForAlbums(context, selectionDirs, entries: selectedItems)) return; // permission for modification at origins
final originAlbums = entries.map((e) => e.directory).whereNotNull().toSet();
if ({MoveType.move, MoveType.toBin}.contains(moveType) && !await checkStoragePermissionForAlbums(context, originAlbums, entries: entries)) return;
if (!await checkFreeSpaceForMove(context, selectedItems, destinationAlbum, moveType)) return; await Future.forEach<String>(destinationAlbums, (destinationAlbum) async {
if (!await checkFreeSpaceForMove(context, entries, destinationAlbum, moveType)) return;
});
// do not directly use selection when moving and post-processing items
// as source monitoring may remove obsolete items from the original selection
final todoItems = selectedItems.toSet();
final copy = moveType == MoveType.copy;
final todoCount = todoItems.length;
assert(todoCount > 0);
final destinationDirectory = Directory(destinationAlbum);
final names = [
...todoItems.map((v) => '${v.filenameWithoutExtension}${v.extension}'),
// do not guard up front based on directory existence,
// as conflicts could be within moved entries scattered across multiple albums
if (await destinationDirectory.exists()) ...destinationDirectory.listSync().map((v) => pContext.basename(v.path)),
];
final uniqueNames = names.toSet();
var nameConflictStrategy = NameConflictStrategy.rename; var nameConflictStrategy = NameConflictStrategy.rename;
if (uniqueNames.length < names.length) { if (!toBin && destinationAlbums.length == 1) {
final value = await showDialog<NameConflictStrategy>( final destinationDirectory = Directory(destinationAlbums.single);
context: context, final names = [
builder: (context) { ...entries.map((v) => '${v.filenameWithoutExtension}${v.extension}'),
return AvesSelectionDialog<NameConflictStrategy>( // do not guard up front based on directory existence,
initialValue: nameConflictStrategy, // as conflicts could be within moved entries scattered across multiple albums
options: Map.fromEntries(NameConflictStrategy.values.map((v) => MapEntry(v, v.getName(context)))), if (await destinationDirectory.exists()) ...destinationDirectory.listSync().map((v) => pContext.basename(v.path)),
message: selectionDirs.length == 1 ? l10n.nameConflictDialogSingleSourceMessage : l10n.nameConflictDialogMultipleSourceMessage, ];
confirmationButtonLabel: l10n.continueButtonLabel, final uniqueNames = names.toSet();
); if (uniqueNames.length < names.length) {
}, final value = await showDialog<NameConflictStrategy>(
); context: context,
if (value == null) return; builder: (context) {
nameConflictStrategy = value; return AvesSelectionDialog<NameConflictStrategy>(
initialValue: nameConflictStrategy,
options: Map.fromEntries(NameConflictStrategy.values.map((v) => MapEntry(v, v.getName(context)))),
message: originAlbums.length == 1 ? l10n.nameConflictDialogSingleSourceMessage : l10n.nameConflictDialogMultipleSourceMessage,
confirmationButtonLabel: l10n.continueButtonLabel,
);
},
);
if (value == null) return;
nameConflictStrategy = value;
}
} }
source.pauseMonitoring(); source.pauseMonitoring();
@ -87,9 +135,8 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
context: context, context: context,
opStream: mediaFileService.move( opStream: mediaFileService.move(
opId: opId, opId: opId,
entries: todoItems, entriesByDestination: entriesByDestination,
copy: copy, copy: copy,
destinationAlbum: destinationAlbum,
nameConflictStrategy: nameConflictStrategy, nameConflictStrategy: nameConflictStrategy,
), ),
itemCount: todoCount, itemCount: todoCount,
@ -98,16 +145,16 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
final successOps = processed.where((e) => e.success).toSet(); final successOps = processed.where((e) => e.success).toSet();
final movedOps = successOps.where((e) => !e.skipped).toSet(); final movedOps = successOps.where((e) => !e.skipped).toSet();
await source.updateAfterMove( await source.updateAfterMove(
todoEntries: todoItems, todoEntries: entries,
copy: copy, moveType: moveType,
destinationAlbum: destinationAlbum, destinationAlbums: destinationAlbums,
movedOps: movedOps, movedOps: movedOps,
); );
source.resumeMonitoring(); source.resumeMonitoring();
// cleanup // cleanup
if (moveType == MoveType.move) { if ({MoveType.move, MoveType.toBin}.contains(moveType)) {
await storageService.deleteEmptyDirectories(selectionDirs); await storageService.deleteEmptyDirectories(originAlbums);
} }
final successCount = successOps.length; final successCount = successOps.length;
@ -119,7 +166,7 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
final appMode = context.read<ValueNotifier<AppMode>>().value; final appMode = context.read<ValueNotifier<AppMode>>().value;
SnackBarAction? action; SnackBarAction? action;
if (count > 0 && appMode == AppMode.main) { if (count > 0 && appMode == AppMode.main && !toBin) {
action = SnackBarAction( action = SnackBarAction(
label: l10n.showButtonLabel, label: l10n.showButtonLabel,
onPressed: () async { onPressed: () async {
@ -130,14 +177,18 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
if (collection != null) { if (collection != null) {
targetCollection = collection; targetCollection = collection;
} }
if (collection == null || collection.filters.any((f) => f is AlbumFilter)) { if (collection == null || collection.filters.any((f) => f is AlbumFilter || f is TrashFilter)) {
final filter = AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum));
// we could simply add the filter to the current collection
// but navigating makes the change less jarring
targetCollection = CollectionLens( targetCollection = CollectionLens(
source: source, source: source,
filters: collection?.filters, filters: collection?.filters.where((f) => f != TrashFilter.instance).toSet(),
)..addFilter(filter); );
// 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( unawaited(Navigator.pushAndRemoveUntil(
context, context,
MaterialPageRoute( MaterialPageRoute(

View file

@ -19,6 +19,8 @@ mixin SizeAwareMixin {
String destinationAlbum, String destinationAlbum,
MoveType moveType, MoveType moveType,
) async { ) async {
if (moveType == MoveType.toBin) return true;
// assume we have enough space if we cannot find the volume or its remaining free space // assume we have enough space if we cannot find the volume or its remaining free space
final destinationVolume = androidFileUtils.getStorageVolume(destinationAlbum); final destinationVolume = androidFileUtils.getStorageVolume(destinationAlbum);
if (destinationVolume == null) return true; if (destinationVolume == null) return true;
@ -34,6 +36,8 @@ mixin SizeAwareMixin {
needed = selection.fold(0, sumSize); needed = selection.fold(0, sumSize);
break; break;
case MoveType.move: 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 // 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 byVolume = groupBy<AvesEntry, StorageVolume?>(selection, (entry) => androidFileUtils.getStorageVolume(entry.path)).whereNotNullKey();
final otherVolumes = byVolume.keys.where((volume) => volume != destinationVolume); final otherVolumes = byVolume.keys.where((volume) => volume != destinationVolume);

View file

@ -6,13 +6,14 @@ import 'package:provider/provider.dart';
class GridTheme extends StatelessWidget { class GridTheme extends StatelessWidget {
final double extent; final double extent;
final bool? showLocation; final bool? showLocation, showTrash;
final Widget child; final Widget child;
const GridTheme({ const GridTheme({
Key? key, Key? key,
required this.extent, required this.extent,
this.showLocation, this.showLocation,
this.showTrash,
required this.child, required this.child,
}) : super(key: key); }) : super(key: key);
@ -33,6 +34,7 @@ class GridTheme extends StatelessWidget {
showMotionPhoto: settings.showThumbnailMotionPhoto, showMotionPhoto: settings.showThumbnailMotionPhoto,
showRating: settings.showThumbnailRating, showRating: settings.showThumbnailRating,
showRaw: settings.showThumbnailRaw, showRaw: settings.showThumbnailRaw,
showTrash: showTrash ?? true,
showVideoDuration: settings.showThumbnailVideoDuration, showVideoDuration: settings.showThumbnailVideoDuration,
); );
}, },
@ -43,7 +45,7 @@ class GridTheme extends StatelessWidget {
class GridThemeData { class GridThemeData {
final double iconSize, fontSize, highlightBorderWidth; final double iconSize, fontSize, highlightBorderWidth;
final bool showFavourite, showLocation, showMotionPhoto, showRating, showRaw, showVideoDuration; final bool showFavourite, showLocation, showMotionPhoto, showRating, showRaw, showTrash, showVideoDuration;
const GridThemeData({ const GridThemeData({
required this.iconSize, required this.iconSize,
@ -54,6 +56,7 @@ class GridThemeData {
required this.showMotionPhoto, required this.showMotionPhoto,
required this.showRating, required this.showRating,
required this.showRaw, required this.showRaw,
required this.showTrash,
required this.showVideoDuration, 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/model/entry.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/utils/android_file_utils.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:aves/widgets/common/grid/theme.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.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 { class OverlayIcon extends StatelessWidget {
final IconData icon; final IconData icon;
final String? text; final String? text;

View file

@ -7,6 +7,7 @@ class EmptyContent extends StatelessWidget {
final String text; final String text;
final AlignmentGeometry alignment; final AlignmentGeometry alignment;
final double fontSize; final double fontSize;
final bool safeBottom;
const EmptyContent({ const EmptyContent({
Key? key, Key? key,
@ -14,15 +15,18 @@ class EmptyContent extends StatelessWidget {
required this.text, required this.text,
this.alignment = const FractionalOffset(.5, .35), this.alignment = const FractionalOffset(.5, .35),
this.fontSize = 22, this.fontSize = 22,
this.safeBottom = true,
}) : super(key: key); }) : super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
const color = Colors.blueGrey; const color = Colors.blueGrey;
return Padding( return Padding(
padding: EdgeInsets.only( padding: safeBottom
bottom: context.select<MediaQueryData, double>((mq) => mq.effectiveBottomPadding), ? EdgeInsets.only(
), bottom: context.select<MediaQueryData, double>((mq) => mq.effectiveBottomPadding),
)
: EdgeInsets.zero,
child: Align( child: Align(
alignment: alignment, alignment: alignment,
child: Column( child: Column(

View file

@ -27,7 +27,6 @@ class DecoratedThumbnail extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isSvg = entry.isSvg;
Widget child = ThumbnailImage( Widget child = ThumbnailImage(
entry: entry, entry: entry,
extent: tileExtent, extent: tileExtent,
@ -36,10 +35,10 @@ class DecoratedThumbnail extends StatelessWidget {
); );
child = Stack( child = Stack(
alignment: isSvg ? Alignment.center : AlignmentDirectional.bottomStart, alignment: AlignmentDirectional.bottomStart,
children: [ children: [
child, child,
if (!isSvg) ThumbnailEntryOverlay(entry: entry), ThumbnailEntryOverlay(entry: entry),
if (selectable) if (selectable)
GridItemSelectionOverlay<AvesEntry>( GridItemSelectionOverlay<AvesEntry>(
item: entry, item: entry,

View file

@ -4,7 +4,6 @@ import 'dart:math';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/utils/mime_utils.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/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -58,14 +57,10 @@ class _ErrorThumbnailState extends State<ErrorThumbnail> {
textAlign: TextAlign.center, textAlign: TextAlign.center,
); );
}) })
: Tooltip( : Icon(
message: context.l10n.viewerErrorDoesNotExist, AIcons.broken,
preferBelow: false, size: extent / 2,
child: Icon( color: color,
AIcons.broken,
size: extent / 2,
color: color,
),
); );
} }
return Container( 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 && context.select<GridThemeData, bool>((t) => t.showMotionPhoto)) const MotionPhotoIcon(),
if (!entry.isMotionPhoto) MultiPageIcon(entry: entry), 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.isEmpty) return const SizedBox();
if (children.length == 1) return children.first; if (children.length == 1) return children.first;

View file

@ -86,6 +86,7 @@ class _ThumbnailScrollerState extends State<ThumbnailScroller> {
return GridTheme( return GridTheme(
extent: extent, extent: extent,
showLocation: false, showLocation: false,
showTrash: false,
child: SizedBox( child: SizedBox(
height: extent, height: extent,
child: ListView.separated( 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/favourites.dart';
import 'package:aves/model/metadata/address.dart'; import 'package:aves/model/metadata/address.dart';
import 'package:aves/model/metadata/catalog.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/model/video_playback.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/utils/file_utils.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<Set<AvesEntry>> _dbEntryLoader;
late Future<Map<int?, int?>> _dbDateLoader; late Future<Map<int?, int?>> _dbDateLoader;
late Future<List<CatalogMetadata>> _dbMetadataLoader; 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<FavouriteRow>> _dbFavouritesLoader;
late Future<Set<CoverRow>> _dbCoversLoader; late Future<Set<CoverRow>> _dbCoversLoader;
late Future<Set<VideoPlaybackRow>> _dbVideoPlaybackLoader; late Future<Set<VideoPlaybackRow>> _dbVideoPlaybackLoader;
@ -127,7 +129,7 @@ class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with
); );
}, },
), ),
FutureBuilder<List>( FutureBuilder<Set>(
future: _dbAddressLoader, future: _dbAddressLoader,
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString()); 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>( FutureBuilder<Set>(
future: _dbFavouritesLoader, future: _dbFavouritesLoader,
builder: (context, snapshot) { builder: (context, snapshot) {
@ -224,6 +247,7 @@ class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with
_dbDateLoader = metadataDb.loadDates(); _dbDateLoader = metadataDb.loadDates();
_dbMetadataLoader = metadataDb.loadAllMetadataEntries(); _dbMetadataLoader = metadataDb.loadAllMetadataEntries();
_dbAddressLoader = metadataDb.loadAllAddresses(); _dbAddressLoader = metadataDb.loadAllAddresses();
_dbTrashLoader = metadataDb.loadAllTrashDetails();
_dbFavouritesLoader = metadataDb.loadAllFavourites(); _dbFavouritesLoader = metadataDb.loadAllFavourites();
_dbCoversLoader = metadataDb.loadAllCovers(); _dbCoversLoader = metadataDb.loadAllCovers();
_dbVideoPlaybackLoader = metadataDb.loadAllVideoPlayback(); _dbVideoPlaybackLoader = metadataDb.loadAllVideoPlayback();

View file

@ -38,7 +38,7 @@ class _AddShortcutDialogState extends State<AddShortcutDialog> {
if (_collection != null) { if (_collection != null) {
final entries = _collection.sortedEntries; final entries = _collection.sortedEntries;
if (entries.isNotEmpty) { 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; _coverEntry = coverEntries.firstOrNull ?? entries.first;
} }
} }

View file

@ -1,7 +1,10 @@
import 'dart:ui'; 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/settings/settings.dart';
import 'package:aves/model/source/album.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/collection_source.dart';
import 'package:aves/model/source/location.dart'; import 'package:aves/model/source/location.dart';
import 'package:aves/model/source/tag.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/countries_page.dart';
import 'package:aves/widgets/filter_grids/tags_page.dart'; import 'package:aves/widgets/filter_grids/tags_page.dart';
import 'package:aves/widgets/settings/settings_page.dart'; import 'package:aves/widgets/settings/settings_page.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class AppDrawer extends StatelessWidget { 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) { static List<String> getDefaultAlbums(BuildContext context) {
final source = context.read<CollectionSource>(); final source = context.read<CollectionSource>();
@ -44,6 +54,10 @@ class AppDrawer extends StatelessWidget {
..._buildTypeLinks(), ..._buildTypeLinks(),
_buildAlbumLinks(context), _buildAlbumLinks(context),
..._buildPageLinks(context), ..._buildPageLinks(context),
if (settings.enableBin) ...[
const Divider(),
binTile,
],
if (!kReleaseMode) ...[ if (!kReleaseMode) ...[
const Divider(), const Divider(),
debugTile, debugTile,
@ -153,6 +167,7 @@ class AppDrawer extends StatelessWidget {
List<Widget> _buildTypeLinks() { List<Widget> _buildTypeLinks() {
final hiddenFilters = settings.hiddenFilters; final hiddenFilters = settings.hiddenFilters;
final typeBookmarks = settings.drawerTypeBookmarks; final typeBookmarks = settings.drawerTypeBookmarks;
final currentFilters = currentCollection?.filters;
return typeBookmarks return typeBookmarks
.where((filter) => !hiddenFilters.contains(filter)) .where((filter) => !hiddenFilters.contains(filter))
.map((filter) => CollectionNavTile( .map((filter) => CollectionNavTile(
@ -161,12 +176,17 @@ class AppDrawer extends StatelessWidget {
leading: DrawerFilterIcon(filter: filter), leading: DrawerFilterIcon(filter: filter),
title: DrawerFilterTitle(filter: filter), title: DrawerFilterTitle(filter: filter),
filter: filter, filter: filter,
isSelected: () {
if (currentFilters == null || currentFilters.length > 1) return false;
return currentFilters.firstOrNull == filter;
},
)) ))
.toList(); .toList();
} }
Widget _buildAlbumLinks(BuildContext context) { Widget _buildAlbumLinks(BuildContext context) {
final source = context.read<CollectionSource>(); final source = context.read<CollectionSource>();
final currentFilters = currentCollection?.filters;
return StreamBuilder( return StreamBuilder(
stream: source.eventBus.on<AlbumsChangedEvent>(), stream: source.eventBus.on<AlbumsChangedEvent>(),
builder: (context, snapshot) { builder: (context, snapshot) {
@ -175,7 +195,14 @@ class AppDrawer extends StatelessWidget {
return Column( return Column(
children: [ children: [
const Divider(), 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( Widget get debugTile => PageNavTile(
// key is expected by test driver // key is expected by test driver
key: const Key('drawer-debug'), 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/theme/icons.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/collection/collection_page.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:aves/widgets/drawer/tile.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -15,6 +16,7 @@ class CollectionNavTile extends StatelessWidget {
final Widget? trailing; final Widget? trailing;
final bool dense; final bool dense;
final CollectionFilter? filter; final CollectionFilter? filter;
final bool Function() isSelected;
const CollectionNavTile({ const CollectionNavTile({
Key? key, Key? key,
@ -23,6 +25,7 @@ class CollectionNavTile extends StatelessWidget {
this.trailing, this.trailing,
bool? dense, bool? dense,
required this.filter, required this.filter,
required this.isSelected,
}) : dense = dense ?? false, }) : dense = dense ?? false,
super(key: key); super(key: key);
@ -37,6 +40,7 @@ class CollectionNavTile extends StatelessWidget {
trailing: trailing, trailing: trailing,
dense: dense, dense: dense,
onTap: () => _goToCollection(context), onTap: () => _goToCollection(context),
selected: context.currentRouteName == CollectionPage.routeName && isSelected(),
), ),
); );
} }
@ -61,10 +65,12 @@ class CollectionNavTile extends StatelessWidget {
class AlbumNavTile extends StatelessWidget { class AlbumNavTile extends StatelessWidget {
final String album; final String album;
final bool Function() isSelected;
const AlbumNavTile({ const AlbumNavTile({
Key? key, Key? key,
required this.album, required this.album,
required this.isSelected,
}) : super(key: key); }) : super(key: key);
@override @override
@ -82,6 +88,7 @@ class AlbumNavTile extends StatelessWidget {
) )
: null, : null,
filter: filter, filter: filter,
isSelected: isSelected,
); );
} }
} }

View file

@ -40,20 +40,18 @@ class PageNavTile extends StatelessWidget {
onTap: _pageBuilder != null onTap: _pageBuilder != null
? () { ? () {
Navigator.pop(context); Navigator.pop(context);
if (routeName != context.currentRouteName) { final route = MaterialPageRoute(
final route = MaterialPageRoute( settings: RouteSettings(name: routeName),
settings: RouteSettings(name: routeName), builder: _pageBuilder,
builder: _pageBuilder, );
if (topLevel) {
Navigator.pushAndRemoveUntil(
context,
route,
(route) => false,
); );
if (topLevel) { } else {
Navigator.pushAndRemoveUntil( Navigator.push(context, route);
context,
route,
(route) => false,
);
} else {
Navigator.push(context, route);
}
} }
} }
: null, : null,

View file

@ -131,11 +131,13 @@ class _AlbumPickAppBar extends StatelessWidget {
switch (moveType) { switch (moveType) {
case MoveType.copy: case MoveType.copy:
return context.l10n.albumPickPageTitleCopy; return context.l10n.albumPickPageTitleCopy;
case MoveType.export:
return context.l10n.albumPickPageTitleExport;
case MoveType.move: case MoveType.move:
return context.l10n.albumPickPageTitleMove; return context.l10n.albumPickPageTitleMove;
default: case MoveType.export:
return context.l10n.albumPickPageTitleExport;
case MoveType.toBin:
case MoveType.fromBin:
case null:
return context.l10n.albumPickPageTitlePick; 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/services/media/enums.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/utils/android_file_utils.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/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/dialogs/filter_editors/create_album_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:provider/provider.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';
class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> { class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> with EntryStorageMixin {
final Iterable<FilterGridItem<AlbumFilter>> _items; final Iterable<FilterGridItem<AlbumFilter>> _items;
AlbumChipSetActionDelegate(Iterable<FilterGridItem<AlbumFilter>> items) : _items = 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 { Future<void> _delete(BuildContext context, Set<AlbumFilter> filters) async {
final l10n = context.l10n;
final messenger = ScaffoldMessenger.of(context);
final source = context.read<CollectionSource>(); final source = context.read<CollectionSource>();
final todoEntries = source.visibleEntries.where((entry) => filters.any((f) => f.test(entry))).toSet(); 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 todoAlbums = filters.map((v) => v.album).toSet();
final filledAlbums = todoEntries.map((e) => e.directory).whereNotNull().toSet(); final filledAlbums = todoEntries.map((e) => e.directory).whereNotNull().toSet();
final emptyAlbums = todoAlbums.whereNot(filledAlbums.contains).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>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (context) { builder: (context) {
@ -226,7 +242,7 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> {
final successOps = processed.where((event) => event.success); final successOps = processed.where((event) => event.success);
final deletedOps = successOps.where((e) => !e.skipped).toSet(); final deletedOps = successOps.where((e) => !e.skipped).toSet();
final deletedUris = deletedOps.map((event) => event.uri).toSet(); final deletedUris = deletedOps.map((event) => event.uri).toSet();
await source.removeEntries(deletedUris); await source.removeEntries(deletedUris, includeTrash: true);
_browse(context); _browse(context);
source.resumeMonitoring(); source.resumeMonitoring();
@ -285,9 +301,8 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> {
context: context, context: context,
opStream: mediaFileService.move( opStream: mediaFileService.move(
opId: opId, opId: opId,
entries: todoEntries, entriesByDestination: {destinationAlbum: todoEntries},
copy: false, copy: false,
destinationAlbum: destinationAlbum,
// there should be no file conflict, as the target directory itself does not exist // there should be no file conflict, as the target directory itself does not exist
nameConflictStrategy: NameConflictStrategy.rename, nameConflictStrategy: NameConflictStrategy.rename,
), ),

View file

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

View file

@ -267,7 +267,7 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin
entryBuilder: (index) => index < regionEntries.length ? regionEntries[index] : null, entryBuilder: (index) => index < regionEntries.length ? regionEntries[index] : null,
indexNotifier: _selectedIndexNotifier, indexNotifier: _selectedIndexNotifier,
onTap: _onThumbnailTap, onTap: _onThumbnailTap,
heroTagger: (entry) => Object.hashAll([regionCollection?.id, entry.uri]), heroTagger: (entry) => Object.hashAll([regionCollection?.id, entry.id]),
highlightable: true, highlightable: true,
); );
}, },

View file

@ -63,6 +63,20 @@ class PrivacySection extends StatelessWidget {
title: Text(context.l10n.settingsSaveSearchHistory), 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(), const HiddenItemsTile(),
if (device.canGrantDirectoryAccess) const StorageAccessTile(), 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/entry_metadata_edition.dart';
import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/album.dart';
import 'package:aves/model/highlight.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_lens.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/common/image_op_events.dart'; import 'package:aves/services/common/image_op_events.dart';
@ -56,6 +57,9 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
case EntryAction.delete: case EntryAction.delete:
_delete(context); _delete(context);
break; break;
case EntryAction.restore:
_move(context, moveType: MoveType.fromBin);
break;
case EntryAction.convert: case EntryAction.convert:
_convert(context); _convert(context);
break; break;
@ -163,11 +167,17 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
} }
Future<void> _delete(BuildContext context) async { 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>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (context) { builder: (context) {
return AvesDialog( return AvesDialog(
content: Text(context.l10n.deleteEntriesConfirmationDialogMessage(1)), content: Text(l10n.deleteEntriesConfirmationDialogMessage(1)),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
@ -175,7 +185,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
), ),
TextButton( TextButton(
onPressed: () => Navigator.pop(context, true), 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 checkStoragePermission(context, {entry})) return;
if (!await entry.delete()) { if (!await entry.delete()) {
showFeedback(context, context.l10n.genericFailureFeedback); showFeedback(context, l10n.genericFailureFeedback);
} else { } else {
final source = context.read<CollectionSource>(); final source = context.read<CollectionSource>();
if (source.initialized) { if (source.initialized) {
await source.removeEntries({entry.uri}); await source.removeEntries({entry.uri}, includeTrash: true);
} }
EntryRemovedNotification(entry).dispatch(context); EntryRemovedNotification(entry).dispatch(context);
} }
@ -300,8 +310,14 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
await move( await move(
context, context,
moveType: moveType, moveType: moveType,
selectedItems: {entry}, entries: {entry},
onSuccess: moveType == MoveType.move ? () => EntryRemovedNotification(entry).dispatch(context) : null, 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/feedback.dart';
import 'package:aves/widgets/common/action_mixins/permission_aware.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/action/single_entry_editor.dart';
import 'package:aves/widgets/viewer/debug/debug_page.dart';
import 'package:aves/widgets/viewer/embedded/notifications.dart'; import 'package:aves/widgets/viewer/embedded/notifications.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEditorMixin, SingleEntryEditorMixin { class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEditorMixin, SingleEntryEditorMixin {
@ -35,6 +37,9 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
// motion photo // motion photo
case EntryInfoAction.viewMotionPhotoVideo: case EntryInfoAction.viewMotionPhotoVideo:
return entry.isMotionPhoto; return entry.isMotionPhoto;
// debug
case EntryInfoAction.debug:
return kDebugMode;
} }
} }
@ -54,6 +59,9 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
// motion photo // motion photo
case EntryInfoAction.viewMotionPhotoVideo: case EntryInfoAction.viewMotionPhotoVideo:
return true; return true;
// debug
case EntryInfoAction.debug:
return true;
} }
} }
@ -80,6 +88,10 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
case EntryInfoAction.viewMotionPhotoVideo: case EntryInfoAction.viewMotionPhotoVideo:
OpenEmbeddedDataNotification.motionPhotoVideo().dispatch(context); OpenEmbeddedDataNotification.motionPhotoVideo().dispatch(context);
break; break;
// debug
case EntryInfoAction.debug:
_goToDebug(context);
break;
} }
_eventStreamController.add(ActionEndedEvent(action)); _eventStreamController.add(ActionEndedEvent(action));
} }
@ -122,4 +134,14 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
await edit(context, () => entry.removeMetadata(types)); 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/entry.dart';
import 'package:aves/model/metadata/address.dart'; import 'package:aves/model/metadata/address.dart';
import 'package:aves/model/metadata/catalog.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/model/video_playback.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/widgets/viewer/info/common.dart'; import 'package:aves/widgets/viewer/info/common.dart';
@ -24,6 +25,7 @@ class _DbTabState extends State<DbTab> {
late Future<AvesEntry?> _dbEntryLoader; late Future<AvesEntry?> _dbEntryLoader;
late Future<CatalogMetadata?> _dbMetadataLoader; late Future<CatalogMetadata?> _dbMetadataLoader;
late Future<AddressDetails?> _dbAddressLoader; late Future<AddressDetails?> _dbAddressLoader;
late Future<TrashDetails?> _dbTrashDetailsLoader;
late Future<VideoPlaybackRow?> _dbVideoPlaybackLoader; late Future<VideoPlaybackRow?> _dbVideoPlaybackLoader;
AvesEntry get entry => widget.entry; AvesEntry get entry => widget.entry;
@ -35,12 +37,13 @@ class _DbTabState extends State<DbTab> {
} }
void _loadDatabase() { void _loadDatabase() {
final contentId = entry.contentId; final id = entry.id;
_dbDateLoader = metadataDb.loadDates().then((values) => values[contentId]); _dbDateLoader = metadataDb.loadDates().then((values) => values[id]);
_dbEntryLoader = metadataDb.loadAllEntries().then((values) => values.firstWhereOrNull((row) => row.contentId == contentId)); _dbEntryLoader = metadataDb.loadAllEntries().then((values) => values.firstWhereOrNull((row) => row.id == id));
_dbMetadataLoader = metadataDb.loadAllMetadataEntries().then((values) => values.firstWhereOrNull((row) => row.contentId == contentId)); _dbMetadataLoader = metadataDb.loadAllMetadataEntries().then((values) => values.firstWhereOrNull((row) => row.id == id));
_dbAddressLoader = metadataDb.loadAllAddresses().then((values) => values.firstWhereOrNull((row) => row.contentId == contentId)); _dbAddressLoader = metadataDb.loadAllAddresses().then((values) => values.firstWhereOrNull((row) => row.id == id));
_dbVideoPlaybackLoader = metadataDb.loadVideoPlayback(contentId); _dbTrashDetailsLoader = metadataDb.loadAllTrashDetails().then((values) => values.firstWhereOrNull((row) => row.id == id));
_dbVideoPlaybackLoader = metadataDb.loadVideoPlayback(id);
setState(() {}); setState(() {});
} }
@ -94,6 +97,7 @@ class _DbTabState extends State<DbTab> {
'dateModifiedSecs': '${data.dateModifiedSecs}', 'dateModifiedSecs': '${data.dateModifiedSecs}',
'sourceDateTakenMillis': '${data.sourceDateTakenMillis}', 'sourceDateTakenMillis': '${data.sourceDateTakenMillis}',
'durationMillis': '${data.durationMillis}', 'durationMillis': '${data.durationMillis}',
'trashed': '${data.trashed}',
}, },
), ),
], ],
@ -155,6 +159,28 @@ class _DbTabState extends State<DbTab> {
}, },
), ),
const SizedBox(height: 16), 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?>( FutureBuilder<VideoPlaybackRow?>(
future: _dbVideoPlaybackLoader, future: _dbVideoPlaybackLoader,
builder: (context, snapshot) { builder: (context, snapshot) {

View file

@ -68,8 +68,9 @@ class ViewerDebugPage extends StatelessWidget {
InfoRowGroup( InfoRowGroup(
info: { info: {
'hash': '#${shortHash(entry)}', 'hash': '#${shortHash(entry)}',
'uri': entry.uri, 'id': '${entry.id}',
'contentId': '${entry.contentId}', 'contentId': '${entry.contentId}',
'uri': entry.uri,
'path': entry.path ?? '', 'path': entry.path ?? '',
'directory': entry.directory ?? '', 'directory': entry.directory ?? '',
'filenameWithoutExtension': entry.filenameWithoutExtension ?? '', 'filenameWithoutExtension': entry.filenameWithoutExtension ?? '',
@ -77,6 +78,7 @@ class ViewerDebugPage extends StatelessWidget {
'sourceTitle': entry.sourceTitle ?? '', 'sourceTitle': entry.sourceTitle ?? '',
'sourceMimeType': entry.sourceMimeType, 'sourceMimeType': entry.sourceMimeType,
'mimeType': entry.mimeType, 'mimeType': entry.mimeType,
'trashed': '${entry.trashed}',
'isMissingAtPath': '${entry.isMissingAtPath}', '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, // so it is, strictly speaking, not contained in the lens used by the viewer,
// but it can be found by content ID // but it can be found by content ID
final initialEntry = widget.initialEntry; 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 // opening hero, with viewer as target
_heroInfoNotifier.value = HeroInfo(collection?.id, entry); _heroInfoNotifier.value = HeroInfo(collection?.id, entry);
_entryNotifier.value = entry; _entryNotifier.value = entry;

View file

@ -70,11 +70,11 @@ class BasicSection extends StatelessWidget {
if (entry.isVideo) ..._buildVideoRows(context), if (entry.isVideo) ..._buildVideoRows(context),
if (showResolution) l10n.viewerInfoLabelResolution: rasterResolutionText, if (showResolution) l10n.viewerInfoLabelResolution: rasterResolutionText,
l10n.viewerInfoLabelSize: sizeText, l10n.viewerInfoLabelSize: sizeText,
l10n.viewerInfoLabelUri: entry.uri, if (!entry.trashed) l10n.viewerInfoLabelUri: entry.uri,
if (path != null) l10n.viewerInfoLabelPath: path, if (path != null) l10n.viewerInfoLabelPath: path,
}, },
), ),
OwnerProp(entry: entry), if (!entry.trashed) OwnerProp(entry: entry),
_buildChips(context), _buildChips(context),
_buildEditButtons(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/action/entry_info_action_delegate.dart';
import 'package:aves/widgets/viewer/info/info_search.dart'; import 'package:aves/widgets/viewer/info/info_search.dart';
import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart'; import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
@ -53,7 +54,13 @@ class InfoAppBar extends StatelessWidget {
if (entry.canEdit) if (entry.canEdit)
MenuIconTheme( MenuIconTheme(
child: PopupMenuButton<EntryInfoAction>( 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 { onSelected: (action) async {
// wait for the popup menu to hide before proceeding with the action // wait for the popup menu to hide before proceeding with the action
await Future.delayed(Durations.popupMenuAnimation * timeDilation); await Future.delayed(Durations.popupMenuAnimation * timeDilation);

View file

@ -384,7 +384,7 @@ class _PositionTitleRow extends StatelessWidget {
// but fail to get information about these pages // but fail to get information about these pages
final pageCount = multiPageInfo.pageCount; final pageCount = multiPageInfo.pageCount;
if (pageCount > 0) { 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'; 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; final status = controller?.status ?? VideoStatus.idle;
Widget child; Widget child;
if (status == VideoStatus.error) { if (status == VideoStatus.error) {
const action = VideoAction.playOutside;
child = Align( child = Align(
alignment: AlignmentDirectional.centerEnd, alignment: AlignmentDirectional.centerEnd,
child: OverlayButton( child: OverlayButton(
scale: scale, scale: scale,
child: IconButton( child: IconButton(
icon: VideoAction.playOutside.getIcon(), icon: action.getIcon(),
onPressed: () => widget.onActionSelected(VideoAction.playOutside), onPressed: entry.trashed ? null : () => widget.onActionSelected(action),
tooltip: VideoAction.playOutside.getText(context), tooltip: action.getText(context),
), ),
), ),
); );
@ -327,13 +328,15 @@ class _ButtonRow extends StatelessWidget {
case VideoAction.setSpeed: case VideoAction.setSpeed:
enabled = controller?.canSetSpeedNotifier.value ?? false; enabled = controller?.canSetSpeedNotifier.value ?? false;
break; break;
case VideoAction.playOutside:
case VideoAction.replay10: case VideoAction.replay10:
case VideoAction.skip10: case VideoAction.skip10:
case VideoAction.settings: case VideoAction.settings:
case VideoAction.togglePlay: case VideoAction.togglePlay:
enabled = true; enabled = true;
break; break;
case VideoAction.playOutside:
enabled = !(controller?.entry.trashed ?? true);
break;
} }
Widget? child; Widget? child;

View file

@ -65,47 +65,62 @@ class ViewerTopOverlay extends StatelessWidget {
Widget _buildOverlay(BuildContext context, int availableCount, AvesEntry mainEntry, {AvesEntry? pageEntry}) { Widget _buildOverlay(BuildContext context, int availableCount, AvesEntry mainEntry, {AvesEntry? pageEntry}) {
pageEntry ??= mainEntry; pageEntry ??= mainEntry;
final trashed = mainEntry.trashed;
bool _isVisible(EntryAction action) { bool _isVisible(EntryAction action) {
final targetEntry = EntryActions.pageActions.contains(action) ? pageEntry! : mainEntry; if (trashed) {
switch (action) { switch (action) {
case EntryAction.toggleFavourite: case EntryAction.delete:
return canToggleFavourite; case EntryAction.restore:
case EntryAction.delete: return true;
case EntryAction.rename: case EntryAction.debug:
case EntryAction.copy: return kDebugMode;
case EntryAction.move: default:
return targetEntry.canEdit; return false;
case EntryAction.rotateCCW: }
case EntryAction.rotateCW: } else {
case EntryAction.flip: final targetEntry = EntryActions.pageActions.contains(action) ? pageEntry! : mainEntry;
return targetEntry.canRotateAndFlip; switch (action) {
case EntryAction.convert: case EntryAction.toggleFavourite:
case EntryAction.print: return canToggleFavourite;
return !targetEntry.isVideo && device.canPrint; case EntryAction.delete:
case EntryAction.openMap: case EntryAction.rename:
return targetEntry.hasGps; case EntryAction.copy:
case EntryAction.viewSource: case EntryAction.move:
return targetEntry.isSvg; return targetEntry.canEdit;
case EntryAction.rotateScreen: case EntryAction.rotateCCW:
return settings.isRotationLocked; case EntryAction.rotateCW:
case EntryAction.addShortcut: case EntryAction.flip:
return device.canPinShortcut; return targetEntry.canRotateAndFlip;
case EntryAction.copyToClipboard: case EntryAction.convert:
case EntryAction.edit: case EntryAction.print:
case EntryAction.open: return !targetEntry.isVideo && device.canPrint;
case EntryAction.setAs: case EntryAction.openMap:
case EntryAction.share: return targetEntry.hasGps;
return true; case EntryAction.viewSource:
case EntryAction.debug: return targetEntry.isSvg;
return kDebugMode; case EntryAction.rotateScreen:
return settings.isRotationLocked;
case EntryAction.addShortcut:
return device.canPinShortcut;
case EntryAction.copyToClipboard:
case EntryAction.edit:
case EntryAction.open:
case EntryAction.setAs:
case EntryAction.share:
return true;
case EntryAction.restore:
return false;
case EntryAction.debug:
return kDebugMode;
}
} }
} }
final buttonRow = Selector<Settings, bool>( final buttonRow = Selector<Settings, bool>(
selector: (context, s) => s.isRotationLocked, selector: (context, s) => s.isRotationLocked,
builder: (context, s, child) { 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 topLevelActions = EntryActions.topLevel.where((action) => !quickActions.contains(action)).where(_isVisible).toList();
final exportActions = EntryActions.export.where((action) => !quickActions.contains(action)).where(_isVisible).toList(); final exportActions = EntryActions.export.where((action) => !quickActions.contains(action)).where(_isVisible).toList();
return _TopOverlayRow( return _TopOverlayRow(
@ -160,6 +175,7 @@ class _TopOverlayRow extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final hasOverflowMenu = pageEntry.canRotateAndFlip || topLevelActions.isNotEmpty || exportActions.isNotEmpty;
return Row( return Row(
children: [ children: [
OverlayButton( OverlayButton(
@ -168,48 +184,50 @@ class _TopOverlayRow extends StatelessWidget {
), ),
const Spacer(), const Spacer(),
...quickActions.map((action) => _buildOverlayButton(context, action)), ...quickActions.map((action) => _buildOverlayButton(context, action)),
OverlayButton( if (hasOverflowMenu)
scale: scale, OverlayButton(
child: MenuIconTheme( scale: scale,
child: AvesPopupMenuButton<EntryAction>( child: MenuIconTheme(
key: const Key('entry-menu-button'), child: AvesPopupMenuButton<EntryAction>(
itemBuilder: (context) { key: const Key('entry-menu-button'),
final exportInternalActions = exportActions.whereNot(EntryActions.exportExternal.contains).toList(); itemBuilder: (context) {
final exportExternalActions = exportActions.where(EntryActions.exportExternal.contains).toList(); final exportInternalActions = exportActions.whereNot(EntryActions.exportExternal.contains).toList();
return [ final exportExternalActions = exportActions.where(EntryActions.exportExternal.contains).toList();
if (pageEntry.canRotateAndFlip) _buildRotateAndFlipMenuItems(context), return [
...topLevelActions.map((action) => _buildPopupMenuItem(context, action)), if (pageEntry.canRotateAndFlip) _buildRotateAndFlipMenuItems(context),
PopupMenuItem<EntryAction>( ...topLevelActions.map((action) => _buildPopupMenuItem(context, action)),
padding: EdgeInsets.zero, if (exportActions.isNotEmpty)
child: PopupMenuItemExpansionPanel<EntryAction>( PopupMenuItem<EntryAction>(
icon: AIcons.export, padding: EdgeInsets.zero,
title: context.l10n.entryActionExport, child: PopupMenuItemExpansionPanel<EntryAction>(
items: [ icon: AIcons.export,
...exportInternalActions.map((action) => _buildPopupMenuItem(context, action)).toList(), title: context.l10n.entryActionExport,
if (exportInternalActions.isNotEmpty && exportExternalActions.isNotEmpty) const PopupMenuDivider(height: 0), items: [
...exportExternalActions.map((action) => _buildPopupMenuItem(context, action)).toList(), ...exportInternalActions.map((action) => _buildPopupMenuItem(context, action)).toList(),
], if (exportInternalActions.isNotEmpty && exportExternalActions.isNotEmpty) const PopupMenuDivider(height: 0),
), ...exportExternalActions.map((action) => _buildPopupMenuItem(context, action)).toList(),
), ],
if (!kReleaseMode) ...[ ),
const PopupMenuDivider(), ),
_buildPopupMenuItem(context, EntryAction.debug), if (!kReleaseMode) ...[
] const PopupMenuDivider(),
]; _buildPopupMenuItem(context, EntryAction.debug),
}, ]
onSelected: (action) { ];
// wait for the popup menu to hide before proceeding with the action },
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => _onActionSelected(context, action)); onSelected: (action) {
}, // wait for the popup menu to hide before proceeding with the action
onMenuOpened: () { Future.delayed(Durations.popupMenuAnimation * timeDilation, () => _onActionSelected(context, action));
// if the menu is opened while overlay is hiding, },
// the popup menu button is disposed and menu items are ineffective, onMenuOpened: () {
// so we make sure overlay stays visible // if the menu is opened while overlay is hiding,
const ToggleOverlayNotification(visible: true).dispatch(context); // the popup menu button is disposed and menu items are ineffective,
}, // so we make sure overlay stays visible
const ToggleOverlayNotification(visible: true).dispatch(context);
},
),
), ),
), ),
),
], ],
); );
} }
@ -226,7 +244,7 @@ class _TopOverlayRow extends StatelessWidget {
break; break;
default: default:
child = IconButton( child = IconButton(
icon: action.getIcon() ?? const SizedBox(), icon: action.getIcon(),
onPressed: onPressed, onPressed: onPressed,
tooltip: action.getText(context), tooltip: action.getText(context),
); );

View file

@ -27,36 +27,34 @@ abstract class AvesVideoController {
} }
Future<void> _savePlaybackState() async { Future<void> _savePlaybackState() async {
final contentId = entry.contentId; final id = entry.id;
if (contentId == null || !isReady || duration < resumeTimeSaveMinDuration.inMilliseconds) return; if (!isReady || duration < resumeTimeSaveMinDuration.inMilliseconds) return;
if (persistPlayback) { if (persistPlayback) {
final _progress = progress; final _progress = progress;
if (resumeTimeSaveMinProgress < _progress && _progress < resumeTimeSaveMaxProgress) { if (resumeTimeSaveMinProgress < _progress && _progress < resumeTimeSaveMaxProgress) {
await metadataDb.addVideoPlayback({ await metadataDb.addVideoPlayback({
VideoPlaybackRow( VideoPlaybackRow(
contentId: contentId, entryId: id,
resumeTimeMillis: currentPosition, resumeTimeMillis: currentPosition,
) )
}); });
} else { } else {
await metadataDb.removeVideoPlayback({contentId}); await metadataDb.removeVideoPlayback({id});
} }
} }
} }
Future<int?> getResumeTime(BuildContext context) async { Future<int?> getResumeTime(BuildContext context) async {
final contentId = entry.contentId;
if (contentId == null) return null;
if (!persistPlayback) 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; final resumeTime = playback?.resumeTimeMillis ?? 0;
if (resumeTime == 0) return null; if (resumeTime == 0) return null;
// clear on retrieval // clear on retrieval
await metadataDb.removeVideoPlayback({contentId}); await metadataDb.removeVideoPlayback({id});
final resume = await showDialog<bool>( final resume = await showDialog<bool>(
context: context, context: context,

View file

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

View file

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

View file

@ -350,6 +350,7 @@ class _RegionTile extends StatefulWidget {
@override @override
void debugFillProperties(DiagnosticPropertiesBuilder properties) { void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties); super.debugFillProperties(properties);
properties.add(IntProperty('id', entry.id));
properties.add(IntProperty('contentId', entry.contentId)); properties.add(IntProperty('contentId', entry.contentId));
properties.add(DiagnosticsProperty<Rect>('tileRect', tileRect)); properties.add(DiagnosticsProperty<Rect>('tileRect', tileRect));
properties.add(DiagnosticsProperty<Rectangle<int>>('regionRect', regionRect)); properties.add(DiagnosticsProperty<Rectangle<int>>('regionRect', regionRect));

View file

@ -305,6 +305,7 @@ class _RegionTile extends StatefulWidget {
@override @override
void debugFillProperties(DiagnosticPropertiesBuilder properties) { void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties); super.debugFillProperties(properties);
properties.add(IntProperty('id', entry.id));
properties.add(IntProperty('contentId', entry.contentId)); properties.add(IntProperty('contentId', entry.contentId));
properties.add(DiagnosticsProperty<Rect>('tileRect', tileRect)); properties.add(DiagnosticsProperty<Rect>('tileRect', tileRect));
properties.add(DiagnosticsProperty<Rectangle<double>>('regionRect', regionRect)); properties.add(DiagnosticsProperty<Rectangle<double>>('regionRect', regionRect));

View file

@ -883,12 +883,12 @@ packages:
source: hosted source: hosted
version: "2.0.13" version: "2.0.13"
shared_preferences_android: shared_preferences_android:
dependency: transitive dependency: "direct main"
description: description:
name: shared_preferences_android name: shared_preferences_android
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.11" version: "2.0.10"
shared_preferences_ios: shared_preferences_ios:
dependency: transitive dependency: transitive
description: description:

View file

@ -63,6 +63,8 @@ dependencies:
provider: provider:
screen_brightness: screen_brightness:
shared_preferences: 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: sqflite:
streams_channel: streams_channel:
git: git:

View file

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

View file

@ -9,27 +9,28 @@ class FakeMediaStoreService extends Fake implements MediaStoreService {
Set<AvesEntry> entries = {}; Set<AvesEntry> entries = {};
@override @override
Future<List<int>> checkObsoleteContentIds(List<int> knownContentIds) => SynchronousFuture([]); Future<List<int>> checkObsoleteContentIds(List<int?> knownContentIds) => SynchronousFuture([]);
@override @override
Future<List<int>> checkObsoletePaths(Map<int, String?> knownPathById) => SynchronousFuture([]); Future<List<int>> checkObsoletePaths(Map<int?, String?> knownPathById) => SynchronousFuture([]);
@override @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 int get dateSecs => DateTime.now().millisecondsSinceEpoch ~/ 1000;
static AvesEntry newImage(String album, String filenameWithoutExtension) { static AvesEntry newImage(String album, String filenameWithoutExtension) {
final contentId = nextContentId; final id = nextId;
final date = dateSecs; final date = dateSecs;
return AvesEntry( return AvesEntry(
uri: 'content://media/external/images/media/$contentId', id: id,
contentId: contentId, uri: 'content://media/external/images/media/$id',
path: '$album/$filenameWithoutExtension.jpg', path: '$album/$filenameWithoutExtension.jpg',
contentId: id,
pageId: null, pageId: null,
sourceMimeType: MimeTypes.jpeg, sourceMimeType: MimeTypes.jpeg,
width: 360, width: 360,
@ -40,11 +41,12 @@ class FakeMediaStoreService extends Fake implements MediaStoreService {
dateModifiedSecs: date, dateModifiedSecs: date,
sourceDateTakenMillis: date, sourceDateTakenMillis: date,
durationMillis: null, durationMillis: null,
trashed: false,
); );
} }
static MoveOpEvent moveOpEventFor(AvesEntry entry, String sourceAlbum, String destinationAlbum) { static MoveOpEvent moveOpEventFor(AvesEntry entry, String sourceAlbum, String destinationAlbum) {
final newContentId = nextContentId; final newContentId = nextId;
return MoveOpEvent( return MoveOpEvent(
success: true, success: true,
skipped: false, skipped: false,

View file

@ -1,19 +1,25 @@
import 'package:aves/model/covers.dart'; import 'package:aves/model/covers.dart';
import 'package:aves/model/db/db_metadata.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/favourites.dart'; import 'package:aves/model/favourites.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/metadata/address.dart'; import 'package:aves/model/metadata/address.dart';
import 'package:aves/model/metadata/catalog.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/foundation.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
class FakeMetadataDb extends Fake implements MetadataDb { class FakeMetadataDb extends Fake implements MetadataDb {
static int _lastId = 0;
@override
int get nextId => ++_lastId;
@override @override
Future<void> init() => SynchronousFuture(null); Future<void> init() => SynchronousFuture(null);
@override @override
Future<void> removeIds(Set<int> contentIds, {Set<EntryDataType>? dataTypes}) => SynchronousFuture(null); Future<void> removeIds(Set<int> ids, {Set<EntryDataType>? dataTypes}) => SynchronousFuture(null);
// entries // entries
@ -24,7 +30,7 @@ class FakeMetadataDb extends Fake implements MetadataDb {
Future<void> saveEntries(Iterable<AvesEntry> entries) => SynchronousFuture(null); Future<void> saveEntries(Iterable<AvesEntry> entries) => SynchronousFuture(null);
@override @override
Future<void> updateEntryId(int oldId, AvesEntry entry) => SynchronousFuture(null); Future<void> updateEntry(int id, AvesEntry entry) => SynchronousFuture(null);
// date taken // date taken
@ -40,18 +46,29 @@ class FakeMetadataDb extends Fake implements MetadataDb {
Future<void> saveMetadata(Set<CatalogMetadata> metadataEntries) => SynchronousFuture(null); Future<void> saveMetadata(Set<CatalogMetadata> metadataEntries) => SynchronousFuture(null);
@override @override
Future<void> updateMetadataId(int oldId, CatalogMetadata? metadata) => SynchronousFuture(null); Future<void> updateMetadata(int id, CatalogMetadata? metadata) => SynchronousFuture(null);
// address // address
@override @override
Future<List<AddressDetails>> loadAllAddresses() => SynchronousFuture([]); Future<Set<AddressDetails>> loadAllAddresses() => SynchronousFuture({});
@override @override
Future<void> saveAddresses(Set<AddressDetails> addresses) => SynchronousFuture(null); Future<void> saveAddresses(Set<AddressDetails> addresses) => SynchronousFuture(null);
@override @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 // favourites
@ -62,7 +79,7 @@ class FakeMetadataDb extends Fake implements MetadataDb {
Future<void> addFavourites(Iterable<FavouriteRow> rows) => SynchronousFuture(null); Future<void> addFavourites(Iterable<FavouriteRow> rows) => SynchronousFuture(null);
@override @override
Future<void> updateFavouriteId(int oldId, FavouriteRow row) => SynchronousFuture(null); Future<void> updateFavouriteId(int id, FavouriteRow row) => SynchronousFuture(null);
@override @override
Future<void> removeFavourites(Iterable<FavouriteRow> rows) => SynchronousFuture(null); 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); Future<void> addCovers(Iterable<CoverRow> rows) => SynchronousFuture(null);
@override @override
Future<void> updateCoverEntryId(int oldId, CoverRow row) => SynchronousFuture(null); Future<void> updateCoverEntryId(int id, CoverRow row) => SynchronousFuture(null);
@override @override
Future<void> removeCovers(Set<CollectionFilter> filters) => SynchronousFuture(null); Future<void> removeCovers(Set<CollectionFilter> filters) => SynchronousFuture(null);
@ -84,8 +101,5 @@ class FakeMetadataDb extends Fake implements MetadataDb {
// video playback // video playback
@override @override
Future<void> updateVideoPlaybackId(int oldId, int? newId) => SynchronousFuture(null); Future<void> removeVideoPlayback(Set<int> ids) => SynchronousFuture(null);
@override
Future<void> removeVideoPlayback(Set<int> contentIds) => SynchronousFuture(null);
} }

View file

@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:aves/model/actions/move_type.dart';
import 'package:aves/model/availability.dart'; import 'package:aves/model/availability.dart';
import 'package:aves/model/covers.dart'; import 'package:aves/model/covers.dart';
import 'package:aves/model/db/db_metadata.dart'; import 'package:aves/model/db/db_metadata.dart';
@ -45,6 +46,7 @@ void main() {
const aTag = 'sometag'; const aTag = 'sometag';
final australiaLatLng = LatLng(-26, 141); final australiaLatLng = LatLng(-26, 141);
const australiaAddress = AddressDetails( const australiaAddress = AddressDetails(
id: 0,
countryCode: 'AU', countryCode: 'AU',
countryName: 'AUS', countryName: 'AUS',
); );
@ -96,7 +98,7 @@ void main() {
(metadataFetchService as FakeMetadataFetchService).setUp( (metadataFetchService as FakeMetadataFetchService).setUp(
image1, image1,
CatalogMetadata( CatalogMetadata(
contentId: image1.contentId, id: image1.id,
xmpSubjects: aTag, xmpSubjects: aTag,
latitude: australiaLatLng.latitude, latitude: australiaLatLng.latitude,
longitude: australiaLatLng.longitude, longitude: australiaLatLng.longitude,
@ -119,7 +121,7 @@ void main() {
(metadataFetchService as FakeMetadataFetchService).setUp( (metadataFetchService as FakeMetadataFetchService).setUp(
image1, image1,
CatalogMetadata( CatalogMetadata(
contentId: image1.contentId, id: image1.id,
xmpSubjects: aTag, xmpSubjects: aTag,
latitude: australiaLatLng.latitude, latitude: australiaLatLng.latitude,
longitude: australiaLatLng.longitude, longitude: australiaLatLng.longitude,
@ -129,7 +131,7 @@ void main() {
final source = await _initSource(); final source = await _initSource();
expect(image1.tags, {aTag}); 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.visibleEntries.length, 0);
expect(source.rawAlbums.length, 0); expect(source.rawAlbums.length, 0);
@ -168,15 +170,15 @@ void main() {
const albumFilter = AlbumFilter(testAlbum, 'whatever'); const albumFilter = AlbumFilter(testAlbum, 'whatever');
expect(albumFilter.test(image1), true); expect(albumFilter.test(image1), true);
expect(covers.count, 0); 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.count, 1);
expect(covers.coverContentId(albumFilter), image1.contentId); expect(covers.coverEntryId(albumFilter), image1.id);
await covers.set(albumFilter, null); await covers.set(albumFilter, null);
expect(covers.count, 0); expect(covers.count, 0);
expect(covers.coverContentId(albumFilter), null); expect(covers.coverEntryId(albumFilter), null);
}); });
test('favourites and covers are kept when renaming entries', () async { test('favourites and covers are kept when renaming entries', () async {
@ -188,13 +190,13 @@ void main() {
final source = await _initSource(); final source = await _initSource();
await image1.toggleFavourite(); await image1.toggleFavourite();
const albumFilter = AlbumFilter(testAlbum, 'whatever'); 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); await source.renameEntry(image1, 'image1b.jpg', persist: true);
expect(favourites.count, 1); expect(favourites.count, 1);
expect(image1.isFavourite, true); expect(image1.isFavourite, true);
expect(covers.count, 1); 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 { test('favourites and covers are cleared when removing entries', () async {
@ -206,13 +208,13 @@ void main() {
final source = await _initSource(); final source = await _initSource();
await image1.toggleFavourite(); await image1.toggleFavourite();
final albumFilter = AlbumFilter(image1.directory!, 'whatever'); final albumFilter = AlbumFilter(image1.directory!, 'whatever');
await covers.set(albumFilter, image1.contentId); await covers.set(albumFilter, image1.id);
await source.removeEntries({image1.uri}); await source.removeEntries({image1.uri}, includeTrash: true);
expect(source.rawAlbums.length, 0); expect(source.rawAlbums.length, 0);
expect(favourites.count, 0); expect(favourites.count, 0);
expect(covers.count, 0); expect(covers.count, 0);
expect(covers.coverContentId(albumFilter), null); expect(covers.coverEntryId(albumFilter), null);
}); });
test('albums are updated when moving entries', () async { test('albums are updated when moving entries', () async {
@ -232,8 +234,8 @@ void main() {
await source.updateAfterMove( await source.updateAfterMove(
todoEntries: {image1}, todoEntries: {image1},
copy: false, moveType: MoveType.move,
destinationAlbum: destinationAlbum, destinationAlbums: {destinationAlbum},
movedOps: { movedOps: {
FakeMediaStoreService.moveOpEventFor(image1, sourceAlbum, destinationAlbum), FakeMediaStoreService.moveOpEventFor(image1, sourceAlbum, destinationAlbum),
}, },
@ -256,8 +258,8 @@ void main() {
await source.updateAfterMove( await source.updateAfterMove(
todoEntries: {image1}, todoEntries: {image1},
copy: false, moveType: MoveType.move,
destinationAlbum: destinationAlbum, destinationAlbums: {destinationAlbum},
movedOps: { movedOps: {
FakeMediaStoreService.moveOpEventFor(image1, sourceAlbum, destinationAlbum), FakeMediaStoreService.moveOpEventFor(image1, sourceAlbum, destinationAlbum),
}, },
@ -277,12 +279,12 @@ void main() {
final source = await _initSource(); final source = await _initSource();
expect(source.rawAlbums.length, 1); expect(source.rawAlbums.length, 1);
const sourceAlbumFilter = AlbumFilter(sourceAlbum, 'whatever'); const sourceAlbumFilter = AlbumFilter(sourceAlbum, 'whatever');
await covers.set(sourceAlbumFilter, image1.contentId); await covers.set(sourceAlbumFilter, image1.id);
await source.updateAfterMove( await source.updateAfterMove(
todoEntries: {image1}, todoEntries: {image1},
copy: false, moveType: MoveType.move,
destinationAlbum: destinationAlbum, destinationAlbums: {destinationAlbum},
movedOps: { movedOps: {
FakeMediaStoreService.moveOpEventFor(image1, sourceAlbum, destinationAlbum), FakeMediaStoreService.moveOpEventFor(image1, sourceAlbum, destinationAlbum),
}, },
@ -290,7 +292,7 @@ void main() {
expect(source.rawAlbums.length, 2); expect(source.rawAlbums.length, 2);
expect(covers.count, 0); expect(covers.count, 0);
expect(covers.coverContentId(sourceAlbumFilter), null); expect(covers.coverEntryId(sourceAlbumFilter), null);
}); });
test('favourites and covers are kept when renaming albums', () async { test('favourites and covers are kept when renaming albums', () async {
@ -302,7 +304,7 @@ void main() {
final source = await _initSource(); final source = await _initSource();
await image1.toggleFavourite(); await image1.toggleFavourite();
var albumFilter = const AlbumFilter(sourceAlbum, 'whatever'); var albumFilter = const AlbumFilter(sourceAlbum, 'whatever');
await covers.set(albumFilter, image1.contentId); await covers.set(albumFilter, image1.id);
await source.renameAlbum(sourceAlbum, destinationAlbum, { await source.renameAlbum(sourceAlbum, destinationAlbum, {
image1 image1
}, { }, {
@ -313,7 +315,7 @@ void main() {
expect(favourites.count, 1); expect(favourites.count, 1);
expect(image1.isFavourite, true); expect(image1.isFavourite, true);
expect(covers.count, 1); expect(covers.count, 1);
expect(covers.coverContentId(albumFilter), image1.contentId); expect(covers.coverEntryId(albumFilter), image1.id);
}); });
testWidgets('unique album names', (tester) async { testWidgets('unique album names', (tester) async {

View file

@ -1,33 +1,66 @@
{ {
"de": [ "de": [
"entryActionConvert" "timeDays",
"entryActionConvert",
"entryActionRestore",
"binEntriesConfirmationDialogMessage",
"collectionActionEmptyBin",
"binPageTitle",
"settingsEnableBin",
"settingsEnableBinSubtitle"
], ],
"es": [ "es": [
"entryActionConvert", "timeDays",
"entryInfoActionEditLocation", "entryActionRestore",
"exportEntryDialogWidth", "binEntriesConfirmationDialogMessage",
"exportEntryDialogHeight", "collectionActionEmptyBin",
"editEntryLocationDialogTitle", "binPageTitle",
"editEntryLocationDialogChooseOnMapTooltip", "settingsEnableBin",
"editEntryLocationDialogLatitude", "settingsEnableBinSubtitle"
"editEntryLocationDialogLongitude",
"locationPickerUseThisLocationButton"
], ],
"fr": [ "fr": [
"entryActionConvert" "timeDays",
"entryActionConvert",
"entryActionRestore",
"binEntriesConfirmationDialogMessage",
"collectionActionEmptyBin",
"binPageTitle",
"settingsEnableBin",
"settingsEnableBinSubtitle"
], ],
"ko": [ "ko": [
"entryActionConvert" "timeDays",
"entryActionConvert",
"entryActionRestore",
"binEntriesConfirmationDialogMessage",
"collectionActionEmptyBin",
"binPageTitle",
"settingsEnableBin",
"settingsEnableBinSubtitle"
], ],
"pt": [ "pt": [
"entryActionConvert" "timeDays",
"entryActionConvert",
"entryActionRestore",
"binEntriesConfirmationDialogMessage",
"collectionActionEmptyBin",
"binPageTitle",
"settingsEnableBin",
"settingsEnableBinSubtitle"
], ],
"ru": [ "ru": [
"entryActionConvert" "timeDays",
"entryActionConvert",
"entryActionRestore",
"binEntriesConfirmationDialogMessage",
"collectionActionEmptyBin",
"binPageTitle",
"settingsEnableBin",
"settingsEnableBinSubtitle"
] ]
} }