#12 bin; entry id v content id
This commit is contained in:
parent
6d1c0e6b2c
commit
0d9e0ca787
98 changed files with 1765 additions and 858 deletions
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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) {}
|
||||||
|
|
|
@ -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)
|
||||||
})
|
})
|
||||||
|
|
|
@ -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?) {}
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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?
|
||||||
}
|
}
|
|
@ -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>()
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -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")
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
enum MoveType { copy, move, export }
|
enum MoveType { copy, move, export, toBin, fromBin }
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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'
|
||||||
|
')');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
38
lib/model/filters/trash.dart
Normal file
38
lib/model/filters/trash.dart
Normal 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;
|
||||||
|
}
|
|
@ -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,
|
||||||
|
|
|
@ -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}';
|
||||||
}
|
}
|
||||||
|
|
42
lib/model/metadata/trash.dart
Normal file
42
lib/model/metadata/trash.dart
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
34
lib/model/source/trash.dart
Normal file
34
lib/model/source/trash.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -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),
|
||||||
);
|
);
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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>{
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 = {};
|
||||||
|
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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'),
|
||||||
|
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
),
|
),
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
@ -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(),
|
||||||
],
|
],
|
||||||
|
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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}',
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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),
|
||||||
],
|
],
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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),
|
||||||
);
|
);
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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!,
|
||||||
),
|
),
|
||||||
|
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|
|
@ -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));
|
||||||
|
|
|
@ -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));
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue