Merge branch 'develop'
2
.github/workflows/check.yml
vendored
|
@ -15,7 +15,7 @@ jobs:
|
|||
- uses: subosito/flutter-action@v1
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version: '2.8.1'
|
||||
flutter-version: '2.10.1'
|
||||
|
||||
- name: Clone the repository.
|
||||
uses: actions/checkout@v2
|
||||
|
|
8
.github/workflows/release.yml
vendored
|
@ -17,7 +17,7 @@ jobs:
|
|||
- uses: subosito/flutter-action@v1
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version: '2.8.1'
|
||||
flutter-version: '2.10.1'
|
||||
|
||||
# Workaround for this Android Gradle Plugin issue (supposedly fixed in AGP 4.1):
|
||||
# https://issuetracker.google.com/issues/144111441
|
||||
|
@ -52,12 +52,12 @@ jobs:
|
|||
rm release.keystore.asc
|
||||
mkdir outputs
|
||||
(cd scripts/; ./apply_flavor_play.sh)
|
||||
flutter build appbundle -t lib/main_play.dart --flavor play --bundle-sksl-path shaders_2.8.1.sksl.json
|
||||
flutter build appbundle -t lib/main_play.dart --flavor play --bundle-sksl-path shaders_2.10.1.sksl.json
|
||||
cp build/app/outputs/bundle/playRelease/*.aab outputs
|
||||
flutter build apk -t lib/main_play.dart --flavor play --bundle-sksl-path shaders_2.8.1.sksl.json
|
||||
flutter build apk -t lib/main_play.dart --flavor play --bundle-sksl-path shaders_2.10.1.sksl.json
|
||||
cp build/app/outputs/apk/play/release/*.apk outputs
|
||||
(cd scripts/; ./apply_flavor_izzy.sh)
|
||||
flutter build apk -t lib/main_izzy.dart --flavor izzy --split-per-abi --bundle-sksl-path shaders_2.8.1.sksl.json
|
||||
flutter build apk -t lib/main_izzy.dart --flavor izzy --split-per-abi --bundle-sksl-path shaders_2.10.1.sksl.json
|
||||
cp build/app/outputs/apk/izzy/release/*.apk outputs
|
||||
rm $AVES_STORE_FILE
|
||||
env:
|
||||
|
|
27
CHANGELOG.md
|
@ -4,6 +4,33 @@ All notable changes to this project will be documented in this file.
|
|||
|
||||
## <a id="unreleased"></a>[Unreleased]
|
||||
|
||||
## <a id="v1.6.0"></a>[v1.6.0] - 2022-02-22
|
||||
|
||||
### Added
|
||||
|
||||
- optional recycle bin to keep deleted items for 30 days
|
||||
- Viewer: actions to copy/move to album
|
||||
- Indonesian translation (thanks MeFinity)
|
||||
|
||||
### Changed
|
||||
|
||||
- Viewer: action menu reorganization
|
||||
- Viewer: `Export` action renamed to `Convert`
|
||||
- Viewer: actual size zoom level respects device pixel ratio
|
||||
- Viewer: allow zooming out small items to actual size
|
||||
- Collection: improved performance for sort/group by name
|
||||
- load previous top items on startup
|
||||
- locale independent colors for known filters
|
||||
- upgraded Flutter to stable v2.10.1
|
||||
|
||||
### Removed
|
||||
|
||||
- Map: connectivity check
|
||||
|
||||
### Fixed
|
||||
|
||||
- navigating from Album page when picking an item for another app
|
||||
|
||||
## <a id="v1.5.11"></a>[v1.5.11] - 2022-01-30
|
||||
|
||||
### Added
|
||||
|
|
|
@ -85,7 +85,7 @@ At this stage this project does *not* accept PRs, except for translations.
|
|||
|
||||
### Translations
|
||||
|
||||
If you want to translate this app in your language and share the result, [there is a guide](https://github.com/deckerst/aves/wiki/Contributing-to-Translations). English, Korean and French are already handled by me. Russian, German and Spanish are handled by generous volunteers.
|
||||
If you want to translate this app in your language and share the result, [there is a guide](https://github.com/deckerst/aves/wiki/Contributing-to-Translations). English, Korean and French are already handled by me. Russian, German, Spanish, Portuguese & Indonesian are handled by generous volunteers.
|
||||
|
||||
### Donations
|
||||
|
||||
|
|
|
@ -141,7 +141,7 @@ repositories {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.1'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2'
|
||||
implementation 'androidx.core:core-ktx:1.7.0'
|
||||
implementation 'androidx.exifinterface:exifinterface:1.3.3'
|
||||
implementation 'androidx.multidex:multidex:2.0.1'
|
||||
|
@ -152,10 +152,10 @@ dependencies {
|
|||
implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a'
|
||||
// forked, built by JitPack, cf https://jitpack.io/p/deckerst/pixymeta-android
|
||||
implementation 'com.github.deckerst:pixymeta-android:706bd73d6e'
|
||||
implementation 'com.github.bumptech.glide:glide:4.12.0'
|
||||
implementation 'com.github.bumptech.glide:glide:4.13.0'
|
||||
|
||||
kapt 'androidx.annotation:annotation:1.3.0'
|
||||
kapt 'com.github.bumptech.glide:compiler:4.12.0'
|
||||
kapt 'com.github.bumptech.glide:compiler:4.13.0'
|
||||
|
||||
compileOnly rootProject.findProject(':streams_channel')
|
||||
}
|
||||
|
|
|
@ -159,10 +159,10 @@ class AnalysisService : MethodChannel.MethodCallHandler, Service() {
|
|||
COMMAND_START -> {
|
||||
runBlocking {
|
||||
FlutterUtils.runOnUiThread {
|
||||
val contentIds = data.get(KEY_CONTENT_IDS)?.takeIf { it is IntArray }?.let { (it as IntArray).toList() }
|
||||
val entryIds = data.get(KEY_ENTRY_IDS)?.takeIf { it is IntArray }?.let { (it as IntArray).toList() }
|
||||
backgroundChannel?.invokeMethod(
|
||||
"start", hashMapOf(
|
||||
"contentIds" to contentIds,
|
||||
"entryIds" to entryIds,
|
||||
"force" to data.getBoolean(KEY_FORCE),
|
||||
)
|
||||
)
|
||||
|
@ -197,7 +197,7 @@ class AnalysisService : MethodChannel.MethodCallHandler, Service() {
|
|||
const val KEY_COMMAND = "command"
|
||||
const val COMMAND_START = "start"
|
||||
const val COMMAND_STOP = "stop"
|
||||
const val KEY_CONTENT_IDS = "content_ids"
|
||||
const val KEY_ENTRY_IDS = "entry_ids"
|
||||
const val KEY_FORCE = "force"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,15 +17,15 @@ import deckers.thibault.aves.utils.LogUtils
|
|||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.*
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
class SearchSuggestionsProvider : MethodChannel.MethodCallHandler, ContentProvider() {
|
||||
private val defaultScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
override fun query(uri: Uri, projection: Array<String>?, selection: String?, selectionArgs: Array<String>?, sortOrder: String?): Cursor? {
|
||||
return selectionArgs?.firstOrNull()?.let { query ->
|
||||
// Samsung Finder does not support:
|
||||
|
@ -77,29 +77,34 @@ class SearchSuggestionsProvider : MethodChannel.MethodCallHandler, ContentProvid
|
|||
val backgroundChannel = MethodChannel(messenger, BACKGROUND_CHANNEL)
|
||||
backgroundChannel.setMethodCallHandler(this)
|
||||
|
||||
return suspendCoroutine { cont ->
|
||||
GlobalScope.launch {
|
||||
FlutterUtils.runOnUiThread {
|
||||
backgroundChannel.invokeMethod("getSuggestions", hashMapOf(
|
||||
"query" to query,
|
||||
"locale" to Locale.getDefault().toString(),
|
||||
"use24hour" to DateFormat.is24HourFormat(context),
|
||||
), object : MethodChannel.Result {
|
||||
override fun success(result: Any?) {
|
||||
@Suppress("unchecked_cast")
|
||||
cont.resume(result as List<FieldMap>)
|
||||
}
|
||||
try {
|
||||
return suspendCoroutine { cont ->
|
||||
defaultScope.launch {
|
||||
FlutterUtils.runOnUiThread {
|
||||
backgroundChannel.invokeMethod("getSuggestions", hashMapOf(
|
||||
"query" to query,
|
||||
"locale" to Locale.getDefault().toString(),
|
||||
"use24hour" to DateFormat.is24HourFormat(context),
|
||||
), object : MethodChannel.Result {
|
||||
override fun success(result: Any?) {
|
||||
@Suppress("unchecked_cast")
|
||||
cont.resume(result as List<FieldMap>)
|
||||
}
|
||||
|
||||
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
|
||||
cont.resumeWithException(Exception("$errorCode: $errorMessage\n$errorDetails"))
|
||||
}
|
||||
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
|
||||
cont.resumeWithException(Exception("$errorCode: $errorMessage\n$errorDetails"))
|
||||
}
|
||||
|
||||
override fun notImplemented() {
|
||||
cont.resumeWithException(NotImplementedError("getSuggestions"))
|
||||
}
|
||||
})
|
||||
override fun notImplemented() {
|
||||
cont.resumeWithException(NotImplementedError("getSuggestions"))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(LOG_TAG, "failed to get suggestions", e)
|
||||
return ArrayList()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -16,14 +16,14 @@ import deckers.thibault.aves.utils.ContextUtils.isMyServiceRunning
|
|||
import deckers.thibault.aves.utils.LogUtils
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
class AnalysisHandler(private val activity: Activity, private val onAnalysisCompleted: () -> Unit) : MethodChannel.MethodCallHandler, AnalysisServiceListener {
|
||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"registerCallback" -> GlobalScope.launch(Dispatchers.IO) { Coresult.safe(call, result, ::registerCallback) }
|
||||
"registerCallback" -> ioScope.launch { Coresult.safe(call, result, ::registerCallback) }
|
||||
"startService" -> Coresult.safe(call, result, ::startAnalysis)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
|
@ -52,12 +52,12 @@ class AnalysisHandler(private val activity: Activity, private val onAnalysisComp
|
|||
}
|
||||
|
||||
// can be null or empty
|
||||
val contentIds = call.argument<List<Int>>("contentIds")
|
||||
val entryIds = call.argument<List<Int>>("entryIds")
|
||||
|
||||
if (!activity.isMyServiceRunning(AnalysisService::class.java)) {
|
||||
val intent = Intent(activity, AnalysisService::class.java)
|
||||
intent.putExtra(AnalysisService.KEY_COMMAND, AnalysisService.COMMAND_START)
|
||||
intent.putExtra(AnalysisService.KEY_CONTENT_IDS, contentIds?.toIntArray())
|
||||
intent.putExtra(AnalysisService.KEY_ENTRY_IDS, entryIds?.toIntArray())
|
||||
intent.putExtra(AnalysisService.KEY_FORCE, force)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
activity.startForegroundService(intent)
|
||||
|
|
|
@ -33,26 +33,26 @@ import deckers.thibault.aves.utils.LogUtils
|
|||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
import kotlin.collections.ArrayList
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"getPackages" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPackages) }
|
||||
"getAppIcon" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getAppIcon) }
|
||||
"copyToClipboard" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::copyToClipboard) }
|
||||
"getPackages" -> ioScope.launch { safe(call, result, ::getPackages) }
|
||||
"getAppIcon" -> ioScope.launch { safeSuspend(call, result, ::getAppIcon) }
|
||||
"copyToClipboard" -> ioScope.launch { safe(call, result, ::copyToClipboard) }
|
||||
"edit" -> safe(call, result, ::edit)
|
||||
"open" -> safe(call, result, ::open)
|
||||
"openMap" -> safe(call, result, ::openMap)
|
||||
"setAs" -> safe(call, result, ::setAs)
|
||||
"share" -> safe(call, result, ::share)
|
||||
"pinShortcut" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::pinShortcut) }
|
||||
"pinShortcut" -> ioScope.launch { safe(call, result, ::pinShortcut) }
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -32,33 +32,33 @@ import deckers.thibault.aves.utils.UriUtils.tryParseId
|
|||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import io.flutter.util.PathUtils
|
||||
import kotlinx.coroutines.*
|
||||
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
||||
import java.io.IOException
|
||||
import java.util.*
|
||||
|
||||
class DebugHandler(private val context: Context) : MethodCallHandler {
|
||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"crash" -> Handler(Looper.getMainLooper()).postDelayed({ throw TestException() }, 50)
|
||||
"exception" -> throw TestException()
|
||||
"safeException" -> safe(call, result) { _, _ -> throw TestException() }
|
||||
"exceptionInCoroutine" -> GlobalScope.launch(Dispatchers.IO) { throw TestException() }
|
||||
"safeExceptionInCoroutine" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result) { _, _ -> throw TestException() } }
|
||||
"exceptionInCoroutine" -> ioScope.launch { throw TestException() }
|
||||
"safeExceptionInCoroutine" -> ioScope.launch { safe(call, result) { _, _ -> throw TestException() } }
|
||||
|
||||
"getContextDirs" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContextDirs) }
|
||||
"getContextDirs" -> ioScope.launch { safe(call, result, ::getContextDirs) }
|
||||
"getCodecs" -> safe(call, result, ::getCodecs)
|
||||
"getEnv" -> safe(call, result, ::getEnv)
|
||||
|
||||
"getBitmapFactoryInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getBitmapFactoryInfo) }
|
||||
"getContentResolverMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverMetadata) }
|
||||
"getExifInterfaceMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getExifInterfaceMetadata) }
|
||||
"getMediaMetadataRetrieverMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMediaMetadataRetrieverMetadata) }
|
||||
"getMetadataExtractorSummary" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMetadataExtractorSummary) }
|
||||
"getPixyMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPixyMetadata) }
|
||||
"getTiffStructure" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getTiffStructure) }
|
||||
"getBitmapFactoryInfo" -> ioScope.launch { safe(call, result, ::getBitmapFactoryInfo) }
|
||||
"getContentResolverMetadata" -> ioScope.launch { safe(call, result, ::getContentResolverMetadata) }
|
||||
"getExifInterfaceMetadata" -> ioScope.launch { safe(call, result, ::getExifInterfaceMetadata) }
|
||||
"getMediaMetadataRetrieverMetadata" -> ioScope.launch { safe(call, result, ::getMediaMetadataRetrieverMetadata) }
|
||||
"getMetadataExtractorSummary" -> ioScope.launch { safe(call, result, ::getMetadataExtractorSummary) }
|
||||
"getPixyMetadata" -> ioScope.launch { safe(call, result, ::getPixyMetadata) }
|
||||
"getTiffStructure" -> ioScope.launch { safe(call, result, ::getTiffStructure) }
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
@ -69,6 +69,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
|||
"filesDir" to context.filesDir,
|
||||
"obbDir" to context.obbDir,
|
||||
"externalCacheDir" to context.externalCacheDir,
|
||||
"externalFilesDir" to context.getExternalFilesDir(null),
|
||||
).apply {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
putAll(
|
||||
|
@ -81,7 +82,18 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
|||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
put("dataDir", context.dataDir)
|
||||
}
|
||||
}.mapValues { it.value?.path }
|
||||
}.mapValues { it.value?.path }.toMutableMap()
|
||||
dirs["externalCacheDirs"] = context.externalCacheDirs.joinToString { it.path }
|
||||
dirs["externalFilesDirs"] = context.getExternalFilesDirs(null).joinToString { it.path }
|
||||
|
||||
// used by flutter plugin `path_provider`
|
||||
dirs.putAll(
|
||||
hashMapOf(
|
||||
"flutter / cacheDir" to PathUtils.getCacheDirectory(context),
|
||||
"flutter / dataDir" to PathUtils.getDataDirectory(context),
|
||||
"flutter / filesDir" to PathUtils.getFilesDir(context),
|
||||
)
|
||||
)
|
||||
|
||||
result.success(dirs)
|
||||
}
|
||||
|
|
|
@ -34,20 +34,20 @@ import deckers.thibault.aves.utils.StorageUtils
|
|||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.util.*
|
||||
|
||||
class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"getExifThumbnails" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getExifThumbnails) }
|
||||
"extractMotionPhotoVideo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractMotionPhotoVideo) }
|
||||
"extractVideoEmbeddedPicture" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractVideoEmbeddedPicture) }
|
||||
"extractXmpDataProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractXmpDataProp) }
|
||||
"getExifThumbnails" -> ioScope.launch { safeSuspend(call, result, ::getExifThumbnails) }
|
||||
"extractMotionPhotoVideo" -> ioScope.launch { safe(call, result, ::extractMotionPhotoVideo) }
|
||||
"extractVideoEmbeddedPicture" -> ioScope.launch { safe(call, result, ::extractVideoEmbeddedPicture) }
|
||||
"extractXmpDataProp" -> ioScope.launch { safe(call, result, ::extractXmpDataProp) }
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
@ -210,7 +210,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
|||
"mimeType" to mimeType,
|
||||
)
|
||||
if (isImage(mimeType) || isVideo(mimeType)) {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
ioScope.launch {
|
||||
ContentImageProvider().fetchSingle(context, uri, mimeType, object : ImageProvider.ImageOpCallback {
|
||||
override fun onSuccess(fields: FieldMap) {
|
||||
resultFields.putAll(fields)
|
||||
|
|
|
@ -6,9 +6,7 @@ import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
|||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.IOException
|
||||
import java.util.*
|
||||
|
||||
|
@ -16,11 +14,12 @@ import java.util.*
|
|||
// - `geocoder` is unmaintained
|
||||
// - `geocoding` method does not return `addressLine` (v2.0.0)
|
||||
class GeocodingHandler(private val context: Context) : MethodCallHandler {
|
||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var geocoder: Geocoder? = null
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"getAddress" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getAddress) }
|
||||
"getAddress" -> ioScope.launch { safe(call, result, ::getAddress) }
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,14 +7,14 @@ import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
|||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
class GlobalSearchHandler(private val context: Context) : MethodCallHandler {
|
||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"registerCallback" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::registerCallback) }
|
||||
"registerCallback" -> ioScope.launch { safe(call, result, ::registerCallback) }
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,24 +21,23 @@ import deckers.thibault.aves.utils.StorageUtils.ensureTrailingSeparator
|
|||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.*
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class MediaFileHandler(private val activity: Activity) : MethodCallHandler {
|
||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val density = activity.resources.displayMetrics.density
|
||||
|
||||
private val regionFetcher = RegionFetcher(activity)
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"getEntry" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getEntry) }
|
||||
"getThumbnail" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getThumbnail) }
|
||||
"getRegion" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getRegion) }
|
||||
"getEntry" -> ioScope.launch { safe(call, result, ::getEntry) }
|
||||
"getThumbnail" -> ioScope.launch { safeSuspend(call, result, ::getThumbnail) }
|
||||
"getRegion" -> ioScope.launch { safeSuspend(call, result, ::getRegion) }
|
||||
"cancelFileOp" -> safe(call, result, ::cancelFileOp)
|
||||
"captureFrame" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::captureFrame) }
|
||||
"clearSizedThumbnailDiskCache" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::clearSizedThumbnailDiskCache) }
|
||||
"captureFrame" -> ioScope.launch { safeSuspend(call, result, ::captureFrame) }
|
||||
"clearSizedThumbnailDiskCache" -> ioScope.launch { safe(call, result, ::clearSizedThumbnailDiskCache) }
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,22 +8,25 @@ import deckers.thibault.aves.model.provider.MediaStoreImageProvider
|
|||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MediaStoreHandler(private val context: Context) : MethodCallHandler {
|
||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"checkObsoleteContentIds" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::checkObsoleteContentIds) }
|
||||
"checkObsoletePaths" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::checkObsoletePaths) }
|
||||
"scanFile" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::scanFile) }
|
||||
"checkObsoleteContentIds" -> ioScope.launch { safe(call, result, ::checkObsoleteContentIds) }
|
||||
"checkObsoletePaths" -> ioScope.launch { safe(call, result, ::checkObsoletePaths) }
|
||||
"scanFile" -> ioScope.launch { safe(call, result, ::scanFile) }
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkObsoleteContentIds(call: MethodCall, result: MethodChannel.Result) {
|
||||
val knownContentIds = call.argument<List<Int>>("knownContentIds")
|
||||
val knownContentIds = call.argument<List<Int?>>("knownContentIds")
|
||||
if (knownContentIds == null) {
|
||||
result.error("checkObsoleteContentIds-args", "failed because of missing arguments", null)
|
||||
return
|
||||
|
@ -32,7 +35,7 @@ class MediaStoreHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
|
||||
private fun checkObsoletePaths(call: MethodCall, result: MethodChannel.Result) {
|
||||
val knownPathById = call.argument<Map<Int, String>>("knownPathById")
|
||||
val knownPathById = call.argument<Map<Int?, String?>>("knownPathById")
|
||||
if (knownPathById == null) {
|
||||
result.error("checkObsoletePaths-args", "failed because of missing arguments", null)
|
||||
return
|
||||
|
|
|
@ -10,18 +10,18 @@ import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
|
|||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
|
||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) }
|
||||
"flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) }
|
||||
"editDate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::editDate) }
|
||||
"editMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::editMetadata) }
|
||||
"removeTypes" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::removeTypes) }
|
||||
"rotate" -> ioScope.launch { safe(call, result, ::rotate) }
|
||||
"flip" -> ioScope.launch { safe(call, result, ::flip) }
|
||||
"editDate" -> ioScope.launch { safe(call, result, ::editDate) }
|
||||
"editMetadata" -> ioScope.launch { safe(call, result, ::editMetadata) }
|
||||
"removeTypes" -> ioScope.launch { safe(call, result, ::removeTypes) }
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -74,29 +74,28 @@ import deckers.thibault.aves.utils.UriUtils.tryParseId
|
|||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.*
|
||||
import java.nio.charset.Charset
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.text.ParseException
|
||||
import java.util.*
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"getAllMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getAllMetadata) }
|
||||
"getCatalogMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getCatalogMetadata) }
|
||||
"getOverlayMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getOverlayMetadata) }
|
||||
"getMultiPageInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMultiPageInfo) }
|
||||
"getPanoramaInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPanoramaInfo) }
|
||||
"getIptc" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getIptc) }
|
||||
"getXmp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getXmp) }
|
||||
"hasContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::hasContentResolverProp) }
|
||||
"getContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverProp) }
|
||||
"getDate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getDate) }
|
||||
"getAllMetadata" -> ioScope.launch { safe(call, result, ::getAllMetadata) }
|
||||
"getCatalogMetadata" -> ioScope.launch { safe(call, result, ::getCatalogMetadata) }
|
||||
"getOverlayMetadata" -> ioScope.launch { safe(call, result, ::getOverlayMetadata) }
|
||||
"getMultiPageInfo" -> ioScope.launch { safe(call, result, ::getMultiPageInfo) }
|
||||
"getPanoramaInfo" -> ioScope.launch { safe(call, result, ::getPanoramaInfo) }
|
||||
"getIptc" -> ioScope.launch { safe(call, result, ::getIptc) }
|
||||
"getXmp" -> ioScope.launch { safe(call, result, ::getXmp) }
|
||||
"hasContentResolverProp" -> ioScope.launch { safe(call, result, ::hasContentResolverProp) }
|
||||
"getContentResolverProp" -> ioScope.launch { safe(call, result, ::getContentResolverProp) }
|
||||
"getDate" -> ioScope.launch { safe(call, result, ::getDate) }
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
@ -412,19 +411,19 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
|
||||
// File type
|
||||
for (dir in metadata.getDirectoriesOfType(FileTypeDirectory::class.java)) {
|
||||
// * `metadata-extractor` sometimes detects the wrong MIME type (e.g. `pef` file as `tiff`, `mpeg` as `dvd`)
|
||||
// * `metadata-extractor` sometimes detects the wrong MIME type (e.g. `pef` file as `tiff`, `mpeg` as `dvd`, `avif` as `mov`)
|
||||
// * the content resolver / media store sometimes reports the wrong MIME type (e.g. `png` file as `jpeg`, `tiff` as `srw`)
|
||||
// * `context.getContentResolver().getType()` sometimes returns an incorrect value
|
||||
// * `MediaMetadataRetriever.setDataSource()` sometimes fails with `status = 0x80000000`
|
||||
// * file extension is unreliable
|
||||
// In the end, `metadata-extractor` is the most reliable, except for `tiff`/`dvd` (false positives, false negatives),
|
||||
// In the end, `metadata-extractor` is the most reliable, except for `tiff`/`dvd`/`mov` (false positives, false negatives),
|
||||
// in which case we trust the file extension
|
||||
// cf https://github.com/drewnoakes/metadata-extractor/issues/296
|
||||
if (path?.matches(TIFF_EXTENSION_PATTERN) == true) {
|
||||
metadataMap[KEY_MIME_TYPE] = MimeTypes.TIFF
|
||||
} else {
|
||||
dir.getSafeString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE) {
|
||||
if (it != MimeTypes.TIFF && it != MimeTypes.DVD) {
|
||||
if (it != MimeTypes.TIFF && it != MimeTypes.DVD && it != MimeTypes.MOV) {
|
||||
metadataMap[KEY_MIME_TYPE] = it
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,22 +13,24 @@ import deckers.thibault.aves.utils.StorageUtils.getVolumePaths
|
|||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
|
||||
class StorageHandler(private val context: Context) : MethodCallHandler {
|
||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"getStorageVolumes" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getStorageVolumes) }
|
||||
"getFreeSpace" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getFreeSpace) }
|
||||
"getGrantedDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getGrantedDirectories) }
|
||||
"getInaccessibleDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getInaccessibleDirectories) }
|
||||
"getRestrictedDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getRestrictedDirectories) }
|
||||
"getStorageVolumes" -> ioScope.launch { safe(call, result, ::getStorageVolumes) }
|
||||
"getFreeSpace" -> ioScope.launch { safe(call, result, ::getFreeSpace) }
|
||||
"getGrantedDirectories" -> ioScope.launch { safe(call, result, ::getGrantedDirectories) }
|
||||
"getInaccessibleDirectories" -> ioScope.launch { safe(call, result, ::getInaccessibleDirectories) }
|
||||
"getRestrictedDirectories" -> ioScope.launch { safe(call, result, ::getRestrictedDirectories) }
|
||||
"revokeDirectoryAccess" -> safe(call, result, ::revokeDirectoryAccess)
|
||||
"deleteEmptyDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::deleteEmptyDirectories) }
|
||||
"deleteEmptyDirectories" -> ioScope.launch { safe(call, result, ::deleteEmptyDirectories) }
|
||||
"canRequestMediaFileBulkAccess" -> safe(call, result, ::canRequestMediaFileBulkAccess)
|
||||
"canInsertMedia" -> safe(call, result, ::canInsertMedia)
|
||||
else -> result.notImplemented()
|
||||
|
|
|
@ -131,7 +131,7 @@ class ThumbnailFetcher internal constructor(
|
|||
svgFetch -> SvgImage(context, uri)
|
||||
tiffFetch -> TiffImage(context, uri, pageId)
|
||||
multiTrackFetch -> MultiTrackImage(context, uri, pageId)
|
||||
else -> StorageUtils.getGlideSafeUri(uri, mimeType)
|
||||
else -> StorageUtils.getGlideSafeUri(context, uri, mimeType)
|
||||
}
|
||||
Glide.with(context)
|
||||
.asBitmap()
|
||||
|
|
|
@ -23,12 +23,11 @@ import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
|
|||
import deckers.thibault.aves.utils.StorageUtils
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.EventChannel.EventSink
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.InputStream
|
||||
|
||||
class ImageByteStreamHandler(private val context: Context, private val arguments: Any?) : EventChannel.StreamHandler {
|
||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private lateinit var eventSink: EventSink
|
||||
private lateinit var handler: Handler
|
||||
|
||||
|
@ -36,7 +35,7 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
|
|||
this.eventSink = eventSink
|
||||
handler = Handler(Looper.getMainLooper())
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) { streamImage() }
|
||||
ioScope.launch { streamImage() }
|
||||
}
|
||||
|
||||
override fun onCancel(o: Any) {}
|
||||
|
@ -119,7 +118,7 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
|
|||
} else if (mimeType == MimeTypes.TIFF) {
|
||||
TiffImage(context, uri, pageId)
|
||||
} else {
|
||||
StorageUtils.getGlideSafeUri(uri, mimeType)
|
||||
StorageUtils.getGlideSafeUri(context, uri, mimeType)
|
||||
}
|
||||
|
||||
val target = Glide.with(context)
|
||||
|
|
|
@ -11,16 +11,19 @@ import deckers.thibault.aves.model.FieldMap
|
|||
import deckers.thibault.aves.model.NameConflictStrategy
|
||||
import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback
|
||||
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
|
||||
import deckers.thibault.aves.model.provider.MediaStoreImageProvider
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.EventChannel.EventSink
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.*
|
||||
import java.io.File
|
||||
|
||||
class ImageOpStreamHandler(private val activity: Activity, private val arguments: Any?) : EventChannel.StreamHandler {
|
||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private lateinit var eventSink: EventSink
|
||||
private lateinit var handler: Handler
|
||||
|
||||
|
@ -45,10 +48,10 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
|
|||
handler = Handler(Looper.getMainLooper())
|
||||
|
||||
when (op) {
|
||||
"delete" -> GlobalScope.launch(Dispatchers.IO) { delete() }
|
||||
"export" -> GlobalScope.launch(Dispatchers.IO) { export() }
|
||||
"move" -> GlobalScope.launch(Dispatchers.IO) { move() }
|
||||
"rename" -> GlobalScope.launch(Dispatchers.IO) { rename() }
|
||||
"delete" -> ioScope.launch { delete() }
|
||||
"export" -> ioScope.launch { export() }
|
||||
"move" -> ioScope.launch { move() }
|
||||
"rename" -> ioScope.launch { rename() }
|
||||
else -> endOfStream()
|
||||
}
|
||||
}
|
||||
|
@ -103,12 +106,16 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
|
|||
|
||||
val entries = entryMapList.map(::AvesEntry)
|
||||
for (entry in entries) {
|
||||
val uri = entry.uri
|
||||
val path = entry.path
|
||||
val mimeType = entry.mimeType
|
||||
val trashed = entry.trashed
|
||||
|
||||
val uri = if (trashed) Uri.fromFile(File(entry.trashPath!!)) else entry.uri
|
||||
val path = if (trashed) entry.trashPath else entry.path
|
||||
|
||||
val result: FieldMap = hashMapOf(
|
||||
"uri" to uri.toString(),
|
||||
// `uri` should reference original content URI,
|
||||
// so it is different with `sourceUri` when deleting trashed entries
|
||||
"uri" to entry.uri.toString(),
|
||||
)
|
||||
if (isCancelledOp()) {
|
||||
result["skipped"] = true
|
||||
|
@ -160,30 +167,34 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
|
|||
}
|
||||
|
||||
private suspend fun move() {
|
||||
if (arguments !is Map<*, *> || entryMapList.isEmpty()) {
|
||||
if (arguments !is Map<*, *>) {
|
||||
endOfStream()
|
||||
return
|
||||
}
|
||||
|
||||
val copy = arguments["copy"] as Boolean?
|
||||
var destinationDir = arguments["destinationPath"] as String?
|
||||
val nameConflictStrategy = NameConflictStrategy.get(arguments["nameConflictStrategy"] as String?)
|
||||
if (copy == null || destinationDir == null || nameConflictStrategy == null) {
|
||||
val rawEntryMap = arguments["entriesByDestination"] as Map<*, *>?
|
||||
if (copy == null || nameConflictStrategy == null || rawEntryMap == null || rawEntryMap.isEmpty()) {
|
||||
error("move-args", "failed because of missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
// assume same provider for all entries
|
||||
val firstEntry = entryMapList.first()
|
||||
val provider = (firstEntry["uri"] as String?)?.let { Uri.parse(it) }?.let { getProvider(it) }
|
||||
if (provider == null) {
|
||||
error("move-provider", "failed to find provider for entry=$firstEntry", null)
|
||||
return
|
||||
val entriesByTargetDir = HashMap<String, List<AvesEntry>>()
|
||||
rawEntryMap.forEach {
|
||||
var destinationDir = it.key as String
|
||||
if (destinationDir != StorageUtils.TRASH_PATH_PLACEHOLDER) {
|
||||
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
|
||||
}
|
||||
@Suppress("unchecked_cast")
|
||||
val rawEntries = it.value as List<FieldMap>
|
||||
entriesByTargetDir[destinationDir] = rawEntries.map(::AvesEntry)
|
||||
}
|
||||
|
||||
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
|
||||
val entries = entryMapList.map(::AvesEntry)
|
||||
provider.moveMultiple(activity, copy, destinationDir, nameConflictStrategy, entries, ::isCancelledOp, object : ImageOpCallback {
|
||||
// always use Media Store (as we move from or to it)
|
||||
val provider = MediaStoreImageProvider()
|
||||
|
||||
provider.moveMultiple(activity, copy, nameConflictStrategy, entriesByTargetDir, ::isCancelledOp, object : ImageOpCallback {
|
||||
override fun onSuccess(fields: FieldMap) = success(fields)
|
||||
override fun onFailure(throwable: Throwable) = error("move-failure", "failed to move entries", throwable)
|
||||
})
|
||||
|
|
|
@ -9,20 +9,24 @@ import deckers.thibault.aves.model.provider.MediaStoreImageProvider
|
|||
import deckers.thibault.aves.utils.LogUtils
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.EventChannel.EventSink
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : EventChannel.StreamHandler {
|
||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private lateinit var eventSink: EventSink
|
||||
private lateinit var handler: Handler
|
||||
|
||||
private var knownEntries: Map<Int, Int?>? = null
|
||||
private var knownEntries: Map<Int?, Int?>? = null
|
||||
private var directory: String? = null
|
||||
|
||||
init {
|
||||
if (arguments is Map<*, *>) {
|
||||
@Suppress("unchecked_cast")
|
||||
knownEntries = arguments["knownEntries"] as Map<Int, Int?>?
|
||||
knownEntries = arguments["knownEntries"] as Map<Int?, Int?>?
|
||||
directory = arguments["directory"] as String?
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -30,7 +34,7 @@ class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : E
|
|||
this.eventSink = eventSink
|
||||
handler = Handler(Looper.getMainLooper())
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) { fetchAll() }
|
||||
ioScope.launch { fetchAll() }
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {}
|
||||
|
@ -56,7 +60,7 @@ class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : E
|
|||
}
|
||||
|
||||
private fun fetchAll() {
|
||||
MediaStoreImageProvider().fetchAll(context, knownEntries ?: emptyMap()) { success(it) }
|
||||
MediaStoreImageProvider().fetchAll(context, knownEntries ?: emptyMap(), directory) { success(it) }
|
||||
endOfStream()
|
||||
}
|
||||
|
||||
|
|
|
@ -15,14 +15,16 @@ import deckers.thibault.aves.utils.MimeTypes
|
|||
import deckers.thibault.aves.utils.PermissionManager
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.EventChannel.EventSink
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.FileOutputStream
|
||||
|
||||
// starting activity to give access with the native dialog
|
||||
// breaks the regular `MethodChannel` so we use a stream channel instead
|
||||
class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?) : EventChannel.StreamHandler {
|
||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private lateinit var eventSink: EventSink
|
||||
private lateinit var handler: Handler
|
||||
|
||||
|
@ -41,10 +43,10 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
|
|||
handler = Handler(Looper.getMainLooper())
|
||||
|
||||
when (op) {
|
||||
"requestDirectoryAccess" -> GlobalScope.launch(Dispatchers.IO) { requestDirectoryAccess() }
|
||||
"requestMediaFileAccess" -> GlobalScope.launch(Dispatchers.IO) { requestMediaFileAccess() }
|
||||
"createFile" -> GlobalScope.launch(Dispatchers.IO) { createFile() }
|
||||
"openFile" -> GlobalScope.launch(Dispatchers.IO) { openFile() }
|
||||
"requestDirectoryAccess" -> ioScope.launch { requestDirectoryAccess() }
|
||||
"requestMediaFileAccess" -> ioScope.launch { requestMediaFileAccess() }
|
||||
"createFile" -> ioScope.launch { createFile() }
|
||||
"openFile" -> ioScope.launch { openFile() }
|
||||
else -> endOfStream()
|
||||
}
|
||||
}
|
||||
|
@ -113,7 +115,7 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
|
|||
putExtra(Intent.EXTRA_TITLE, name)
|
||||
}
|
||||
MainActivity.pendingStorageAccessResultHandlers[MainActivity.CREATE_FILE_REQUEST] = PendingStorageAccessResultHandler(null, { uri ->
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
ioScope.launch {
|
||||
try {
|
||||
activity.contentResolver.openOutputStream(uri)?.use { output ->
|
||||
output as FileOutputStream
|
||||
|
@ -145,7 +147,7 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
|
|||
val mimeType = args["mimeType"] as String? // optional
|
||||
|
||||
fun onGranted(uri: Uri) {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
ioScope.launch {
|
||||
activity.contentResolver.openInputStream(uri)?.use { input ->
|
||||
val buffer = ByteArray(BUFFER_SIZE)
|
||||
var len: Int
|
||||
|
|
|
@ -18,8 +18,9 @@ import com.bumptech.glide.module.LibraryGlideModule
|
|||
import com.bumptech.glide.signature.ObjectKey
|
||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||
import deckers.thibault.aves.utils.StorageUtils.openMetadataRetriever
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.InputStream
|
||||
|
@ -48,8 +49,10 @@ internal class VideoThumbnailLoader : ModelLoader<VideoThumbnail, InputStream> {
|
|||
}
|
||||
|
||||
internal class VideoThumbnailFetcher(private val model: VideoThumbnail) : DataFetcher<InputStream> {
|
||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override fun loadData(priority: Priority, callback: DataCallback<in InputStream>) {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
ioScope.launch {
|
||||
val retriever = openMetadataRetriever(model.context, model.uri)
|
||||
if (retriever == null) {
|
||||
callback.onLoadFailed(Exception("failed to initialize MediaMetadataRetriever for uri=${model.uri}"))
|
||||
|
|
|
@ -62,7 +62,7 @@ class GSpherical(xmlBytes: ByteArray) {
|
|||
}
|
||||
}
|
||||
|
||||
fun describe(): Map<String, String?> = hashMapOf(
|
||||
fun describe(): Map<String, String> = hashMapOf(
|
||||
"Spherical" to spherical.toString(),
|
||||
"Stitched" to stitched.toString(),
|
||||
"Stitching Software" to stitchingSoftware,
|
||||
|
@ -79,7 +79,7 @@ class GSpherical(xmlBytes: ByteArray) {
|
|||
"Cropped Area Image Height Pixels" to croppedAreaImageHeightPixels?.toString(),
|
||||
"Cropped Area Left Pixels" to croppedAreaLeftPixels?.toString(),
|
||||
"Cropped Area Top Pixels" to croppedAreaTopPixels?.toString(),
|
||||
).filterValues { it != null }
|
||||
).filterValues { it != null }.mapValues { it.value as String }
|
||||
|
||||
companion object SphericalVideo {
|
||||
private val LOG_TAG = LogUtils.createTag<SphericalVideo>()
|
||||
|
|
|
@ -11,4 +11,6 @@ class AvesEntry(map: FieldMap) {
|
|||
val height = map["height"] as Int
|
||||
val rotationDegrees = map["rotationDegrees"] as Int
|
||||
val isFlipped = map["isFlipped"] as Boolean
|
||||
val trashed = map["trashed"] as Boolean
|
||||
val trashPath = map["trashPath"] as String?
|
||||
}
|
|
@ -1,8 +1,11 @@
|
|||
package deckers.thibault.aves.model.provider
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import deckers.thibault.aves.model.SourceEntry
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import java.io.File
|
||||
|
||||
internal class FileImageProvider : ImageProvider() {
|
||||
|
@ -33,4 +36,18 @@ internal class FileImageProvider : ImageProvider() {
|
|||
callback.onFailure(Exception("entry has no size"))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun delete(activity: Activity, uri: Uri, path: String?, mimeType: String) {
|
||||
val file = File(File(uri.path!!).path)
|
||||
if (!file.exists()) return
|
||||
|
||||
Log.d(LOG_TAG, "delete file at uri=$uri")
|
||||
if (file.delete()) return
|
||||
|
||||
throw Exception("failed to delete entry with uri=$uri path=$path")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val LOG_TAG = LogUtils.createTag<MediaStoreImageProvider>()
|
||||
}
|
||||
}
|
|
@ -39,7 +39,6 @@ import java.io.File
|
|||
import java.io.IOException
|
||||
import java.io.OutputStream
|
||||
import java.util.*
|
||||
import kotlin.collections.HashMap
|
||||
|
||||
abstract class ImageProvider {
|
||||
open fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) {
|
||||
|
@ -53,9 +52,8 @@ abstract class ImageProvider {
|
|||
open suspend fun moveMultiple(
|
||||
activity: Activity,
|
||||
copy: Boolean,
|
||||
targetDir: String,
|
||||
nameConflictStrategy: NameConflictStrategy,
|
||||
entries: List<AvesEntry>,
|
||||
entriesByTargetDir: Map<String, List<AvesEntry>>,
|
||||
isCancelledOp: CancelCheck,
|
||||
callback: ImageOpCallback,
|
||||
) {
|
||||
|
@ -133,7 +131,6 @@ abstract class ImageProvider {
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
private suspend fun exportSingle(
|
||||
activity: Activity,
|
||||
sourceEntry: AvesEntry,
|
||||
|
@ -174,6 +171,7 @@ abstract class ImageProvider {
|
|||
targetMimeType = sourceMimeType
|
||||
write = { output ->
|
||||
val sourceDocFile = DocumentFileCompat.fromSingleUri(activity, sourceUri)
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
sourceDocFile.copyTo(output)
|
||||
}
|
||||
} else {
|
||||
|
@ -184,7 +182,7 @@ abstract class ImageProvider {
|
|||
} else if (sourceMimeType == MimeTypes.SVG) {
|
||||
SvgImage(activity, sourceUri)
|
||||
} else {
|
||||
StorageUtils.getGlideSafeUri(sourceUri, sourceMimeType)
|
||||
StorageUtils.getGlideSafeUri(activity, sourceUri, sourceMimeType)
|
||||
}
|
||||
|
||||
// request a fresh image with the highest quality format
|
||||
|
@ -198,6 +196,7 @@ abstract class ImageProvider {
|
|||
.apply(glideOptions)
|
||||
.load(model)
|
||||
.submit(width, height)
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
var bitmap = target.get()
|
||||
if (MimeTypes.needRotationAfterGlide(sourceMimeType)) {
|
||||
bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped)
|
||||
|
@ -244,7 +243,6 @@ abstract class ImageProvider {
|
|||
// clearing Glide target should happen after effectively writing the bitmap
|
||||
Glide.with(activity).clear(target)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
|
|
|
@ -3,6 +3,7 @@ package deckers.thibault.aves.model.provider
|
|||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.app.RecoverableSecurityException
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
|
@ -26,24 +27,55 @@ import deckers.thibault.aves.utils.MimeTypes.isImage
|
|||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
import deckers.thibault.aves.utils.StorageUtils.PathSegments
|
||||
import deckers.thibault.aves.utils.StorageUtils.ensureTrailingSeparator
|
||||
import deckers.thibault.aves.utils.StorageUtils.removeTrailingSeparator
|
||||
import deckers.thibault.aves.utils.UriUtils.tryParseId
|
||||
import java.io.File
|
||||
import java.io.OutputStream
|
||||
import java.util.*
|
||||
import java.util.concurrent.CompletableFuture
|
||||
import kotlin.collections.ArrayList
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
class MediaStoreImageProvider : ImageProvider() {
|
||||
fun fetchAll(context: Context, knownEntries: Map<Int, Int?>, handleNewEntry: NewEntryHandler) {
|
||||
fun fetchAll(
|
||||
context: Context,
|
||||
knownEntries: Map<Int?, Int?>,
|
||||
directory: String?,
|
||||
handleNewEntry: NewEntryHandler,
|
||||
) {
|
||||
val isModified = fun(contentId: Int, dateModifiedSecs: Int): Boolean {
|
||||
val knownDate = knownEntries[contentId]
|
||||
return knownDate == null || knownDate < dateModifiedSecs
|
||||
}
|
||||
fetchFrom(context, isModified, handleNewEntry, IMAGE_CONTENT_URI, IMAGE_PROJECTION)
|
||||
fetchFrom(context, isModified, handleNewEntry, VIDEO_CONTENT_URI, VIDEO_PROJECTION)
|
||||
val handleNew: NewEntryHandler
|
||||
var selection: String? = null
|
||||
var selectionArgs: Array<String>? = null
|
||||
if (directory != null) {
|
||||
val relativePathDirectory = ensureTrailingSeparator(directory)
|
||||
val relativePath = PathSegments(context, relativePathDirectory).relativeDir
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && relativePath != null) {
|
||||
selection = "${MediaStore.MediaColumns.RELATIVE_PATH} = ? AND ${MediaColumns.PATH} LIKE ?"
|
||||
selectionArgs = arrayOf(relativePath, "relativePathDirectory%")
|
||||
} else {
|
||||
selection = "${MediaColumns.PATH} LIKE ?"
|
||||
selectionArgs = arrayOf("$relativePathDirectory%")
|
||||
}
|
||||
|
||||
val parentCheckDirectory = removeTrailingSeparator(directory)
|
||||
handleNew = { entry ->
|
||||
// skip entries in subfolders
|
||||
val path = entry["path"] as String?
|
||||
if (path != null && File(path).parent == parentCheckDirectory) {
|
||||
handleNewEntry(entry)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
handleNew = handleNewEntry
|
||||
}
|
||||
fetchFrom(context, isModified, handleNew, IMAGE_CONTENT_URI, IMAGE_PROJECTION, selection, selectionArgs)
|
||||
fetchFrom(context, isModified, handleNew, VIDEO_CONTENT_URI, VIDEO_PROJECTION, selection, selectionArgs)
|
||||
}
|
||||
|
||||
// the provided URI can point to the wrong media collection,
|
||||
|
@ -83,7 +115,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
}
|
||||
}
|
||||
|
||||
fun checkObsoleteContentIds(context: Context, knownContentIds: List<Int>): List<Int> {
|
||||
fun checkObsoleteContentIds(context: Context, knownContentIds: List<Int?>): List<Int> {
|
||||
val foundContentIds = HashSet<Int>()
|
||||
fun check(context: Context, contentUri: Uri) {
|
||||
val projection = arrayOf(MediaStore.MediaColumns._ID)
|
||||
|
@ -102,10 +134,10 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
}
|
||||
check(context, IMAGE_CONTENT_URI)
|
||||
check(context, VIDEO_CONTENT_URI)
|
||||
return knownContentIds.subtract(foundContentIds).toList()
|
||||
return knownContentIds.subtract(foundContentIds).filterNotNull().toList()
|
||||
}
|
||||
|
||||
fun checkObsoletePaths(context: Context, knownPathById: Map<Int, String>): List<Int> {
|
||||
fun checkObsoletePaths(context: Context, knownPathById: Map<Int?, String?>): List<Int> {
|
||||
val obsoleteIds = ArrayList<Int>()
|
||||
fun check(context: Context, contentUri: Uri) {
|
||||
val projection = arrayOf(MediaStore.MediaColumns._ID, MediaColumns.PATH)
|
||||
|
@ -138,12 +170,14 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
handleNewEntry: NewEntryHandler,
|
||||
contentUri: Uri,
|
||||
projection: Array<String>,
|
||||
selection: String? = null,
|
||||
selectionArgs: Array<String>? = null,
|
||||
fileMimeType: String? = null,
|
||||
): Boolean {
|
||||
var found = false
|
||||
val orderBy = "${MediaStore.MediaColumns.DATE_MODIFIED} DESC"
|
||||
try {
|
||||
val cursor = context.contentResolver.query(contentUri, projection, null, null, orderBy)
|
||||
val cursor = context.contentResolver.query(contentUri, projection, selection, selectionArgs, orderBy)
|
||||
if (cursor != null) {
|
||||
val contentUriContainsId = when (contentUri) {
|
||||
IMAGE_CONTENT_URI, VIDEO_CONTENT_URI -> false
|
||||
|
@ -291,6 +325,10 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
}
|
||||
throw Exception("failed to delete document with df=$df")
|
||||
}
|
||||
} else if (uri.scheme?.lowercase(Locale.ROOT) == ContentResolver.SCHEME_FILE) {
|
||||
val uriFilePath = File(uri.path!!).path
|
||||
// URI and path both point to the same non existent path
|
||||
if (uriFilePath == path) return
|
||||
}
|
||||
|
||||
try {
|
||||
|
@ -329,84 +367,119 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
override suspend fun moveMultiple(
|
||||
activity: Activity,
|
||||
copy: Boolean,
|
||||
targetDir: String,
|
||||
nameConflictStrategy: NameConflictStrategy,
|
||||
entries: List<AvesEntry>,
|
||||
entriesByTargetDir: Map<String, List<AvesEntry>>,
|
||||
isCancelledOp: CancelCheck,
|
||||
callback: ImageOpCallback,
|
||||
) {
|
||||
val targetDirDocFile = StorageUtils.createDirectoryDocIfAbsent(activity, targetDir)
|
||||
if (!File(targetDir).exists()) {
|
||||
callback.onFailure(Exception("failed to create directory at path=$targetDir"))
|
||||
return
|
||||
}
|
||||
entriesByTargetDir.forEach { kv ->
|
||||
val targetDir = kv.key
|
||||
val entries = kv.value
|
||||
|
||||
for (entry in entries) {
|
||||
val sourceUri = entry.uri
|
||||
val sourcePath = entry.path
|
||||
val mimeType = entry.mimeType
|
||||
val toBin = targetDir == StorageUtils.TRASH_PATH_PLACEHOLDER
|
||||
|
||||
val result: FieldMap = hashMapOf(
|
||||
"uri" to sourceUri.toString(),
|
||||
"success" to false,
|
||||
)
|
||||
|
||||
if (sourcePath != null) {
|
||||
// on API 30 we cannot get access granted directly to a volume root from its document tree,
|
||||
// but it is still less constraining to use tree document files than to rely on the Media Store
|
||||
//
|
||||
// Relying on `DocumentFile`, we can create an item via `DocumentFile.createFile()`, but:
|
||||
// - we need to scan the file to get the Media Store content URI
|
||||
// - the underlying document provider controls the new file name
|
||||
//
|
||||
// Relying on the Media Store, we can create an item via `ContentResolver.insert()`
|
||||
// with a path, and retrieve its content URI, but:
|
||||
// - the Media Store isolates content by storage volume (e.g. `MediaStore.Images.Media.getContentUri(volumeName)`)
|
||||
// - the volume name should be lower case, not exactly as the `StorageVolume` UUID
|
||||
// cf new method in API 30 `StorageVolume.getMediaStoreVolumeName()`
|
||||
// - inserting on a removable volume works on API 29, but not on API 25 nor 26 (on which API/devices does it work?)
|
||||
// - there is no documentation regarding support for usage with removable storage
|
||||
// - the Media Store only allows inserting in specific primary directories ("DCIM", "Pictures") when using scoped storage
|
||||
try {
|
||||
val newFields = if (isCancelledOp()) skippedFieldMap else moveSingle(
|
||||
activity = activity,
|
||||
sourcePath = sourcePath,
|
||||
sourceUri = sourceUri,
|
||||
targetDir = targetDir,
|
||||
targetDirDocFile = targetDirDocFile,
|
||||
nameConflictStrategy = nameConflictStrategy,
|
||||
mimeType = mimeType,
|
||||
copy = copy,
|
||||
)
|
||||
result["newFields"] = newFields
|
||||
result["success"] = true
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to move to targetDir=$targetDir entry with sourcePath=$sourcePath", e)
|
||||
var effectiveTargetDir: String? = null
|
||||
var targetDirDocFile: DocumentFileCompat? = null
|
||||
if (!toBin) {
|
||||
effectiveTargetDir = targetDir
|
||||
targetDirDocFile = StorageUtils.createDirectoryDocIfAbsent(activity, targetDir)
|
||||
if (!File(targetDir).exists()) {
|
||||
callback.onFailure(Exception("failed to create directory at path=$targetDir"))
|
||||
return
|
||||
}
|
||||
}
|
||||
callback.onSuccess(result)
|
||||
|
||||
for (entry in entries) {
|
||||
val mimeType = entry.mimeType
|
||||
val trashed = entry.trashed
|
||||
|
||||
val sourceUri = if (trashed) Uri.fromFile(File(entry.trashPath!!)) else entry.uri
|
||||
val sourcePath = if (trashed) entry.trashPath else entry.path
|
||||
|
||||
var desiredName: String? = null
|
||||
if (trashed) {
|
||||
entry.path?.let { desiredName = File(it).name }
|
||||
}
|
||||
|
||||
val result: FieldMap = hashMapOf(
|
||||
// `uri` should reference original content URI,
|
||||
// so it is different with `sourceUri` when recycling trashed entries
|
||||
"uri" to entry.uri.toString(),
|
||||
"success" to false,
|
||||
)
|
||||
|
||||
if (sourcePath != null) {
|
||||
// on API 30 we cannot get access granted directly to a volume root from its document tree,
|
||||
// but it is still less constraining to use tree document files than to rely on the Media Store
|
||||
//
|
||||
// Relying on `DocumentFile`, we can create an item via `DocumentFile.createFile()`, but:
|
||||
// - we need to scan the file to get the Media Store content URI
|
||||
// - the underlying document provider controls the new file name
|
||||
//
|
||||
// Relying on the Media Store, we can create an item via `ContentResolver.insert()`
|
||||
// with a path, and retrieve its content URI, but:
|
||||
// - the Media Store isolates content by storage volume (e.g. `MediaStore.Images.Media.getContentUri(volumeName)`)
|
||||
// - the volume name should be lower case, not exactly as the `StorageVolume` UUID
|
||||
// cf new method in API 30 `StorageVolume.getMediaStoreVolumeName()`
|
||||
// - inserting on a removable volume works on API 29, but not on API 25 nor 26 (on which API/devices does it work?)
|
||||
// - there is no documentation regarding support for usage with removable storage
|
||||
// - the Media Store only allows inserting in specific primary directories ("DCIM", "Pictures") when using scoped storage
|
||||
try {
|
||||
if (toBin) {
|
||||
val trashDir = StorageUtils.trashDirFor(activity, sourcePath)
|
||||
if (trashDir != null) {
|
||||
effectiveTargetDir = ensureTrailingSeparator(trashDir.path)
|
||||
targetDirDocFile = DocumentFileCompat.fromFile(trashDir)
|
||||
}
|
||||
}
|
||||
if (effectiveTargetDir != null) {
|
||||
val newFields = if (isCancelledOp()) skippedFieldMap else {
|
||||
val sourceFile = File(sourcePath)
|
||||
moveSingle(
|
||||
activity = activity,
|
||||
sourceFile = sourceFile,
|
||||
sourceUri = sourceUri,
|
||||
targetDir = effectiveTargetDir,
|
||||
targetDirDocFile = targetDirDocFile,
|
||||
desiredName = desiredName ?: sourceFile.name,
|
||||
nameConflictStrategy = nameConflictStrategy,
|
||||
mimeType = mimeType,
|
||||
copy = copy,
|
||||
toBin = toBin,
|
||||
)
|
||||
}
|
||||
result["newFields"] = newFields
|
||||
result["success"] = true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to move to targetDir=$targetDir entry with sourcePath=$sourcePath", e)
|
||||
}
|
||||
}
|
||||
callback.onSuccess(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun moveSingle(
|
||||
activity: Activity,
|
||||
sourcePath: String,
|
||||
sourceFile: File,
|
||||
sourceUri: Uri,
|
||||
targetDir: String,
|
||||
targetDirDocFile: DocumentFileCompat?,
|
||||
desiredName: String,
|
||||
nameConflictStrategy: NameConflictStrategy,
|
||||
mimeType: String,
|
||||
copy: Boolean,
|
||||
toBin: Boolean,
|
||||
): FieldMap {
|
||||
val sourceFile = File(sourcePath)
|
||||
val sourceDir = sourceFile.parent?.let { StorageUtils.ensureTrailingSeparator(it) }
|
||||
val sourcePath = sourceFile.path
|
||||
val sourceDir = sourceFile.parent?.let { ensureTrailingSeparator(it) }
|
||||
if (sourceDir == targetDir && !(copy && nameConflictStrategy == NameConflictStrategy.RENAME)) {
|
||||
// nothing to do unless it's a renamed copy
|
||||
return skippedFieldMap
|
||||
}
|
||||
|
||||
val sourceFileName = sourceFile.name
|
||||
val desiredNameWithoutExtension = sourceFileName.replaceFirst(FILE_EXTENSION_PATTERN, "")
|
||||
val desiredNameWithoutExtension = desiredName.replaceFirst(FILE_EXTENSION_PATTERN, "")
|
||||
val targetNameWithoutExtension = resolveTargetFileNameWithoutExtension(
|
||||
activity = activity,
|
||||
dir = targetDir,
|
||||
|
@ -432,7 +505,12 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
Log.w(LOG_TAG, "failed to delete entry with path=$sourcePath", e)
|
||||
}
|
||||
}
|
||||
|
||||
if (toBin) {
|
||||
return hashMapOf(
|
||||
"trashed" to true,
|
||||
"trashPath" to targetPath,
|
||||
)
|
||||
}
|
||||
return scanNewPath(activity, targetPath, mimeType)
|
||||
}
|
||||
|
||||
|
@ -493,10 +571,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
}
|
||||
|
||||
private fun isDownloadDir(context: Context, dirPath: String): Boolean {
|
||||
var relativeDir = PathSegments(context, dirPath).relativeDir ?: ""
|
||||
if (relativeDir.endsWith(File.separator)) {
|
||||
relativeDir = relativeDir.substring(0, relativeDir.length - 1)
|
||||
}
|
||||
val relativeDir = removeTrailingSeparator(PathSegments(context, dirPath).relativeDir ?: "")
|
||||
return relativeDir == Environment.DIRECTORY_DOWNLOADS
|
||||
}
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@ object MimeTypes {
|
|||
const val ANY = "*/*"
|
||||
|
||||
// generic raster
|
||||
private const val AVIF = "image/avif"
|
||||
const val BMP = "image/bmp"
|
||||
private const val DJVU = "image/vnd.djvu"
|
||||
const val GIF = "image/gif"
|
||||
|
@ -49,7 +50,7 @@ object MimeTypes {
|
|||
private const val AVI_VND = "video/vnd.avi"
|
||||
const val DVD = "video/dvd"
|
||||
private const val MKV = "video/x-matroska"
|
||||
private const val MOV = "video/quicktime"
|
||||
const val MOV = "video/quicktime"
|
||||
private const val MP2T = "video/mp2t"
|
||||
private const val MP2TS = "video/mp2ts"
|
||||
const val MP4 = "video/mp4"
|
||||
|
@ -72,7 +73,7 @@ object MimeTypes {
|
|||
// returns whether the specified MIME type represents
|
||||
// a raster image format that allows an alpha channel
|
||||
fun canHaveAlpha(mimeType: String?) = when (mimeType) {
|
||||
BMP, GIF, ICO, PNG, SVG, TIFF, WEBP -> true
|
||||
AVIF, BMP, GIF, ICO, PNG, SVG, TIFF, WEBP -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
|
@ -150,6 +151,7 @@ object MimeTypes {
|
|||
fun extensionFor(mimeType: String): String? = when (mimeType) {
|
||||
ARW -> ".arw"
|
||||
AVI, AVI_VND -> ".avi"
|
||||
AVIF -> ".avif"
|
||||
BMP -> ".bmp"
|
||||
CR2 -> ".cr2"
|
||||
CRW -> ".crw"
|
||||
|
|
|
@ -18,9 +18,7 @@ import deckers.thibault.aves.PendingStorageAccessResultHandler
|
|||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.utils.StorageUtils.PathSegments
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
import java.util.concurrent.CompletableFuture
|
||||
import kotlin.collections.ArrayList
|
||||
|
||||
object PermissionManager {
|
||||
private val LOG_TAG = LogUtils.createTag<PermissionManager>()
|
||||
|
@ -94,11 +92,12 @@ object PermissionManager {
|
|||
}
|
||||
|
||||
fun getInaccessibleDirectories(context: Context, dirPaths: List<String>): List<Map<String, String>> {
|
||||
val concreteDirPaths = dirPaths.filter { it != StorageUtils.TRASH_PATH_PLACEHOLDER }
|
||||
val accessibleDirs = getAccessibleDirs(context)
|
||||
|
||||
// find set of inaccessible directories for each volume
|
||||
val dirsPerVolume = HashMap<String, MutableSet<String>>()
|
||||
for (dirPath in dirPaths.map { if (it.endsWith(File.separator)) it else it + File.separator }) {
|
||||
for (dirPath in concreteDirPaths.map { if (it.endsWith(File.separator)) it else it + File.separator }) {
|
||||
if (accessibleDirs.none { dirPath.startsWith(it) }) {
|
||||
// inaccessible dirs
|
||||
val segments = PathSegments(context, dirPath)
|
||||
|
@ -211,7 +210,8 @@ object PermissionManager {
|
|||
)
|
||||
})
|
||||
} else if (Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT
|
||||
|| Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT_WATCH) {
|
||||
|| Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT_WATCH
|
||||
) {
|
||||
// removable storage requires access permission, at the file level
|
||||
// without directory access, we consider the whole volume restricted
|
||||
val primaryVolume = StorageUtils.getPrimaryVolumePath(context)
|
||||
|
|
|
@ -33,6 +33,32 @@ object StorageUtils {
|
|||
private const val TREE_URI_ROOT = "content://com.android.externalstorage.documents/tree/"
|
||||
private val TREE_URI_PATH_PATTERN = Pattern.compile("(.*?):(.*)")
|
||||
|
||||
const val TRASH_PATH_PLACEHOLDER = "#trash"
|
||||
|
||||
private fun isAppFile(context: Context, path: String): Boolean {
|
||||
return context.getExternalFilesDirs(null).any { filesDir -> path.startsWith(filesDir.path) }
|
||||
}
|
||||
|
||||
private fun appExternalFilesDirFor(context: Context, path: String): File? {
|
||||
val filesDirs = context.getExternalFilesDirs(null)
|
||||
val volumePath = getVolumePath(context, path)
|
||||
return volumePath?.let { filesDirs.firstOrNull { it.startsWith(volumePath) } } ?: filesDirs.first()
|
||||
}
|
||||
|
||||
fun trashDirFor(context: Context, path: String): File? {
|
||||
val filesDir = appExternalFilesDirFor(context, path)
|
||||
if (filesDir == null) {
|
||||
Log.e(LOG_TAG, "failed to find external files dir for path=$path")
|
||||
return null
|
||||
}
|
||||
val trashDir = File(filesDir, "trash")
|
||||
if (!trashDir.exists() && !trashDir.mkdirs()) {
|
||||
Log.e(LOG_TAG, "failed to create directories at path=$trashDir")
|
||||
return null
|
||||
}
|
||||
return trashDir
|
||||
}
|
||||
|
||||
/**
|
||||
* Volume paths
|
||||
*/
|
||||
|
@ -268,10 +294,7 @@ object StorageUtils {
|
|||
fun convertDirPathToTreeUri(context: Context, dirPath: String): Uri? {
|
||||
val uuid = getVolumeUuidForTreeUri(context, dirPath)
|
||||
if (uuid != null) {
|
||||
var relativeDir = PathSegments(context, dirPath).relativeDir ?: ""
|
||||
if (relativeDir.endsWith(File.separator)) {
|
||||
relativeDir = relativeDir.substring(0, relativeDir.length - 1)
|
||||
}
|
||||
val relativeDir = removeTrailingSeparator(PathSegments(context, dirPath).relativeDir ?: "")
|
||||
return DocumentsContract.buildTreeDocumentUri("com.android.externalstorage.documents", "$uuid:$relativeDir")
|
||||
}
|
||||
Log.e(LOG_TAG, "failed to convert dirPath=$dirPath to tree URI")
|
||||
|
@ -329,7 +352,7 @@ object StorageUtils {
|
|||
|
||||
// try to strip user info, if any
|
||||
if (mediaUri.userInfo != null) {
|
||||
val genericMediaUri = Uri.parse(mediaUri.toString().replaceFirst("${mediaUri.userInfo}@", ""))
|
||||
val genericMediaUri = stripMediaUriUserInfo(mediaUri)
|
||||
Log.d(LOG_TAG, "retry getDocumentFile for mediaUri=$mediaUri without userInfo: $genericMediaUri")
|
||||
return getDocumentFile(context, anyPath, genericMediaUri)
|
||||
}
|
||||
|
@ -408,10 +431,11 @@ object StorageUtils {
|
|||
fun canEditByFile(context: Context, path: String) = !requireAccessPermission(context, path)
|
||||
|
||||
fun requireAccessPermission(context: Context, anyPath: String): Boolean {
|
||||
if (isAppFile(context, anyPath)) return false
|
||||
|
||||
// on Android R, we should always require access permission, even on primary volume
|
||||
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) {
|
||||
return true
|
||||
}
|
||||
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) return true
|
||||
|
||||
val onPrimaryVolume = anyPath.startsWith(getPrimaryVolumePath(context))
|
||||
return !onPrimaryVolume
|
||||
}
|
||||
|
@ -442,36 +466,71 @@ object StorageUtils {
|
|||
// As of Glide v4.12.0, a special loader `QMediaStoreUriLoader` is automatically used
|
||||
// to work around a bug from Android Q where metadata redaction corrupts HEIC images.
|
||||
// This loader relies on `MediaStore.setRequireOriginal` but this yields a `SecurityException`
|
||||
// for some content URIs (e.g. `content://media/external_primary/downloads/...`)
|
||||
// so we build a typical `images` or `videos` content URI from the original content ID.
|
||||
fun getGlideSafeUri(uri: Uri, mimeType: String): Uri = normalizeMediaUri(uri, mimeType)
|
||||
|
||||
// requesting access or writing to some MediaStore content URIs
|
||||
// e.g. `content://0@media/...`, `content://media/external_primary/downloads/...`
|
||||
// yields an exception with `All requested items must be referenced by specific ID`
|
||||
fun getMediaStoreScopedStorageSafeUri(uri: Uri, mimeType: String): Uri = normalizeMediaUri(uri, mimeType)
|
||||
|
||||
private fun normalizeMediaUri(uri: Uri, mimeType: String): Uri {
|
||||
// for some non image/video content URIs (e.g. `downloads`, `file`)
|
||||
fun getGlideSafeUri(context: Context, uri: Uri, mimeType: String): Uri {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isMediaStoreContentUri(uri)) {
|
||||
// we cannot safely apply this to a file content URI, as it may point to a file not indexed
|
||||
// by the Media Store (via `.nomedia`), and therefore has no matching image/video content URI
|
||||
if (uri.path?.contains("/downloads/") == true) {
|
||||
uri.tryParseId()?.let { id ->
|
||||
return when {
|
||||
isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
|
||||
isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
|
||||
else -> uri
|
||||
val uriPath = uri.path
|
||||
when {
|
||||
uriPath?.contains("/downloads/") == true -> {
|
||||
// e.g. `content://media/external_primary/downloads/...`
|
||||
getMediaUriImageVideoUri(uri, mimeType)?.let { imageVideUri -> return imageVideUri }
|
||||
}
|
||||
uriPath?.contains("/file/") == true -> {
|
||||
// e.g. `content://media/external/file/...`
|
||||
// create an ad-hoc temporary file for decoding only
|
||||
File.createTempFile("aves", null).apply {
|
||||
deleteOnExit()
|
||||
try {
|
||||
outputStream().use { output ->
|
||||
openInputStream(context, uri)?.use { input ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
return Uri.fromFile(this)
|
||||
} catch (e: Exception) {
|
||||
Log.e(LOG_TAG, "failed to create temporary file from uri=$uri", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (uri.userInfo != null) {
|
||||
// strip user info, if any
|
||||
return Uri.parse(uri.toString().replaceFirst("${uri.userInfo}@", ""))
|
||||
uri.userInfo != null -> return stripMediaUriUserInfo(uri)
|
||||
}
|
||||
|
||||
}
|
||||
return uri
|
||||
}
|
||||
|
||||
// requesting access or writing to some MediaStore content URIs
|
||||
// yields an exception with `All requested items must be referenced by specific ID`
|
||||
fun getMediaStoreScopedStorageSafeUri(uri: Uri, mimeType: String): Uri {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isMediaStoreContentUri(uri)) {
|
||||
val uriPath = uri.path
|
||||
when {
|
||||
uriPath?.contains("/downloads/") == true -> {
|
||||
// e.g. `content://media/external_primary/downloads/...`
|
||||
getMediaUriImageVideoUri(uri, mimeType)?.let { imageVideUri -> return imageVideUri }
|
||||
}
|
||||
uri.userInfo != null -> return stripMediaUriUserInfo(uri)
|
||||
}
|
||||
}
|
||||
return uri
|
||||
}
|
||||
|
||||
// Build a typical `images` or `videos` content URI from the original content ID.
|
||||
// We cannot safely apply this to a `file` content URI, as it may point to a file not indexed
|
||||
// by the Media Store (via `.nomedia`), and therefore has no matching image/video content URI.
|
||||
private fun getMediaUriImageVideoUri(uri: Uri, mimeType: String): Uri? {
|
||||
return uri.tryParseId()?.let { id ->
|
||||
return when {
|
||||
isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
|
||||
isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
|
||||
else -> uri
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// strip user info, if any
|
||||
// e.g. `content://0@media/...`
|
||||
private fun stripMediaUriUserInfo(uri: Uri) = Uri.parse(uri.toString().replaceFirst("${uri.userInfo}@", ""))
|
||||
|
||||
fun openInputStream(context: Context, uri: Uri): InputStream? {
|
||||
val effectiveUri = getOriginalUri(context, uri)
|
||||
return try {
|
||||
|
@ -517,6 +576,10 @@ object StorageUtils {
|
|||
return if (dirPath.endsWith(File.separator)) dirPath else dirPath + File.separator
|
||||
}
|
||||
|
||||
fun removeTrailingSeparator(dirPath: String): String {
|
||||
return if (dirPath.endsWith(File.separator)) dirPath.substring(0, dirPath.length - 1) else dirPath
|
||||
}
|
||||
|
||||
// `fullPath` should match "volumePath + relativeDir + fileName"
|
||||
class PathSegments(context: Context, fullPath: String) {
|
||||
var volumePath: String? = null // `volumePath` with trailing "/"
|
||||
|
|
10
android/app/src/main/res/values-id/strings.xml
Normal file
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Aves</string>
|
||||
<string name="search_shortcut_short_label">Cari</string>
|
||||
<string name="videos_shortcut_short_label">Video</string>
|
||||
<string name="analysis_channel_name">Pindai media</string>
|
||||
<string name="analysis_service_description">Pindai gambar & video</string>
|
||||
<string name="analysis_notification_default_title">Memindai media</string>
|
||||
<string name="analysis_notification_action_stop">Berhenti</string>
|
||||
</resources>
|
|
@ -6,7 +6,7 @@ buildscript {
|
|||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:7.1.0'
|
||||
classpath 'com.android.tools.build:gradle:7.1.1'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
// GMS & Firebase Crashlytics are not actually used by all flavors
|
||||
classpath 'com.google.gms:google-services:4.3.10'
|
||||
|
|
5
fastlane/metadata/android/en-US/changelogs/1066.txt
Normal file
|
@ -0,0 +1,5 @@
|
|||
In v1.6.0:
|
||||
- recycle bin
|
||||
- view small and large images at their actual size
|
||||
- enjoy the app in Indonesian
|
||||
Full changelog available on GitHub
|
5
fastlane/metadata/android/id/full_description.txt
Normal file
|
@ -0,0 +1,5 @@
|
|||
<i>Aves</i> dapat menangani semua jenis gambar dan video, termasuk JPEG dan MP4, tetapi juga hal-hal yang lebih eksotis seperti <b>TIFF halaman-multi, SVG, AVI lama, dan lainnya</b>! Ini memindai koleksi media Anda untuk mengidentifikasi <b>foto bergerak</b>, <b>panorama</b> (foto 360), <b>video 360°</b>, dan file <b>GeoTIFF</b>.
|
||||
|
||||
<b>Navigasi dan pencarian</b> merupakan bagian penting dari <i>Aves</i>. Tujuannya adalah agar pengguna dengan mudah mengalir dari album ke foto ke tag ke peta, dll.
|
||||
|
||||
<i>Aves</i> terintegrasi dengan Android (dari <b>API 19 ke 32</b>, yaitu dari KitKat ke Android 12L) dengan fitur-fitur seperti <b>pintasan aplikasi</b> dan <b>pencarian global</b> penanganan. Ini juga berfungsi sebagai <b>penampil dan pemilih media</b>.
|
BIN
fastlane/metadata/android/id/images/featureGraphic.png
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
fastlane/metadata/android/id/images/phoneScreenshots/1.png
Normal file
After Width: | Height: | Size: 274 KiB |
BIN
fastlane/metadata/android/id/images/phoneScreenshots/2.png
Normal file
After Width: | Height: | Size: 500 KiB |
BIN
fastlane/metadata/android/id/images/phoneScreenshots/3.png
Normal file
After Width: | Height: | Size: 207 KiB |
BIN
fastlane/metadata/android/id/images/phoneScreenshots/4.png
Normal file
After Width: | Height: | Size: 87 KiB |
BIN
fastlane/metadata/android/id/images/phoneScreenshots/5.png
Normal file
After Width: | Height: | Size: 78 KiB |
BIN
fastlane/metadata/android/id/images/phoneScreenshots/6.png
Normal file
After Width: | Height: | Size: 370 KiB |
1
fastlane/metadata/android/id/short_description.txt
Normal file
|
@ -0,0 +1 @@
|
|||
Galeri dan penjelajah metadata
|
|
@ -1,11 +1,11 @@
|
|||
enum AppMode { main, pickExternal, pickInternal, view }
|
||||
enum AppMode { main, pickMediaExternal, pickMediaInternal, pickFilterInternal, view }
|
||||
|
||||
extension ExtraAppMode on AppMode {
|
||||
bool get canSearch => this == AppMode.main || this == AppMode.pickExternal;
|
||||
bool get canSearch => this == AppMode.main || this == AppMode.pickMediaExternal;
|
||||
|
||||
bool get canSelect => this == AppMode.main;
|
||||
|
||||
bool get hasDrawer => this == AppMode.main || this == AppMode.pickExternal;
|
||||
bool get hasDrawer => this == AppMode.main || this == AppMode.pickMediaExternal;
|
||||
|
||||
bool get isPicking => this == AppMode.pickExternal || this == AppMode.pickInternal;
|
||||
bool get isPickingMedia => this == AppMode.pickMediaExternal || this == AppMode.pickMediaInternal;
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
"timeSeconds": "{seconds, plural, =1{1 Sekunde} other{{seconds} Sekunde}}",
|
||||
"timeMinutes": "{minutes, plural, =1{1 Minute} other{{minutes} Minuten}}",
|
||||
"timeDays": "{days, plural, =1{1 Tag} other{{days} Tage}}",
|
||||
"focalLength": "{length} mm",
|
||||
|
||||
"applyButtonLabel": "ANWENDEN",
|
||||
|
@ -27,6 +28,7 @@
|
|||
"resetButtonTooltip": "Zurücksetzen",
|
||||
|
||||
"doubleBackExitMessage": "Zum Verlassen erneut auf „Zurück“ tippen.",
|
||||
"doNotAskAgain": "Nicht noch einmal fragen",
|
||||
|
||||
"sourceStateLoading": "Laden",
|
||||
"sourceStateCataloguing": "Katalogisierung",
|
||||
|
@ -47,8 +49,8 @@
|
|||
"entryActionCopyToClipboard": "In die Zwischenablage kopieren",
|
||||
"entryActionDelete": "Löschen",
|
||||
"entryActionExport": "Exportieren",
|
||||
"entryActionInfo": "Info",
|
||||
"entryActionRename": "Umbenennen",
|
||||
"entryActionRestore": "Wiederherstellen",
|
||||
"entryActionRotateCCW": "Drehen gegen den Uhrzeigersinn",
|
||||
"entryActionRotateCW": "Drehen im Uhrzeigersinn",
|
||||
"entryActionFlip": "Horizontal spiegeln",
|
||||
|
@ -56,10 +58,10 @@
|
|||
"entryActionShare": "Teilen",
|
||||
"entryActionViewSource": "Quelle anzeigen",
|
||||
"entryActionViewMotionPhotoVideo": "Bewegtes Foto öffnen",
|
||||
"entryActionEdit": "Bearbeiten mit...",
|
||||
"entryActionOpen": "Öffnen mit...",
|
||||
"entryActionSetAs": "Einstellen als...",
|
||||
"entryActionOpenMap": "In der Karten-App anzeigen...",
|
||||
"entryActionEdit": "Bearbeiten",
|
||||
"entryActionOpen": "Öffnen mit",
|
||||
"entryActionSetAs": "Einstellen als",
|
||||
"entryActionOpenMap": "In der Karten-App anzeigen",
|
||||
"entryActionRotateScreen": "Bildschirm rotieren",
|
||||
"entryActionAddFavourite": "Zu Favoriten hinzufügen ",
|
||||
"entryActionRemoveFavourite": "Aus Favoriten entfernen",
|
||||
|
@ -79,6 +81,7 @@
|
|||
"entryInfoActionEditTags": "Tags bearbeiten",
|
||||
"entryInfoActionRemoveMetadata": "Metadaten entfernen",
|
||||
|
||||
"filterBinLabel": "Papierkorb",
|
||||
"filterFavouriteLabel": "Favorit",
|
||||
"filterLocationEmptyLabel": "Ungeortet",
|
||||
"filterTagEmptyLabel": "Unmarkiert",
|
||||
|
@ -157,6 +160,7 @@
|
|||
"noMatchingAppDialogTitle": "Keine passende App",
|
||||
"noMatchingAppDialogMessage": "Es gibt keine Anwendungen, die dies bewältigen können.",
|
||||
|
||||
"binEntriesConfirmationDialogMessage": "{count, plural, =1{Dieses Element in den Papierkorb verschieben?} other{Diese {count} Elemente in den Papierkorb verschieben?}}",
|
||||
"deleteEntriesConfirmationDialogMessage": "{count, plural, =1{Sicher, dass dieses Element gelöscht werden soll?} other{Sicher, dass diese {count} Elemente gelöscht werden sollen?}}",
|
||||
|
||||
"videoResumeDialogMessage": "Soll bei {time} weiter abspielt werden?",
|
||||
|
@ -267,11 +271,12 @@
|
|||
|
||||
"collectionPageTitle": "Sammlung",
|
||||
"collectionPickPageTitle": "Wähle",
|
||||
"collectionSelectionPageTitle": "{count, plural, =0{Elemente auswählen} =1{1 Element} other{{count} Elemente}}",
|
||||
"collectionSelectPageTitle": "Elemente auswählen",
|
||||
|
||||
"collectionActionShowTitleSearch": "Titelfilter anzeigen",
|
||||
"collectionActionHideTitleSearch": "Titelfilter ausblenden",
|
||||
"collectionActionAddShortcut": "Verknüpfung hinzufügen",
|
||||
"collectionActionEmptyBin": "Papierkorb leeren",
|
||||
"collectionActionCopy": "In Album kopieren",
|
||||
"collectionActionMove": "Zum Album verschieben",
|
||||
"collectionActionRescan": "Neu scannen",
|
||||
|
@ -350,6 +355,8 @@
|
|||
"tagPageTitle": "Tags",
|
||||
"tagEmpty": "Keine Tags",
|
||||
|
||||
"binPageTitle": "Papierkorb",
|
||||
|
||||
"searchCollectionFieldHint": "Sammlung durchsuchen",
|
||||
"searchSectionRecent": "Neueste",
|
||||
"searchSectionAlbums": "Alben",
|
||||
|
@ -375,6 +382,11 @@
|
|||
"settingsKeepScreenOnTitle": "Bildschirm eingeschaltet lassen",
|
||||
"settingsDoubleBackExit": "Zum Verlassen zweimal „zurück“ tippen",
|
||||
|
||||
"settingsConfirmationDialogTile": "Bestätigungsdialoge",
|
||||
"settingsConfirmationDialogTitle": "Bestätigungsdialoge",
|
||||
"settingsConfirmationDialogDeleteItems": "Vor dem endgültigen Löschen von Elementen fragen",
|
||||
"settingsConfirmationDialogMoveToBinItems": "Vor dem Verschieben von Elementen in den Papierkorb fragen",
|
||||
|
||||
"settingsNavigationDrawerTile": "Menü Navigation",
|
||||
"settingsNavigationDrawerEditorTitle": "Menü Navigation",
|
||||
"settingsNavigationDrawerBanner": "Die Taste berühren und halten, um Menüpunkte zu verschieben und neu anzuordnen.",
|
||||
|
@ -450,6 +462,8 @@
|
|||
"settingsAllowInstalledAppAccessSubtitle": "zur Gruppierung von Bildern nach Apps",
|
||||
"settingsAllowErrorReporting": "Anonyme Fehlermeldungen zulassen",
|
||||
"settingsSaveSearchHistory": "Suchverlauf speichern",
|
||||
"settingsEnableBin": "Papierkorb verwenden",
|
||||
"settingsEnableBinSubtitle": "Gelöschte Elemente 30 Tage lang aufbewahren",
|
||||
|
||||
"settingsHiddenItemsTile": "Versteckte Elemente",
|
||||
"settingsHiddenItemsTitle": "Versteckte Elemente",
|
||||
|
|
|
@ -22,6 +22,12 @@
|
|||
"minutes": {}
|
||||
}
|
||||
},
|
||||
"timeDays": "{days, plural, =1{1 day} other{{days} days}}",
|
||||
"@timeDays": {
|
||||
"placeholders": {
|
||||
"days": {}
|
||||
}
|
||||
},
|
||||
"focalLength": "{length} mm",
|
||||
"@focalLength": {
|
||||
"placeholders": {
|
||||
|
@ -50,6 +56,7 @@
|
|||
"resetButtonTooltip": "Reset",
|
||||
|
||||
"doubleBackExitMessage": "Tap “back” again to exit.",
|
||||
"doNotAskAgain": "Do not ask again",
|
||||
|
||||
"sourceStateLoading": "Loading",
|
||||
"sourceStateCataloguing": "Cataloguing",
|
||||
|
@ -69,9 +76,10 @@
|
|||
|
||||
"entryActionCopyToClipboard": "Copy to clipboard",
|
||||
"entryActionDelete": "Delete",
|
||||
"entryActionConvert": "Convert",
|
||||
"entryActionExport": "Export",
|
||||
"entryActionInfo": "Info",
|
||||
"entryActionRename": "Rename",
|
||||
"entryActionRestore": "Restore",
|
||||
"entryActionRotateCCW": "Rotate counterclockwise",
|
||||
"entryActionRotateCW": "Rotate clockwise",
|
||||
"entryActionFlip": "Flip horizontally",
|
||||
|
@ -79,10 +87,10 @@
|
|||
"entryActionShare": "Share",
|
||||
"entryActionViewSource": "View source",
|
||||
"entryActionViewMotionPhotoVideo": "Open Motion Photo",
|
||||
"entryActionEdit": "Edit with…",
|
||||
"entryActionOpen": "Open with…",
|
||||
"entryActionSetAs": "Set as…",
|
||||
"entryActionOpenMap": "Show in map app…",
|
||||
"entryActionEdit": "Edit",
|
||||
"entryActionOpen": "Open with",
|
||||
"entryActionSetAs": "Set as",
|
||||
"entryActionOpenMap": "Show in map app",
|
||||
"entryActionRotateScreen": "Rotate screen",
|
||||
"entryActionAddFavourite": "Add to favourites",
|
||||
"entryActionRemoveFavourite": "Remove from favourites",
|
||||
|
@ -102,6 +110,7 @@
|
|||
"entryInfoActionEditTags": "Edit tags",
|
||||
"entryInfoActionRemoveMetadata": "Remove metadata",
|
||||
|
||||
"filterBinLabel": "Recycle bin",
|
||||
"filterFavouriteLabel": "Favourite",
|
||||
"filterLocationEmptyLabel": "Unlocated",
|
||||
"filterTagEmptyLabel": "Untagged",
|
||||
|
@ -254,7 +263,13 @@
|
|||
"noMatchingAppDialogTitle": "No Matching App",
|
||||
"noMatchingAppDialogMessage": "There are no apps that can handle this.",
|
||||
|
||||
"deleteEntriesConfirmationDialogMessage": "{count, plural, =1{Are you sure you want to delete this item?} other{Are you sure you want to delete these {count} items?}}",
|
||||
"binEntriesConfirmationDialogMessage": "{count, plural, =1{Move this item to the recycle bin?} other{Move these {count} items to the recycle bin?}}",
|
||||
"@binEntriesConfirmationDialogMessage": {
|
||||
"placeholders": {
|
||||
"count": {}
|
||||
}
|
||||
},
|
||||
"deleteEntriesConfirmationDialogMessage": "{count, plural, =1{Delete this item?} other{Delete these {count} items?}}",
|
||||
"@deleteEntriesConfirmationDialogMessage": {
|
||||
"placeholders": {
|
||||
"count": {}
|
||||
|
@ -287,13 +302,13 @@
|
|||
"renameAlbumDialogLabel": "New name",
|
||||
"renameAlbumDialogLabelAlreadyExistsHelper": "Directory already exists",
|
||||
|
||||
"deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{Are you sure you want to delete this album and its item?} other{Are you sure you want to delete this album and its {count} items?}}",
|
||||
"deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{Delete this album and its item?} other{Delete this album and its {count} items?}}",
|
||||
"@deleteSingleAlbumConfirmationDialogMessage": {
|
||||
"placeholders": {
|
||||
"count": {}
|
||||
}
|
||||
},
|
||||
"deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{Are you sure you want to delete these albums and their item?} other{Are you sure you want to delete these albums and their {count} items?}}",
|
||||
"deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{Delete these albums and their item?} other{Delete these albums and their {count} items?}}",
|
||||
"@deleteMultiAlbumConfirmationDialogMessage": {
|
||||
"placeholders": {
|
||||
"count": {}
|
||||
|
@ -399,16 +414,12 @@
|
|||
|
||||
"collectionPageTitle": "Collection",
|
||||
"collectionPickPageTitle": "Pick",
|
||||
"collectionSelectionPageTitle": "{count, plural, =0{Select items} =1{1 item} other{{count} items}}",
|
||||
"@collectionSelectionPageTitle": {
|
||||
"placeholders": {
|
||||
"count": {}
|
||||
}
|
||||
},
|
||||
"collectionSelectPageTitle": "Select items",
|
||||
|
||||
"collectionActionShowTitleSearch": "Show title filter",
|
||||
"collectionActionHideTitleSearch": "Hide title filter",
|
||||
"collectionActionAddShortcut": "Add shortcut",
|
||||
"collectionActionEmptyBin": "Empty bin",
|
||||
"collectionActionCopy": "Copy to album",
|
||||
"collectionActionMove": "Move to album",
|
||||
"collectionActionRescan": "Rescan",
|
||||
|
@ -527,6 +538,8 @@
|
|||
"tagPageTitle": "Tags",
|
||||
"tagEmpty": "No tags",
|
||||
|
||||
"binPageTitle": "Recycle Bin",
|
||||
|
||||
"searchCollectionFieldHint": "Search collection",
|
||||
"searchSectionRecent": "Recent",
|
||||
"searchSectionAlbums": "Albums",
|
||||
|
@ -552,6 +565,11 @@
|
|||
"settingsKeepScreenOnTitle": "Keep Screen On",
|
||||
"settingsDoubleBackExit": "Tap “back” twice to exit",
|
||||
|
||||
"settingsConfirmationDialogTile": "Confirmation dialogs",
|
||||
"settingsConfirmationDialogTitle": "Confirmation Dialogs",
|
||||
"settingsConfirmationDialogDeleteItems": "Ask before deleting items forever",
|
||||
"settingsConfirmationDialogMoveToBinItems": "Ask before moving items to the recycle bin",
|
||||
|
||||
"settingsNavigationDrawerTile": "Navigation menu",
|
||||
"settingsNavigationDrawerEditorTitle": "Navigation Menu",
|
||||
"settingsNavigationDrawerBanner": "Touch and hold to move and reorder menu items.",
|
||||
|
@ -627,6 +645,8 @@
|
|||
"settingsAllowInstalledAppAccessSubtitle": "Used to improve album display",
|
||||
"settingsAllowErrorReporting": "Allow anonymous error reporting",
|
||||
"settingsSaveSearchHistory": "Save search history",
|
||||
"settingsEnableBin": "Use recycle bin",
|
||||
"settingsEnableBinSubtitle": "Keep deleted items for 30 days",
|
||||
|
||||
"settingsHiddenItemsTile": "Hidden items",
|
||||
"settingsHiddenItemsTitle": "Hidden Items",
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
"timeSeconds": "{seconds, plural, =1{1 segundo} other{{seconds} segundos}}",
|
||||
"timeMinutes": "{minutes, plural, =1{1 minuto} other{{minutes} minutos}}",
|
||||
"timeDays": "{days, plural, =1{1 día} other{{days} días}}",
|
||||
"focalLength": "{length} mm",
|
||||
|
||||
"applyButtonLabel": "APLICAR",
|
||||
|
@ -27,6 +28,7 @@
|
|||
"resetButtonTooltip": "Restablecer",
|
||||
|
||||
"doubleBackExitMessage": "Presione «atrás» nuevamente para salir.",
|
||||
"doNotAskAgain": "No preguntar nuevamente",
|
||||
|
||||
"sourceStateLoading": "Cargando",
|
||||
"sourceStateCataloguing": "Catalogando",
|
||||
|
@ -46,9 +48,10 @@
|
|||
|
||||
"entryActionCopyToClipboard": "Copiar al portapapeles",
|
||||
"entryActionDelete": "Borrar",
|
||||
"entryActionConvert": "Convertir",
|
||||
"entryActionExport": "Exportar",
|
||||
"entryActionInfo": "Información",
|
||||
"entryActionRename": "Renombrar",
|
||||
"entryActionRestore": "Restaurar",
|
||||
"entryActionRotateCCW": "Rotar en sentido antihorario",
|
||||
"entryActionRotateCW": "Rotar en sentido horario",
|
||||
"entryActionFlip": "Voltear horizontalmente",
|
||||
|
@ -56,10 +59,10 @@
|
|||
"entryActionShare": "Compartir",
|
||||
"entryActionViewSource": "Ver fuente",
|
||||
"entryActionViewMotionPhotoVideo": "Abrir foto en movimiento",
|
||||
"entryActionEdit": "Editar con…",
|
||||
"entryActionOpen": "Abrir con…",
|
||||
"entryActionSetAs": "Establecer como…",
|
||||
"entryActionOpenMap": "Mostrar en aplicación de mapa…",
|
||||
"entryActionEdit": "Editar",
|
||||
"entryActionOpen": "Abrir con",
|
||||
"entryActionSetAs": "Establecer como",
|
||||
"entryActionOpenMap": "Mostrar en aplicación de mapa",
|
||||
"entryActionRotateScreen": "Rotar pantalla",
|
||||
"entryActionAddFavourite": "Agregar a favoritos",
|
||||
"entryActionRemoveFavourite": "Quitar de favoritos",
|
||||
|
@ -74,10 +77,12 @@
|
|||
"videoActionSettings": "Ajustes",
|
||||
|
||||
"entryInfoActionEditDate": "Editar fecha y hora",
|
||||
"entryInfoActionEditLocation": "Editar ubicación",
|
||||
"entryInfoActionEditRating": "Editar clasificación",
|
||||
"entryInfoActionEditTags": "Editar etiquetas",
|
||||
"entryInfoActionRemoveMetadata": "Eliminar metadatos",
|
||||
|
||||
"filterBinLabel": "Cesto de basura",
|
||||
"filterFavouriteLabel": "Favorito",
|
||||
"filterLocationEmptyLabel": "No localizado",
|
||||
"filterTagEmptyLabel": "Sin etiquetar",
|
||||
|
@ -157,6 +162,8 @@
|
|||
"noMatchingAppDialogTitle": "Sin aplicación compatible",
|
||||
"noMatchingAppDialogMessage": "No se encontraron aplicaciones para manejar esto.",
|
||||
|
||||
"binEntriesConfirmationDialogMessage": "{count, plural, =1{¿Mover este elemento al cesto de basura?} other{¿Mover estos {count} elementos al cesto de basura?}}",
|
||||
|
||||
"deleteEntriesConfirmationDialogMessage": "{count, plural, =1{¿Está seguro de borrar este elemento?} other{¿Está seguro de querer borrar {count} elementos?}}",
|
||||
|
||||
"videoResumeDialogMessage": "¿Desea reanudar la reproducción a las {time}?",
|
||||
|
@ -181,6 +188,8 @@
|
|||
"deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{¿Está seguro de que desea borrar estos álbumes y un elemento?} other{¿Está seguro de que desea borrar estos álbumes y sus {count} elementos?}}",
|
||||
|
||||
"exportEntryDialogFormat": "Formato:",
|
||||
"exportEntryDialogWidth": "Anchura",
|
||||
"exportEntryDialogHeight": "Altura",
|
||||
|
||||
"renameEntryDialogLabel": "Renombrar",
|
||||
|
||||
|
@ -194,6 +203,13 @@
|
|||
"editEntryDateDialogHours": "Horas",
|
||||
"editEntryDateDialogMinutes": "Minutos",
|
||||
|
||||
"editEntryLocationDialogTitle": "Ubicación",
|
||||
"editEntryLocationDialogChooseOnMapTooltip": "Elegir en el mapa",
|
||||
"editEntryLocationDialogLatitude": "Latitud",
|
||||
"editEntryLocationDialogLongitude": "Longitud",
|
||||
|
||||
"locationPickerUseThisLocationButton": "Usar esta ubicación",
|
||||
|
||||
"editEntryRatingDialogTitle": "Clasificación",
|
||||
|
||||
"removeEntryMetadataDialogTitle": "Eliminación de metadatos",
|
||||
|
@ -258,11 +274,12 @@
|
|||
|
||||
"collectionPageTitle": "Colección",
|
||||
"collectionPickPageTitle": "Elegir",
|
||||
"collectionSelectionPageTitle": "{count, plural, =0{Seleccionar} =1{1 elemento} other{{count} elementos}}",
|
||||
"collectionSelectPageTitle": "Seleccionar",
|
||||
|
||||
"collectionActionShowTitleSearch": "Mostrar filtros de títulos",
|
||||
"collectionActionHideTitleSearch": "Ocultar filtros de títulos",
|
||||
"collectionActionAddShortcut": "Agregar atajo",
|
||||
"collectionActionEmptyBin": "Vaciar cesto",
|
||||
"collectionActionCopy": "Copiar a álbum",
|
||||
"collectionActionMove": "Mover a álbum",
|
||||
"collectionActionRescan": "Volver a buscar",
|
||||
|
@ -341,6 +358,8 @@
|
|||
"tagPageTitle": "Etiquetas",
|
||||
"tagEmpty": "Sin etiquetas",
|
||||
|
||||
"binPageTitle": "Cesto de basura",
|
||||
|
||||
"searchCollectionFieldHint": "Buscar en colección",
|
||||
"searchSectionRecent": "Reciente",
|
||||
"searchSectionAlbums": "Álbumes",
|
||||
|
@ -366,6 +385,11 @@
|
|||
"settingsKeepScreenOnTitle": "Mantener pantalla encendida",
|
||||
"settingsDoubleBackExit": "Presione «atrás» dos veces para salir",
|
||||
|
||||
"settingsConfirmationDialogTile": "Diálogos de confirmación",
|
||||
"settingsConfirmationDialogTitle": "Diálogos de confirmación",
|
||||
"settingsConfirmationDialogDeleteItems": "Preguntar antes de eliminar elementos permanentemente",
|
||||
"settingsConfirmationDialogMoveToBinItems": "Preguntar antes de mover elementos al cesto de basura",
|
||||
|
||||
"settingsNavigationDrawerTile": "Menú de navegación",
|
||||
"settingsNavigationDrawerEditorTitle": "Menú de navegación",
|
||||
"settingsNavigationDrawerBanner": "Toque y mantenga para mover y reordenar elementos del menú.",
|
||||
|
@ -441,6 +465,8 @@
|
|||
"settingsAllowInstalledAppAccessSubtitle": "Usado para mejorar los álbumes mostrados",
|
||||
"settingsAllowErrorReporting": "Permitir reporte de errores anónimo",
|
||||
"settingsSaveSearchHistory": "Guardar historial de búsqueda",
|
||||
"settingsEnableBin": "Usar cesto de basura",
|
||||
"settingsEnableBinSubtitle": "Guardar los elementos eliminados por 30 días",
|
||||
|
||||
"settingsHiddenItemsTile": "Elementos ocultos",
|
||||
"settingsHiddenItemsTitle": "Elementos ocultos",
|
||||
|
|
|
@ -5,8 +5,9 @@
|
|||
"welcomeTermsToggle": "J’accepte les conditions d’utilisation",
|
||||
"itemCount": "{count, plural, =1{1 élément} other{{count} éléments}}",
|
||||
|
||||
"timeSeconds": "{seconds, plural, =1{1 seconde} other{{seconds} secondes}}",
|
||||
"timeMinutes": "{minutes, plural, =1{1 minute} other{{minutes} minutes}}",
|
||||
"timeSeconds": "{seconds, plural, =0{0 seconde} =1{1 seconde} other{{seconds} secondes}}",
|
||||
"timeMinutes": "{minutes, plural, =0{0 minute} =1{1 minute} other{{minutes} minutes}}",
|
||||
"timeDays": "{days, plural, =0{0 jour} =1{1 jour} other{{days} jours}}",
|
||||
"focalLength": "{length} mm",
|
||||
|
||||
"applyButtonLabel": "ENREGISTRER",
|
||||
|
@ -27,6 +28,7 @@
|
|||
"resetButtonTooltip": "Réinitialiser",
|
||||
|
||||
"doubleBackExitMessage": "Pressez «\u00A0retour\u00A0» à nouveau pour quitter.",
|
||||
"doNotAskAgain": "Ne pas demander de nouveau",
|
||||
|
||||
"sourceStateLoading": "Chargement",
|
||||
"sourceStateCataloguing": "Classification",
|
||||
|
@ -46,9 +48,10 @@
|
|||
|
||||
"entryActionCopyToClipboard": "Copier dans presse-papier",
|
||||
"entryActionDelete": "Supprimer",
|
||||
"entryActionConvert": "Convertir",
|
||||
"entryActionExport": "Exporter",
|
||||
"entryActionInfo": "Détails",
|
||||
"entryActionRename": "Renommer",
|
||||
"entryActionRestore": "Restaurer",
|
||||
"entryActionRotateCCW": "Pivoter à gauche",
|
||||
"entryActionRotateCW": "Pivoter à droite",
|
||||
"entryActionFlip": "Retourner horizontalement",
|
||||
|
@ -56,10 +59,10 @@
|
|||
"entryActionShare": "Partager",
|
||||
"entryActionViewSource": "Voir le code",
|
||||
"entryActionViewMotionPhotoVideo": "Ouvrir le clip vidéo",
|
||||
"entryActionEdit": "Modifier avec…",
|
||||
"entryActionOpen": "Ouvrir avec…",
|
||||
"entryActionSetAs": "Utiliser comme…",
|
||||
"entryActionOpenMap": "Localiser avec…",
|
||||
"entryActionEdit": "Modifier",
|
||||
"entryActionOpen": "Ouvrir avec",
|
||||
"entryActionSetAs": "Utiliser comme",
|
||||
"entryActionOpenMap": "Localiser avec",
|
||||
"entryActionRotateScreen": "Pivoter l’écran",
|
||||
"entryActionAddFavourite": "Ajouter aux favoris",
|
||||
"entryActionRemoveFavourite": "Retirer des favoris",
|
||||
|
@ -79,6 +82,7 @@
|
|||
"entryInfoActionEditTags": "Modifier les libellés",
|
||||
"entryInfoActionRemoveMetadata": "Retirer les métadonnées",
|
||||
|
||||
"filterBinLabel": "Corbeille",
|
||||
"filterFavouriteLabel": "Favori",
|
||||
"filterLocationEmptyLabel": "Sans lieu",
|
||||
"filterTagEmptyLabel": "Sans libellé",
|
||||
|
@ -157,7 +161,8 @@
|
|||
"noMatchingAppDialogTitle": "App indisponible",
|
||||
"noMatchingAppDialogMessage": "Aucune app ne peut effectuer cette opération.",
|
||||
|
||||
"deleteEntriesConfirmationDialogMessage": "{count, plural, =1{Voulez-vous vraiment supprimer cet élément ?} other{Voulez-vous vraiment supprimer ces {count} éléments ?}}",
|
||||
"binEntriesConfirmationDialogMessage": "{count, plural, =1{Mettre cet élément à la corbeille ?} other{Mettre ces {count} éléments à la corbeille ?}}",
|
||||
"deleteEntriesConfirmationDialogMessage": "{count, plural, =1{Supprimer cet élément ?} other{Supprimer ces {count} éléments ?}}",
|
||||
|
||||
"videoResumeDialogMessage": "Voulez-vous reprendre la lecture à {time} ?",
|
||||
"videoStartOverButtonLabel": "RECOMMENCER",
|
||||
|
@ -177,8 +182,8 @@
|
|||
"renameAlbumDialogLabel": "Nouveau nom",
|
||||
"renameAlbumDialogLabelAlreadyExistsHelper": "Le dossier existe déjà",
|
||||
|
||||
"deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{Voulez-vous vraiment supprimer cet album et son élément ?} other{Voulez-vous vraiment supprimer cet album et ses {count} éléments ?}}",
|
||||
"deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{Voulez-vous vraiment supprimer ces albums et leur élément ?} other{Voulez-vous vraiment supprimer ces albums et leurs {count} éléments ?}}",
|
||||
"deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{Supprimer cet album et son élément ?} other{Supprimer cet album et ses {count} éléments ?}}",
|
||||
"deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{Supprimer ces albums et leur élément ?} other{Supprimer ces albums et leurs {count} éléments ?}}",
|
||||
|
||||
"exportEntryDialogFormat": "Format :",
|
||||
"exportEntryDialogWidth": "Largeur",
|
||||
|
@ -188,7 +193,7 @@
|
|||
|
||||
"editEntryDateDialogTitle": "Date & Heure",
|
||||
"editEntryDateDialogSetCustom": "Régler une date personnalisée",
|
||||
"editEntryDateDialogCopyField": "Copier d'une autre date",
|
||||
"editEntryDateDialogCopyField": "Copier d’une autre date",
|
||||
"editEntryDateDialogExtractFromTitle": "Extraire du titre",
|
||||
"editEntryDateDialogShift": "Décaler",
|
||||
"editEntryDateDialogSourceFileModifiedDate": "Date de modification du fichier",
|
||||
|
@ -267,11 +272,12 @@
|
|||
|
||||
"collectionPageTitle": "Collection",
|
||||
"collectionPickPageTitle": "Sélection",
|
||||
"collectionSelectionPageTitle": "{count, plural, =0{Sélection} =1{1 élément} other{{count} éléments}}",
|
||||
"collectionSelectPageTitle": "Sélection",
|
||||
|
||||
"collectionActionShowTitleSearch": "Filtrer les titres",
|
||||
"collectionActionHideTitleSearch": "Masquer le filtre",
|
||||
"collectionActionAddShortcut": "Créer un raccourci",
|
||||
"collectionActionEmptyBin": "Vider la corbeille",
|
||||
"collectionActionCopy": "Copier vers l’album",
|
||||
"collectionActionMove": "Déplacer vers l’album",
|
||||
"collectionActionRescan": "Réanalyser",
|
||||
|
@ -350,6 +356,8 @@
|
|||
"tagPageTitle": "Libellés",
|
||||
"tagEmpty": "Aucun libellé",
|
||||
|
||||
"binPageTitle": "Corbeille",
|
||||
|
||||
"searchCollectionFieldHint": "Recherche",
|
||||
"searchSectionRecent": "Historique",
|
||||
"searchSectionAlbums": "Albums",
|
||||
|
@ -375,6 +383,11 @@
|
|||
"settingsKeepScreenOnTitle": "Allumage de l’écran",
|
||||
"settingsDoubleBackExit": "Presser «\u00A0retour\u00A0» 2 fois pour quitter",
|
||||
|
||||
"settingsConfirmationDialogTile": "Demandes de confirmation",
|
||||
"settingsConfirmationDialogTitle": "Demandes de confirmation",
|
||||
"settingsConfirmationDialogDeleteItems": "Suppression définitive d’éléments",
|
||||
"settingsConfirmationDialogMoveToBinItems": "Mise d’éléments à la corbeille",
|
||||
|
||||
"settingsNavigationDrawerTile": "Menu de navigation",
|
||||
"settingsNavigationDrawerEditorTitle": "Menu de navigation",
|
||||
"settingsNavigationDrawerBanner": "Maintenez votre doigt appuyé pour déplacer et réorganiser les éléments de menu.",
|
||||
|
@ -450,6 +463,8 @@
|
|||
"settingsAllowInstalledAppAccessSubtitle": "Pour un affichage amélioré des albums",
|
||||
"settingsAllowErrorReporting": "Autoriser l’envoi de rapports d’erreur",
|
||||
"settingsSaveSearchHistory": "Maintenir un historique des recherches",
|
||||
"settingsEnableBin": "Utiliser la corbeille",
|
||||
"settingsEnableBinSubtitle": "Conserver les éléments supprimés pendant 30 jours",
|
||||
|
||||
"settingsHiddenItemsTile": "Éléments masqués",
|
||||
"settingsHiddenItemsTitle": "Éléments masqués",
|
||||
|
|
560
lib/l10n/app_id.arb
Normal file
|
@ -0,0 +1,560 @@
|
|||
{
|
||||
"appName": "Aves",
|
||||
"welcomeMessage": "Selamat datang ke Aves",
|
||||
"welcomeOptional": "Opsional",
|
||||
"welcomeTermsToggle": "Saya menyetujui syarat dan ketentuan",
|
||||
"itemCount": "{count, plural, other{{count} benda}}",
|
||||
|
||||
"timeSeconds": "{seconds, plural, other{{seconds} detik}}",
|
||||
"timeMinutes": "{minutes, plural, other{{minutes} menit}}",
|
||||
"timeDays": "{days, plural, other{{days} hari}}",
|
||||
"focalLength": "{length} mm",
|
||||
|
||||
"applyButtonLabel": "TERAPKAN",
|
||||
"deleteButtonLabel": "BUANG",
|
||||
"nextButtonLabel": "SELANJUTNYA",
|
||||
"showButtonLabel": "TAMPILKAN",
|
||||
"hideButtonLabel": "SEMBUNYIKAN",
|
||||
"continueButtonLabel": "SELANJUTNYA",
|
||||
|
||||
"cancelTooltip": "Batalkan",
|
||||
"changeTooltip": "Ganti",
|
||||
"clearTooltip": "Hapus",
|
||||
"previousTooltip": "Sebelumnya",
|
||||
"nextTooltip": "Selanjutnya",
|
||||
"showTooltip": "Tampilkan",
|
||||
"hideTooltip": "Sembunyikan",
|
||||
"actionRemove": "Hapus",
|
||||
"resetButtonTooltip": "Ulang",
|
||||
|
||||
"doubleBackExitMessage": "Ketuk “kembali” lagi untuk keluar.",
|
||||
"doNotAskAgain": "Jangan tanya lagi",
|
||||
|
||||
"sourceStateLoading": "Memuat",
|
||||
"sourceStateCataloguing": "Mengkatalog",
|
||||
"sourceStateLocatingCountries": "Mencari negara",
|
||||
"sourceStateLocatingPlaces": "Mencari tempat",
|
||||
|
||||
"chipActionDelete": "Hapus",
|
||||
"chipActionGoToAlbumPage": "Tampilkan di Album",
|
||||
"chipActionGoToCountryPage": "Tampilkan di Negara",
|
||||
"chipActionGoToTagPage": "Tampilkan di Tag",
|
||||
"chipActionHide": "Sembunyikan",
|
||||
"chipActionPin": "Sematkan ke atas",
|
||||
"chipActionUnpin": "Lepas sematan dari atas",
|
||||
"chipActionRename": "Ganti nama",
|
||||
"chipActionSetCover": "Setel sampul",
|
||||
"chipActionCreateAlbum": "Membuat album",
|
||||
|
||||
"entryActionCopyToClipboard": "Salinan ke papan",
|
||||
"entryActionDelete": "Hapus",
|
||||
"entryActionConvert": "Ubah",
|
||||
"entryActionExport": "Ekspor",
|
||||
"entryActionRename": "Ganti nama",
|
||||
"entryActionRestore": "Pulihkan",
|
||||
"entryActionRotateCCW": "Putar berlawanan arah jarum jam",
|
||||
"entryActionRotateCW": "Putar searah jarum jam",
|
||||
"entryActionFlip": "Balik secara horisontal",
|
||||
"entryActionPrint": "Cetak",
|
||||
"entryActionShare": "Bagikan",
|
||||
"entryActionViewSource": "Lihat sumber",
|
||||
"entryActionViewMotionPhotoVideo": "Buka Foto bergerak",
|
||||
"entryActionEdit": "Ubah",
|
||||
"entryActionOpen": "Buka dengan",
|
||||
"entryActionSetAs": "Tetapkan sebagai",
|
||||
"entryActionOpenMap": "Tampilkan di peta",
|
||||
"entryActionRotateScreen": "Putar layar",
|
||||
"entryActionAddFavourite": "Tambahkan ke favorit",
|
||||
"entryActionRemoveFavourite": "Hapus dari favorit",
|
||||
|
||||
"videoActionCaptureFrame": "Tangkap bingkai",
|
||||
"videoActionPause": "Henti",
|
||||
"videoActionPlay": "Mainkan",
|
||||
"videoActionReplay10": "Mundur 10 detik",
|
||||
"videoActionSkip10": "Majukan 10 detik",
|
||||
"videoActionSelectStreams": "Pilih trek",
|
||||
"videoActionSetSpeed": "Kecepatan pemutaran",
|
||||
"videoActionSettings": "Pengaturan",
|
||||
|
||||
"entryInfoActionEditDate": "Ubah tanggal & waktu",
|
||||
"entryInfoActionEditLocation": "Ubah lokasi",
|
||||
"entryInfoActionEditRating": "Ubah peringkat",
|
||||
"entryInfoActionEditTags": "Ubah tag",
|
||||
"entryInfoActionRemoveMetadata": "Hapus metadata",
|
||||
|
||||
"filterBinLabel": "Tong sampah",
|
||||
"filterFavouriteLabel": "Favorit",
|
||||
"filterLocationEmptyLabel": "Lokasi Tidak ditemukan",
|
||||
"filterTagEmptyLabel": "Tidak ditag",
|
||||
"filterRatingUnratedLabel": "Belum diberi peringkat",
|
||||
"filterRatingRejectedLabel": "Ditolak",
|
||||
"filterTypeAnimatedLabel": "Teranimasi",
|
||||
"filterTypeMotionPhotoLabel": "Foto bergerak",
|
||||
"filterTypePanoramaLabel": "Panorama",
|
||||
"filterTypeRawLabel": "Raw",
|
||||
"filterTypeSphericalVideoLabel": "Video 360°",
|
||||
"filterTypeGeotiffLabel": "GeoTIFF",
|
||||
"filterMimeImageLabel": "Gambar",
|
||||
"filterMimeVideoLabel": "Video",
|
||||
|
||||
"coordinateFormatDms": "DMS",
|
||||
"coordinateFormatDecimal": "Derajat desimal",
|
||||
"coordinateDms": "{coordinate} {direction}",
|
||||
"coordinateDmsNorth": "N",
|
||||
"coordinateDmsSouth": "S",
|
||||
"coordinateDmsEast": "E",
|
||||
"coordinateDmsWest": "W",
|
||||
|
||||
"unitSystemMetric": "Metrik",
|
||||
"unitSystemImperial": "Imperial",
|
||||
|
||||
"videoLoopModeNever": "Tidak pernah",
|
||||
"videoLoopModeShortOnly": "Hanya video pendek",
|
||||
"videoLoopModeAlways": "Selalu",
|
||||
|
||||
"mapStyleGoogleNormal": "Google Maps",
|
||||
"mapStyleGoogleHybrid": "Google Maps (Hybrid)",
|
||||
"mapStyleGoogleTerrain": "Google Maps (Terrain)",
|
||||
"mapStyleOsmHot": "Humanitarian OSM",
|
||||
"mapStyleStamenToner": "Stamen Toner",
|
||||
"mapStyleStamenWatercolor": "Stamen Watercolor",
|
||||
|
||||
"nameConflictStrategyRename": "Ganti nama",
|
||||
"nameConflictStrategyReplace": "Ganti",
|
||||
"nameConflictStrategySkip": "Lewati",
|
||||
|
||||
"keepScreenOnNever": "Tidak pernah",
|
||||
"keepScreenOnViewerOnly": "Hanya halaman pemirsa",
|
||||
"keepScreenOnAlways": "Selalu",
|
||||
|
||||
"accessibilityAnimationsRemove": "Mencegah efek layar",
|
||||
"accessibilityAnimationsKeep": "Simpan efek layar",
|
||||
|
||||
"albumTierNew": "Baru",
|
||||
"albumTierPinned": "Disemat",
|
||||
"albumTierSpecial": "Umum",
|
||||
"albumTierApps": "Aplikasi",
|
||||
"albumTierRegular": "Lainnya",
|
||||
|
||||
"storageVolumeDescriptionFallbackPrimary": "Penyimpanan internal",
|
||||
"storageVolumeDescriptionFallbackNonPrimary": "kartu SD",
|
||||
"rootDirectoryDescription": "direktori root",
|
||||
"otherDirectoryDescription": "“{name}” direktori",
|
||||
"storageAccessDialogTitle": "Akses Penyimpanan",
|
||||
"storageAccessDialogMessage": "Silahkan pilih {directory} dari “{volume}” di layar berikutnya untuk memberikan akses aplikasi ini ke sana.",
|
||||
"restrictedAccessDialogTitle": "Akses Terbatas",
|
||||
"restrictedAccessDialogMessage": "Aplikasi ini tidak diizinkan untuk mengubah file di {directory} dari “{volume}”.\n\nSilahkan pakai aplikasi Manager File atau aplikasi gallery untuk gerakkan benda ke direktori lain.",
|
||||
"notEnoughSpaceDialogTitle": "Tidak Cukup Ruang",
|
||||
"notEnoughSpaceDialogMessage": "Operasi ini memerlukan {neededSize} ruang kosong di “{volume}” untuk menyelesaikan, tetapi hanya ada {freeSize} tersisa.",
|
||||
"missingSystemFilePickerDialogTitle": "Pemilih File Sistem Tidak Ada",
|
||||
"missingSystemFilePickerDialogMessage": "Pemilih file sistem tidak ada atau dinonaktifkan. Harap aktifkan dan coba lagi.",
|
||||
|
||||
"unsupportedTypeDialogTitle": "Jenis Yang Tidak Didukung",
|
||||
"unsupportedTypeDialogMessage": "{count, plural, other{Operasi ini tidak didukung untuk benda dari jenis berikut: {types}.}}",
|
||||
|
||||
"nameConflictDialogSingleSourceMessage": "Beberapa file di folder tujuan memiliki nama yang sama.",
|
||||
"nameConflictDialogMultipleSourceMessage": "Beberapa file memiliki nama yang sama.",
|
||||
|
||||
"addShortcutDialogLabel": "Label pintasan",
|
||||
"addShortcutButtonLabel": "TAMBAH",
|
||||
|
||||
"noMatchingAppDialogTitle": "Tidak Ada Aplikasi Yang Cocok",
|
||||
"noMatchingAppDialogMessage": "Tidak ada aplikasi yang cocok untuk menangani ini.",
|
||||
|
||||
"binEntriesConfirmationDialogMessage": "{count, plural, =1{Pindah benda ini ke tong sampah?} other{Pindah {count} benda ke tempat sampah?}}",
|
||||
"deleteEntriesConfirmationDialogMessage": "{count, plural, =1{Anda yakin ingin menghapus benda ini?} other{Apakah Anda yakin ingin menghapus {count} benda?}}",
|
||||
|
||||
"videoResumeDialogMessage": "Apakah Anda ingin melanjutkan di {time}?",
|
||||
"videoStartOverButtonLabel": "ULANG DARI AWAL",
|
||||
"videoResumeButtonLabel": "LANJUT",
|
||||
|
||||
"setCoverDialogTitle": "Setel Sampul",
|
||||
"setCoverDialogLatest": "Benda terbaru",
|
||||
"setCoverDialogCustom": "Kustom",
|
||||
|
||||
"hideFilterConfirmationDialogMessage": "Foto dan video yang cocok akan disembunyikan dari koleksi Anda. Anda dapat menampilkannya lagi dari pengaturan “Privasi”.\n\nApakah Anda yakin ingin menyembunyikannya?",
|
||||
|
||||
"newAlbumDialogTitle": "Album Baru",
|
||||
"newAlbumDialogNameLabel": "Nama album",
|
||||
"newAlbumDialogNameLabelAlreadyExistsHelper": "Direktori sudah ada",
|
||||
"newAlbumDialogStorageLabel": "Penyimpanan:",
|
||||
|
||||
"renameAlbumDialogLabel": "Nama baru",
|
||||
"renameAlbumDialogLabelAlreadyExistsHelper": "Direktori sudah ada",
|
||||
|
||||
"deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{Anda yakin ingin menghapus album ini dan bendanya?} other{Apakah Anda yakin ingin menghapus album ini dan {count} bendanya?}}",
|
||||
"deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{Yakin ingin menghapus album ini dan bendanya?} other{Anda yakin ingin menghapus album ini dan {count} bendanya?}}",
|
||||
|
||||
"exportEntryDialogFormat": "Format:",
|
||||
"exportEntryDialogWidth": "Lebar",
|
||||
"exportEntryDialogHeight": "Tinggi",
|
||||
|
||||
"renameEntryDialogLabel": "Nama baru",
|
||||
|
||||
"editEntryDateDialogTitle": "Tanggal & Waktu",
|
||||
"editEntryDateDialogSetCustom": "Atur tanggal khusus",
|
||||
"editEntryDateDialogCopyField": "Salin dari tanggal lain",
|
||||
"editEntryDateDialogExtractFromTitle": "Ekstrak dari judul",
|
||||
"editEntryDateDialogShift": "Geser",
|
||||
"editEntryDateDialogSourceFileModifiedDate": "Tanggal modifikasi file",
|
||||
"editEntryDateDialogTargetFieldsHeader": "Bidang untuk dimodifikasikan",
|
||||
"editEntryDateDialogHours": "Jam",
|
||||
"editEntryDateDialogMinutes": "Menit",
|
||||
|
||||
"editEntryLocationDialogTitle": "Lokasi",
|
||||
"editEntryLocationDialogChooseOnMapTooltip": "Pilih di peta",
|
||||
"editEntryLocationDialogLatitude": "Garis lintang",
|
||||
"editEntryLocationDialogLongitude": "Garis bujur",
|
||||
|
||||
"locationPickerUseThisLocationButton": "Gunakan lokasi ini",
|
||||
|
||||
"editEntryRatingDialogTitle": "Peringkat",
|
||||
|
||||
"removeEntryMetadataDialogTitle": "Penghapusan Metadata",
|
||||
"removeEntryMetadataDialogMore": "Lebih Banyak",
|
||||
|
||||
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP diperlukan untuk memutar video di dalam Foto bergerak.\n\nAnda yakin ingin menghapusnya?",
|
||||
|
||||
"videoSpeedDialogLabel": "Kecepatan pemutaran",
|
||||
|
||||
"videoStreamSelectionDialogVideo": "Video",
|
||||
"videoStreamSelectionDialogAudio": "Audio",
|
||||
"videoStreamSelectionDialogText": "Subtitle",
|
||||
"videoStreamSelectionDialogOff": "Mati",
|
||||
"videoStreamSelectionDialogTrack": "Trek",
|
||||
"videoStreamSelectionDialogNoSelection": "Tidak ada Trek yang lain.",
|
||||
|
||||
"genericSuccessFeedback": "Sukses!",
|
||||
"genericFailureFeedback": "Gagal",
|
||||
|
||||
"menuActionConfigureView": "Lihat",
|
||||
"menuActionSelect": "Pilih",
|
||||
"menuActionSelectAll": "Pilih semua",
|
||||
"menuActionSelectNone": "Pilih tidak ada",
|
||||
"menuActionMap": "Peta",
|
||||
"menuActionStats": "Statistik",
|
||||
|
||||
"viewDialogTabSort": "Sortir",
|
||||
"viewDialogTabGroup": "Grup",
|
||||
"viewDialogTabLayout": "Tata letak",
|
||||
|
||||
"tileLayoutGrid": "Grid",
|
||||
"tileLayoutList": "Daftar",
|
||||
|
||||
"aboutPageTitle": "Tentang",
|
||||
"aboutLinkSources": "Sumber",
|
||||
"aboutLinkLicense": "Lisensi",
|
||||
"aboutLinkPolicy": "Aturan Privasi",
|
||||
|
||||
"aboutBug": "Lapor Bug",
|
||||
"aboutBugSaveLogInstruction": "Simpan log aplikasi ke file",
|
||||
"aboutBugSaveLogButton": "Simpan",
|
||||
"aboutBugCopyInfoInstruction": "Salin informasi sistem",
|
||||
"aboutBugCopyInfoButton": "Salin",
|
||||
"aboutBugReportInstruction": "Laporkan ke GitHub dengan log dan informasi sistem",
|
||||
"aboutBugReportButton": "Rapor",
|
||||
|
||||
"aboutCredits": "Kredit",
|
||||
"aboutCreditsWorldAtlas1": "Aplikasi ini menggunakan file TopoJSON dari",
|
||||
"aboutCreditsWorldAtlas2": "dibawah Lisensi ISC.",
|
||||
"aboutCreditsTranslators": "Penerjemah:",
|
||||
"aboutCreditsTranslatorLine": "{language}: {names}",
|
||||
|
||||
"aboutLicenses": "Lisensi Sumber Terbuka",
|
||||
"aboutLicensesBanner": "Aplikasi ini menggunakan paket dan pustaka sumber terbuka berikut.",
|
||||
"aboutLicensesAndroidLibraries": "Perpustakaan Android",
|
||||
"aboutLicensesFlutterPlugins": "Plugin Flutter",
|
||||
"aboutLicensesFlutterPackages": "Paket Flutter",
|
||||
"aboutLicensesDartPackages": "Paket Dart",
|
||||
"aboutLicensesShowAllButtonLabel": "Tampilkan Semua Lisensi",
|
||||
|
||||
"policyPageTitle": "Aturan Privasi",
|
||||
|
||||
"collectionPageTitle": "Koleksi",
|
||||
"collectionPickPageTitle": "Pilih",
|
||||
"collectionSelectPageTitle": "Pilih benda",
|
||||
|
||||
"collectionActionShowTitleSearch": "Tampilkan filter judul",
|
||||
"collectionActionHideTitleSearch": "Sembunyikan filter judul",
|
||||
"collectionActionAddShortcut": "Tambahkan pintasan",
|
||||
"collectionActionEmptyBin": "Kosongkan tong sampah",
|
||||
"collectionActionCopy": "Salin ke album",
|
||||
"collectionActionMove": "Pindah ke album",
|
||||
"collectionActionRescan": "Pindai ulang",
|
||||
"collectionActionEdit": "Ubah",
|
||||
|
||||
"collectionSearchTitlesHintText": "Cari judul",
|
||||
|
||||
"collectionSortDate": "Lewat tanggal",
|
||||
"collectionSortSize": "Lewat ukuran",
|
||||
"collectionSortName": "Lewat nama album & file",
|
||||
"collectionSortRating": "Lewat peringkat",
|
||||
|
||||
"collectionGroupAlbum": "Lewat album",
|
||||
"collectionGroupMonth": "Lewat bulan",
|
||||
"collectionGroupDay": "Lewat hari",
|
||||
"collectionGroupNone": "Jangan kelompokkan",
|
||||
|
||||
"sectionUnknown": "Tidak dikenal",
|
||||
"dateToday": "Hari ini",
|
||||
"dateYesterday": "Kemaren",
|
||||
"dateThisMonth": "Bulan ini",
|
||||
"collectionDeleteFailureFeedback": "{count, plural, other{Gagal untuk menghapus {count} benda}}",
|
||||
"collectionCopyFailureFeedback": "{count, plural, other{Gagal untuk menyalin {count} benda}}",
|
||||
"collectionMoveFailureFeedback": "{count, plural, other{Gagal untuk menggerakkan {count} benda}}",
|
||||
"collectionEditFailureFeedback": "{count, plural, other{Gagal untuk mengubah {count} benda}}",
|
||||
"collectionExportFailureFeedback": "{count, plural, other{Gagal untuk mengekspor {count} halaman}}",
|
||||
"collectionCopySuccessFeedback": "{count, plural, other{Menyalin {count} benda}}",
|
||||
"collectionMoveSuccessFeedback": "{count, plural, other{Menggerakkan {count} benda}}",
|
||||
"collectionEditSuccessFeedback": "{count, plural, other{Mengubah {count} benda}}",
|
||||
|
||||
"collectionEmptyFavourites": "Tidak ada favorit",
|
||||
"collectionEmptyVideos": "Tidak ada video",
|
||||
"collectionEmptyImages": "Tidak ada gambar",
|
||||
|
||||
"collectionSelectSectionTooltip": "Pilih bagian",
|
||||
"collectionDeselectSectionTooltip": "Batalkan pilihan bagian",
|
||||
|
||||
"drawerCollectionAll": "Semua koleksi",
|
||||
"drawerCollectionFavourites": "Favorit",
|
||||
"drawerCollectionImages": "Gambar",
|
||||
"drawerCollectionVideos": "Video",
|
||||
"drawerCollectionAnimated": "Teranimasi",
|
||||
"drawerCollectionMotionPhotos": "Foto bergerak",
|
||||
"drawerCollectionPanoramas": "Panorama",
|
||||
"drawerCollectionRaws": "Foto Raw",
|
||||
"drawerCollectionSphericalVideos": "Video 360°",
|
||||
|
||||
"chipSortDate": "Lewat tanggal",
|
||||
"chipSortName": "Lewat nama",
|
||||
"chipSortCount": "Lewat jumlah benda",
|
||||
|
||||
"albumGroupTier": "Lewat tingkat",
|
||||
"albumGroupVolume": "Lewat volume penyimpanan",
|
||||
"albumGroupNone": "Jangan kelompokkan",
|
||||
|
||||
"albumPickPageTitleCopy": "Salin ke Album",
|
||||
"albumPickPageTitleExport": "Ekspor ke Album",
|
||||
"albumPickPageTitleMove": "Pindah ke Album",
|
||||
"albumPickPageTitlePick": "Pilih Album",
|
||||
|
||||
"albumCamera": "Kamera",
|
||||
"albumDownload": "Download",
|
||||
"albumScreenshots": "Tangkapan layar",
|
||||
"albumScreenRecordings": "Rekaman layar",
|
||||
"albumVideoCaptures": "Tangkapan Video",
|
||||
|
||||
"albumPageTitle": "Album",
|
||||
"albumEmpty": "Tidak ada album",
|
||||
"createAlbumTooltip": "Buat album",
|
||||
"createAlbumButtonLabel": "BUAT",
|
||||
"newFilterBanner": "baru",
|
||||
|
||||
"countryPageTitle": "Negara",
|
||||
"countryEmpty": "Tidak ada negara",
|
||||
|
||||
"tagPageTitle": "Tag",
|
||||
"tagEmpty": "Tidak ada tag",
|
||||
|
||||
"binPageTitle": "Tong Sampah",
|
||||
|
||||
"searchCollectionFieldHint": "Cari koleksi",
|
||||
"searchSectionRecent": "Terkini",
|
||||
"searchSectionAlbums": "Album",
|
||||
"searchSectionCountries": "Negara",
|
||||
"searchSectionPlaces": "Tempat",
|
||||
"searchSectionTags": "Tag",
|
||||
"searchSectionRating": "Peringkat",
|
||||
|
||||
"settingsPageTitle": "Pengaturan",
|
||||
"settingsSystemDefault": "Sistem",
|
||||
"settingsDefault": "Default",
|
||||
|
||||
"settingsActionExport": "Ekspor",
|
||||
"settingsActionImport": "Impor",
|
||||
|
||||
"appExportCovers": "Sampul",
|
||||
"appExportFavourites": "Favorit",
|
||||
"appExportSettings": "Pengaturan",
|
||||
|
||||
"settingsSectionNavigation": "Navigasi",
|
||||
"settingsHome": "Beranda",
|
||||
"settingsKeepScreenOnTile": "Biarkan layarnya menyala",
|
||||
"settingsKeepScreenOnTitle": "Biarkan Layarnya Menyala",
|
||||
"settingsDoubleBackExit": "Ketuk “kembali” dua kali untuk keluar",
|
||||
|
||||
"settingsConfirmationDialogTile": "Dialog konfirmasi",
|
||||
"settingsConfirmationDialogTitle": "Dialog Konfirmasi",
|
||||
"settingsConfirmationDialogDeleteItems": "Tanya sebelum menghapus benda selamanya",
|
||||
"settingsConfirmationDialogMoveToBinItems": "Tanya sebelum memindahkan benda ke tempat sampah",
|
||||
"settingsNavigationDrawerTile": "Menu navigasi",
|
||||
"settingsNavigationDrawerEditorTitle": "Menu Navigasi",
|
||||
"settingsNavigationDrawerBanner": "Sentuh dan tahan untuk memindahkan dan menyusun ulang benda menu.",
|
||||
"settingsNavigationDrawerTabTypes": "Tipe",
|
||||
"settingsNavigationDrawerTabAlbums": "Album",
|
||||
"settingsNavigationDrawerTabPages": "Halaman",
|
||||
"settingsNavigationDrawerAddAlbum": "Tambahkan album",
|
||||
|
||||
"settingsSectionThumbnails": "Thumbnail",
|
||||
"settingsThumbnailShowFavouriteIcon": "Tampilkan ikon favorit",
|
||||
"settingsThumbnailShowLocationIcon": "Tampilkan ikon lokasi",
|
||||
"settingsThumbnailShowMotionPhotoIcon": "Tampilkan ikon Foto bergerak",
|
||||
"settingsThumbnailShowRating": "Tampilkan peringkat",
|
||||
"settingsThumbnailShowRawIcon": "Tampilkan ikon raw",
|
||||
"settingsThumbnailShowVideoDuration": "Tampilkan durasi video",
|
||||
|
||||
"settingsCollectionQuickActionsTile": "Aksi cepat",
|
||||
"settingsCollectionQuickActionEditorTitle": "Aksi Cepat",
|
||||
"settingsCollectionQuickActionTabBrowsing": "Menjelajah",
|
||||
"settingsCollectionQuickActionTabSelecting": "Memilih",
|
||||
"settingsCollectionBrowsingQuickActionEditorBanner": "Sentuh dan tahan untuk memindahkan tombol dan memilih tindakan yang ditampilkan saat menelusuri benda.",
|
||||
"settingsCollectionSelectionQuickActionEditorBanner": "Sentuh dan tahan untuk memindahkan tombol dan memilih tindakan yang ditampilkan saat memilih benda.",
|
||||
|
||||
"settingsSectionViewer": "Penonton",
|
||||
"settingsViewerUseCutout": "Gunakan area potongan",
|
||||
"settingsViewerMaximumBrightness": "Kecerahan maksimum",
|
||||
"settingsMotionPhotoAutoPlay": "Putar foto bergerak otomatis",
|
||||
"settingsImageBackground": "Latar belakang gambar",
|
||||
|
||||
"settingsViewerQuickActionsTile": "Aksi cepat",
|
||||
"settingsViewerQuickActionEditorTitle": "Aksi Cepat",
|
||||
"settingsViewerQuickActionEditorBanner": "Sentuh dan tahan untuk memindahkan tombol dan memilih tindakan yang ditampilkan di penampil.",
|
||||
"settingsViewerQuickActionEditorDisplayedButtons": "Tombol yang Ditampilkan",
|
||||
"settingsViewerQuickActionEditorAvailableButtons": "Tombol yang tersedia",
|
||||
"settingsViewerQuickActionEmpty": "Tidak ada tombol",
|
||||
|
||||
"settingsViewerOverlayTile": "Hamparan",
|
||||
"settingsViewerOverlayTitle": "Hamparan",
|
||||
"settingsViewerShowOverlayOnOpening": "Tampilkan saat pembukaan",
|
||||
"settingsViewerShowMinimap": "Tampilkan minimap",
|
||||
"settingsViewerShowInformation": "Tampilkan informasi",
|
||||
"settingsViewerShowInformationSubtitle": "Tampilkan judul, tanggal, lokasi, dll.",
|
||||
"settingsViewerShowShootingDetails": "Tampilkan detail pemotretan",
|
||||
"settingsViewerEnableOverlayBlurEffect": "Efek Kabur",
|
||||
|
||||
"settingsVideoPageTitle": "Pengaturan Video",
|
||||
"settingsSectionVideo": "Video",
|
||||
"settingsVideoShowVideos": "Tampilkan video",
|
||||
"settingsVideoEnableHardwareAcceleration": "Akselerasi perangkat keras",
|
||||
"settingsVideoEnableAutoPlay": "Putar otomatis",
|
||||
"settingsVideoLoopModeTile": "Putar ulang",
|
||||
"settingsVideoLoopModeTitle": "Putar Ulang",
|
||||
"settingsVideoQuickActionsTile": "Aksi cepat untuk video",
|
||||
"settingsVideoQuickActionEditorTitle": "Aksi Cepat",
|
||||
|
||||
"settingsSubtitleThemeTile": "Subtitle",
|
||||
"settingsSubtitleThemeTitle": "Subtitle",
|
||||
"settingsSubtitleThemeSample": "Ini adalah sampel.",
|
||||
"settingsSubtitleThemeTextAlignmentTile": "Perataan teks",
|
||||
"settingsSubtitleThemeTextAlignmentTitle": "Perataan Teks",
|
||||
"settingsSubtitleThemeTextSize": "Ukuran teks",
|
||||
"settingsSubtitleThemeShowOutline": "Tampilkan garis besar dan bayangan",
|
||||
"settingsSubtitleThemeTextColor": "Warna teks",
|
||||
"settingsSubtitleThemeTextOpacity": "Opasitas teks",
|
||||
"settingsSubtitleThemeBackgroundColor": "Warna latar belakang",
|
||||
"settingsSubtitleThemeBackgroundOpacity": "Opasitas latar belakang",
|
||||
"settingsSubtitleThemeTextAlignmentLeft": "Kiri",
|
||||
"settingsSubtitleThemeTextAlignmentCenter": "Tengah",
|
||||
"settingsSubtitleThemeTextAlignmentRight": "Kanan",
|
||||
|
||||
"settingsSectionPrivacy": "Privasi",
|
||||
"settingsAllowInstalledAppAccess": "Izinkan akses ke inventori aplikasi",
|
||||
"settingsAllowInstalledAppAccessSubtitle": "Digunakan untuk meningkatkan tampilan album",
|
||||
"settingsAllowErrorReporting": "Izinkan pelaporan kesalahan anonim",
|
||||
"settingsSaveSearchHistory": "Simpan riwayat pencarian",
|
||||
"settingsEnableBin": "Gunakan tong sampah",
|
||||
"settingsEnableBinSubtitle": "Simpan benda yang dihapus selama 30 hari",
|
||||
|
||||
"settingsHiddenItemsTile": "Benda tersembunyi",
|
||||
"settingsHiddenItemsTitle": "Benda Tersembunyi",
|
||||
|
||||
"settingsHiddenFiltersTitle": "Filter Tersembunyi",
|
||||
"settingsHiddenFiltersBanner": "Foto dan video filter tersembunyi yang cocok tidak akan muncul di koleksi Anda.",
|
||||
"settingsHiddenFiltersEmpty": "Tidak ada filter tersembunyi",
|
||||
|
||||
"settingsHiddenPathsTitle": "Jalan Tersembunyi",
|
||||
"settingsHiddenPathsBanner": "Foto dan video di folder ini, atau subfoldernya, tidak akan muncul di koleksi Anda.",
|
||||
"addPathTooltip": "Tambahkan jalan",
|
||||
|
||||
"settingsStorageAccessTile": "Akses penyimpanan",
|
||||
"settingsStorageAccessTitle": "Akses Penyimpanan",
|
||||
"settingsStorageAccessBanner": "Beberapa direktori memerlukan pemberian akses eksplisit untuk memodifikasi file di dalamnya. Anda dapat meninjau di sini direktori yang sebelumnya Anda beri akses.",
|
||||
"settingsStorageAccessEmpty": "Tidak ada akses",
|
||||
"settingsStorageAccessRevokeTooltip": "Tarik kembali",
|
||||
|
||||
"settingsSectionAccessibility": "Aksesibilitas",
|
||||
"settingsRemoveAnimationsTile": "Hapus animasi",
|
||||
"settingsRemoveAnimationsTitle": "Hapus Animasi",
|
||||
"settingsTimeToTakeActionTile": "Saatnya untuk mengambil tindakan",
|
||||
"settingsTimeToTakeActionTitle": "Saatnya Bertindak",
|
||||
|
||||
"settingsSectionLanguage": "Bahasa & Format",
|
||||
"settingsLanguage": "Bahasa",
|
||||
"settingsCoordinateFormatTile": "Format koordinat",
|
||||
"settingsCoordinateFormatTitle": "Format Koordinat",
|
||||
"settingsUnitSystemTile": "Unit",
|
||||
"settingsUnitSystemTitle": "Unit",
|
||||
|
||||
"statsPageTitle": "Statistik",
|
||||
"statsWithGps": "{count, plural, other{{count} benda dengan lokasi}}",
|
||||
"statsTopCountries": "Negara Teratas",
|
||||
"statsTopPlaces": "Tempat Teratas",
|
||||
"statsTopTags": "Tag Teratas",
|
||||
|
||||
"viewerOpenPanoramaButtonLabel": "BUKA PANORAMA",
|
||||
"viewerErrorUnknown": "Ups!",
|
||||
"viewerErrorDoesNotExist": "File tidak ada lagi.",
|
||||
|
||||
"viewerInfoPageTitle": "Info",
|
||||
"viewerInfoBackToViewerTooltip": "Kembali ke pemirsa",
|
||||
|
||||
"viewerInfoUnknown": "tidak dikenal",
|
||||
"viewerInfoLabelTitle": "Judul",
|
||||
"viewerInfoLabelDate": "Tanggal",
|
||||
"viewerInfoLabelResolution": "Resolusi",
|
||||
"viewerInfoLabelSize": "Ukuran",
|
||||
"viewerInfoLabelUri": "URI",
|
||||
"viewerInfoLabelPath": "Jalan",
|
||||
"viewerInfoLabelDuration": "Durasi",
|
||||
"viewerInfoLabelOwner": "Dimiliki oleh",
|
||||
"viewerInfoLabelCoordinates": "Koordinat",
|
||||
"viewerInfoLabelAddress": "Alamat",
|
||||
|
||||
"mapStyleTitle": "Gaya Peta",
|
||||
"mapStyleTooltip": "Pilih gaya peta",
|
||||
"mapZoomInTooltip": "Perbesar",
|
||||
"mapZoomOutTooltip": "Perkecil",
|
||||
"mapPointNorthUpTooltip": "Arahkan ke utara ke atas",
|
||||
"mapAttributionOsmHot": "Data peta © [OpenStreetMap](https://www.openstreetmap.org/copyright) kontributor • Tile oleh [HOT](https://www.hotosm.org/) • Diselenggarakan oleh [OSM France](https://openstreetmap.fr/)",
|
||||
"mapAttributionStamen": "Data peta © [OpenStreetMap](https://www.openstreetmap.org/copyright) kontributor • Tile oleh [Stamen Design](http://stamen.com), [CC BY 3.0](http://creativecommons.org/licenses/by/3.0)",
|
||||
"openMapPageTooltip": "Lihat di halaman Peta",
|
||||
"mapEmptyRegion": "Tidak ada gambar di wilayah ini",
|
||||
|
||||
"viewerInfoOpenEmbeddedFailureFeedback": "Gagal mengekstrak data yang disematkan",
|
||||
"viewerInfoOpenLinkText": "Buka",
|
||||
"viewerInfoViewXmlLinkText": "Tampilkan XML",
|
||||
|
||||
"viewerInfoSearchFieldLabel": "Cari metadata",
|
||||
"viewerInfoSearchEmpty": "Tidak ada kata kunci yang cocok",
|
||||
"viewerInfoSearchSuggestionDate": "Tanggal & waktu",
|
||||
"viewerInfoSearchSuggestionDescription": "Deskripsi",
|
||||
"viewerInfoSearchSuggestionDimensions": "Dimensi",
|
||||
"viewerInfoSearchSuggestionResolution": "Resolusi",
|
||||
"viewerInfoSearchSuggestionRights": "Hak",
|
||||
|
||||
"tagEditorPageTitle": "Ubah Tag",
|
||||
"tagEditorPageNewTagFieldLabel": "Tag baru",
|
||||
"tagEditorPageAddTagTooltip": "Tambah tag",
|
||||
"tagEditorSectionRecent": "Terkini",
|
||||
|
||||
"panoramaEnableSensorControl": "Aktifkan kontrol sensor",
|
||||
"panoramaDisableSensorControl": "Nonaktifkan kontrol sensor",
|
||||
|
||||
"sourceViewerPageTitle": "Sumber",
|
||||
|
||||
"filePickerShowHiddenFiles": "Tampilkan file tersembunyi",
|
||||
"filePickerDoNotShowHiddenFiles": "Jangan tampilkan file tersembunyi",
|
||||
"filePickerOpenFrom": "Buka dari",
|
||||
"filePickerNoItems": "Tidak ada benda",
|
||||
"filePickerUseThisFolder": "Gunakan folder ini"
|
||||
}
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
"timeSeconds": "{seconds, plural, other{{seconds}초}}",
|
||||
"timeMinutes": "{minutes, plural, other{{minutes}분}}",
|
||||
"timeDays": "{days, plural, other{{days}일}}",
|
||||
"focalLength": "{length} mm",
|
||||
|
||||
"applyButtonLabel": "확인",
|
||||
|
@ -27,6 +28,7 @@
|
|||
"resetButtonTooltip": "복원",
|
||||
|
||||
"doubleBackExitMessage": "종료하려면 한번 더 누르세요.",
|
||||
"doNotAskAgain": "다시 묻지 않기",
|
||||
|
||||
"sourceStateLoading": "로딩 중",
|
||||
"sourceStateCataloguing": "분석 중",
|
||||
|
@ -46,9 +48,10 @@
|
|||
|
||||
"entryActionCopyToClipboard": "클립보드에 복사",
|
||||
"entryActionDelete": "삭제",
|
||||
"entryActionConvert": "변환",
|
||||
"entryActionExport": "내보내기",
|
||||
"entryActionInfo": "상세정보",
|
||||
"entryActionRename": "이름 변경",
|
||||
"entryActionRestore": "복원",
|
||||
"entryActionRotateCCW": "좌회전",
|
||||
"entryActionRotateCW": "우회전",
|
||||
"entryActionFlip": "좌우 뒤집기",
|
||||
|
@ -56,10 +59,10 @@
|
|||
"entryActionShare": "공유",
|
||||
"entryActionViewSource": "소스 코드 보기",
|
||||
"entryActionViewMotionPhotoVideo": "모션 포토 보기",
|
||||
"entryActionEdit": "편집…",
|
||||
"entryActionOpen": "다른 앱에서 열기…",
|
||||
"entryActionSetAs": "다음 용도로 사용…",
|
||||
"entryActionOpenMap": "지도 앱에서 보기…",
|
||||
"entryActionEdit": "편집",
|
||||
"entryActionOpen": "다른 앱에서 열기",
|
||||
"entryActionSetAs": "다음 용도로 사용",
|
||||
"entryActionOpenMap": "지도 앱에서 보기",
|
||||
"entryActionRotateScreen": "화면 회전",
|
||||
"entryActionAddFavourite": "즐겨찾기에 추가",
|
||||
"entryActionRemoveFavourite": "즐겨찾기에서 삭제",
|
||||
|
@ -79,6 +82,7 @@
|
|||
"entryInfoActionEditTags": "태그 수정",
|
||||
"entryInfoActionRemoveMetadata": "메타데이터 삭제",
|
||||
|
||||
"filterBinLabel": "휴지통",
|
||||
"filterFavouriteLabel": "즐겨찾기",
|
||||
"filterLocationEmptyLabel": "장소 없음",
|
||||
"filterTagEmptyLabel": "태그 없음",
|
||||
|
@ -157,6 +161,7 @@
|
|||
"noMatchingAppDialogTitle": "처리할 앱 없음",
|
||||
"noMatchingAppDialogMessage": "이 작업을 처리할 수 있는 앱이 없습니다.",
|
||||
|
||||
"binEntriesConfirmationDialogMessage": "{count, plural, =1{이 항목을 휴지통으로 이동하시겠습니까?} other{항목 {count}개를 휴지통으로 이동하시겠습니까?}}",
|
||||
"deleteEntriesConfirmationDialogMessage": "{count, plural, =1{이 항목을 삭제하시겠습니까?} other{항목 {count}개를 삭제하시겠습니까?}}",
|
||||
|
||||
"videoResumeDialogMessage": "{time}부터 재개하시겠습니까?",
|
||||
|
@ -267,11 +272,12 @@
|
|||
|
||||
"collectionPageTitle": "미디어",
|
||||
"collectionPickPageTitle": "항목 선택",
|
||||
"collectionSelectionPageTitle": "{count, plural, =0{항목 선택} other{{count}개}}",
|
||||
"collectionSelectPageTitle": "항목 선택",
|
||||
|
||||
"collectionActionShowTitleSearch": "제목 필터 보기",
|
||||
"collectionActionHideTitleSearch": "제목 필터 숨기기",
|
||||
"collectionActionAddShortcut": "홈 화면에 추가",
|
||||
"collectionActionEmptyBin": "휴지통 비우기",
|
||||
"collectionActionCopy": "앨범으로 복사",
|
||||
"collectionActionMove": "앨범으로 이동",
|
||||
"collectionActionRescan": "새로 분석",
|
||||
|
@ -350,6 +356,8 @@
|
|||
"tagPageTitle": "태그",
|
||||
"tagEmpty": "태그가 없습니다",
|
||||
|
||||
"binPageTitle": "휴지통",
|
||||
|
||||
"searchCollectionFieldHint": "미디어 검색",
|
||||
"searchSectionRecent": "최근 검색기록",
|
||||
"searchSectionAlbums": "앨범",
|
||||
|
@ -375,6 +383,11 @@
|
|||
"settingsKeepScreenOnTitle": "화면 자동 꺼짐 방지",
|
||||
"settingsDoubleBackExit": "뒤로가기 두번 눌러 앱 종료하기",
|
||||
|
||||
"settingsConfirmationDialogTile": "확정 대화상자",
|
||||
"settingsConfirmationDialogTitle": "확정 대화상자",
|
||||
"settingsConfirmationDialogDeleteItems": "항목을 완전히 삭제 시",
|
||||
"settingsConfirmationDialogMoveToBinItems": "항목을 휴지통으로 이동 시",
|
||||
|
||||
"settingsNavigationDrawerTile": "탐색 메뉴",
|
||||
"settingsNavigationDrawerEditorTitle": "탐색 메뉴",
|
||||
"settingsNavigationDrawerBanner": "항목을 길게 누른 후 이동하여 탐색 메뉴에 표시될 항목의 순서를 수정하세요.",
|
||||
|
@ -450,6 +463,8 @@
|
|||
"settingsAllowInstalledAppAccessSubtitle": "앨범 표시 개선을 위해",
|
||||
"settingsAllowErrorReporting": "오류 보고서 보내기",
|
||||
"settingsSaveSearchHistory": "검색기록",
|
||||
"settingsEnableBin": "휴지통 사용",
|
||||
"settingsEnableBinSubtitle": "삭제한 항목을 30일 동안 보관하기",
|
||||
|
||||
"settingsHiddenItemsTile": "숨겨진 항목",
|
||||
"settingsHiddenItemsTitle": "숨겨진 항목",
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
"timeSeconds": "{seconds, plural, =1{1 segundo} other{{seconds} segundos}}",
|
||||
"timeMinutes": "{minutes, plural, =1{1 minuto} other{{minutes} minutos}}",
|
||||
"timeDays": "{days, plural, =1{1 dia} other{{days} dias}}",
|
||||
"focalLength": "{length} mm",
|
||||
|
||||
"applyButtonLabel": "APLIQUE",
|
||||
|
@ -27,6 +28,7 @@
|
|||
"resetButtonTooltip": "Resetar",
|
||||
|
||||
"doubleBackExitMessage": "Toque em “voltar” novamente para sair.",
|
||||
"doNotAskAgain": "Não pergunte novamente",
|
||||
|
||||
"sourceStateLoading": "Carregando",
|
||||
"sourceStateCataloguing": "Catalogação",
|
||||
|
@ -47,8 +49,9 @@
|
|||
"entryActionCopyToClipboard": "Copiar para área de transferência",
|
||||
"entryActionDelete": "Excluir",
|
||||
"entryActionExport": "Exportar",
|
||||
"entryActionInfo": "Informações",
|
||||
"entryActionConvert": "Converter",
|
||||
"entryActionRename": "Renomear",
|
||||
"entryActionRestore": "Restaurar",
|
||||
"entryActionRotateCCW": "Rotacionar para esquerda",
|
||||
"entryActionRotateCW": "Rotacionar para direita",
|
||||
"entryActionFlip": "Virar horizontalmente",
|
||||
|
@ -56,10 +59,10 @@
|
|||
"entryActionShare": "Compartilhado",
|
||||
"entryActionViewSource": "Ver fonte",
|
||||
"entryActionViewMotionPhotoVideo": "Abrir foto em movimento",
|
||||
"entryActionEdit": "Editar com…",
|
||||
"entryActionOpen": "Abrir com…",
|
||||
"entryActionSetAs": "Definir como…",
|
||||
"entryActionOpenMap": "Mostrar no aplicativo de mapa…",
|
||||
"entryActionEdit": "Editar",
|
||||
"entryActionOpen": "Abrir com",
|
||||
"entryActionSetAs": "Definir como",
|
||||
"entryActionOpenMap": "Mostrar no aplicativo de mapa",
|
||||
"entryActionRotateScreen": "Girar a tela",
|
||||
"entryActionAddFavourite": "Adicionar aos favoritos",
|
||||
"entryActionRemoveFavourite": "Remova dos favoritos",
|
||||
|
@ -79,6 +82,7 @@
|
|||
"entryInfoActionEditTags": "Editar etiquetas",
|
||||
"entryInfoActionRemoveMetadata": "Remover metadados",
|
||||
|
||||
"filterBinLabel": "Lixeira",
|
||||
"filterFavouriteLabel": "Favorito",
|
||||
"filterLocationEmptyLabel": "Não localizado",
|
||||
"filterTagEmptyLabel": "Sem etiqueta",
|
||||
|
@ -157,6 +161,7 @@
|
|||
"noMatchingAppDialogTitle": "Nenhum aplicativo correspondente",
|
||||
"noMatchingAppDialogMessage": "Não há aplicativos que possam lidar com isso.",
|
||||
|
||||
"binEntriesConfirmationDialogMessage": "{count, plural, =1{Mover esse item para a lixeira?} other{Mova estes {count} itens para a lixeira?}}",
|
||||
"deleteEntriesConfirmationDialogMessage": "{count, plural, =1{Tem certeza de que deseja excluir este item?} other{Tem certeza de que deseja excluir estes {count} itens?}}",
|
||||
|
||||
"videoResumeDialogMessage": "Deseja continuar jogando em {time}?",
|
||||
|
@ -267,11 +272,12 @@
|
|||
|
||||
"collectionPageTitle": "Coleção",
|
||||
"collectionPickPageTitle": "Escolher",
|
||||
"collectionSelectionPageTitle": "{count, plural, =0{Selecionar itens} =1{1 item} other{{count} itens}}",
|
||||
"collectionSelectPageTitle": "Selecionar itens",
|
||||
|
||||
"collectionActionShowTitleSearch": "Mostrar filtro de título",
|
||||
"collectionActionHideTitleSearch": "Ocultar filtro de título",
|
||||
"collectionActionAddShortcut": "Adicionar atalho",
|
||||
"collectionActionEmptyBin": "Caixa vazia",
|
||||
"collectionActionCopy": "Copiar para o álbum",
|
||||
"collectionActionMove": "Mover para o álbum",
|
||||
"collectionActionRescan": "Reexaminar",
|
||||
|
@ -350,6 +356,8 @@
|
|||
"tagPageTitle": "Etiquetas",
|
||||
"tagEmpty": "Sem etiquetas",
|
||||
|
||||
"binPageTitle": "Lixeira",
|
||||
|
||||
"searchCollectionFieldHint": "Pesquisar coleção",
|
||||
"searchSectionRecent": "Recente",
|
||||
"searchSectionAlbums": "Álbuns",
|
||||
|
@ -375,6 +383,11 @@
|
|||
"settingsKeepScreenOnTitle": "Manter a tela ligada",
|
||||
"settingsDoubleBackExit": "Toque em “voltar” duas vezes para sair",
|
||||
|
||||
"settingsConfirmationDialogTile": "Caixas de diálogo de confirmação",
|
||||
"settingsConfirmationDialogTitle": "Caixas de diálogo de confirmação",
|
||||
"settingsConfirmationDialogDeleteItems": "Pergunte antes de excluir itens para sempre",
|
||||
"settingsConfirmationDialogMoveToBinItems": "Pergunte antes de mover itens para a lixeira",
|
||||
|
||||
"settingsNavigationDrawerTile": "Menu de navegação",
|
||||
"settingsNavigationDrawerEditorTitle": "Menu de navegação",
|
||||
"settingsNavigationDrawerBanner": "Toque e segure para mover e reordenar os itens do menu.",
|
||||
|
@ -450,6 +463,8 @@
|
|||
"settingsAllowInstalledAppAccessSubtitle": "Usado para melhorar a exibição do álbum",
|
||||
"settingsAllowErrorReporting": "Permitir relatórios de erros anônimos",
|
||||
"settingsSaveSearchHistory": "Salvar histórico de pesquisa",
|
||||
"settingsEnableBin": "Usar lixeira",
|
||||
"settingsEnableBinSubtitle": "Manter itens excluídos por 30 dias",
|
||||
|
||||
"settingsHiddenItemsTile": "Itens ocultos",
|
||||
"settingsHiddenItemsTitle": "Itens ocultos",
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
"timeSeconds": "{seconds, plural, =1{1 секунда} few{{seconds} секунды} other{{seconds} секунд}}",
|
||||
"timeMinutes": "{minutes, plural, =1{1 минута} few{{minutes} минуты} other{{minutes} минут}}",
|
||||
"timeDays": "{days, plural, =1{1 день} few{{days} дня} other{{days} дней}}",
|
||||
"focalLength": "{length} mm",
|
||||
|
||||
"applyButtonLabel": "ПРИМЕНИТЬ",
|
||||
|
@ -27,6 +28,7 @@
|
|||
"resetButtonTooltip": "Сбросить",
|
||||
|
||||
"doubleBackExitMessage": "Нажмите «назад» еще раз, чтобы выйти.",
|
||||
"doNotAskAgain": "Больше не спрашивать",
|
||||
|
||||
"sourceStateLoading": "Загрузка",
|
||||
"sourceStateCataloguing": "Каталогизация",
|
||||
|
@ -47,8 +49,8 @@
|
|||
"entryActionCopyToClipboard": "Скопировать в буфер обмена",
|
||||
"entryActionDelete": "Удалить",
|
||||
"entryActionExport": "Экспорт",
|
||||
"entryActionInfo": "Информация",
|
||||
"entryActionRename": "Переименовать",
|
||||
"entryActionRestore": "Восстановить",
|
||||
"entryActionRotateCCW": "Повернуть против часовой стрелки",
|
||||
"entryActionRotateCW": "Повернуть по часовой стрелки",
|
||||
"entryActionFlip": "Отразить по горизонтали",
|
||||
|
@ -56,10 +58,10 @@
|
|||
"entryActionShare": "Поделиться",
|
||||
"entryActionViewSource": "Посмотреть источник",
|
||||
"entryActionViewMotionPhotoVideo": "Открыть «Живые фото»",
|
||||
"entryActionEdit": "Изменить с помощью…",
|
||||
"entryActionOpen": "Открыть с помощью…",
|
||||
"entryActionSetAs": "Установить как…",
|
||||
"entryActionOpenMap": "Показать на карте…",
|
||||
"entryActionEdit": "Изменить",
|
||||
"entryActionOpen": "Открыть с помощью",
|
||||
"entryActionSetAs": "Установить как",
|
||||
"entryActionOpenMap": "Показать на карте",
|
||||
"entryActionRotateScreen": "Повернуть экран",
|
||||
"entryActionAddFavourite": "Добавить в избранное",
|
||||
"entryActionRemoveFavourite": "Удалить из избранного",
|
||||
|
@ -79,6 +81,7 @@
|
|||
"entryInfoActionEditTags": "Изменить теги",
|
||||
"entryInfoActionRemoveMetadata": "Удалить метаданные",
|
||||
|
||||
"filterBinLabel": "Корзина",
|
||||
"filterFavouriteLabel": "Избранное",
|
||||
"filterLocationEmptyLabel": "Без местоположения",
|
||||
"filterTagEmptyLabel": "Без тегов",
|
||||
|
@ -157,6 +160,7 @@
|
|||
"noMatchingAppDialogTitle": "Нет подходящего приложения",
|
||||
"noMatchingAppDialogMessage": "Нет приложений, которые могли бы с этим справиться.",
|
||||
|
||||
"binEntriesConfirmationDialogMessage": "{count, plural, =1{Переместить этот элемент в корзину?} few{Переместить эти {count} элемента в корзину?} other{Переместить эти {count} элементов в корзину?}}",
|
||||
"deleteEntriesConfirmationDialogMessage": "{count, plural, =1{Вы уверены, что хотите удалить этот объект?} few{Вы уверены, что хотите удалить эти {count} объекта?} other{Вы уверены, что хотите удалить эти {count} объектов?}}",
|
||||
|
||||
"videoResumeDialogMessage": "Хотите ли вы возобновить воспроизведение на {time}?",
|
||||
|
@ -267,11 +271,12 @@
|
|||
|
||||
"collectionPageTitle": "Коллекция",
|
||||
"collectionPickPageTitle": "Выбрать",
|
||||
"collectionSelectionPageTitle": "{count, plural, =0{Выберите объекты} =1{1 объект} few{{count} объекта} other{{count} объектов}}",
|
||||
"collectionSelectPageTitle": "Выберите объекты",
|
||||
|
||||
"collectionActionShowTitleSearch": "Показать фильтр заголовка",
|
||||
"collectionActionHideTitleSearch": "Скрыть фильтр заголовка",
|
||||
"collectionActionAddShortcut": "Добавить ярлык",
|
||||
"collectionActionEmptyBin": "Очистить корзину",
|
||||
"collectionActionCopy": "Скопировать в альбом",
|
||||
"collectionActionMove": "Переместить в альбом",
|
||||
"collectionActionRescan": "Пересканировать",
|
||||
|
@ -350,6 +355,8 @@
|
|||
"tagPageTitle": "Теги",
|
||||
"tagEmpty": "Нет тегов",
|
||||
|
||||
"binPageTitle": "Корзина",
|
||||
|
||||
"searchCollectionFieldHint": "Поиск по коллекции",
|
||||
"searchSectionRecent": "Недавние",
|
||||
"searchSectionAlbums": "Альбомы",
|
||||
|
@ -375,6 +382,11 @@
|
|||
"settingsKeepScreenOnTitle": "Держать экран включенным",
|
||||
"settingsDoubleBackExit": "Дважды нажмите «Назад», чтобы выйти",
|
||||
|
||||
"settingsConfirmationDialogTile": "Диалоги подтверждения",
|
||||
"settingsConfirmationDialogTitle": "Диалоги подтверждения",
|
||||
"settingsConfirmationDialogDeleteItems": "Спросить, прежде чем удалять элементы навсегда",
|
||||
"settingsConfirmationDialogMoveToBinItems": "Спросить, прежде чем перемещать элементы в корзину",
|
||||
|
||||
"settingsNavigationDrawerTile": "Навигационное меню",
|
||||
"settingsNavigationDrawerEditorTitle": "Навигационное меню",
|
||||
"settingsNavigationDrawerBanner": "Нажмите и удерживайте, чтобы переместить и изменить порядок пунктов меню.",
|
||||
|
@ -450,6 +462,8 @@
|
|||
"settingsAllowInstalledAppAccessSubtitle": "Используется для улучшения отображения альбомов",
|
||||
"settingsAllowErrorReporting": "Разрешить анонимную отправку логов",
|
||||
"settingsSaveSearchHistory": "Сохранять историю поиска",
|
||||
"settingsEnableBin": "Использовать корзину",
|
||||
"settingsEnableBinSubtitle": "Хранить удалённые элементы в течение 30 дней",
|
||||
|
||||
"settingsHiddenItemsTile": "Скрытые объекты",
|
||||
"settingsHiddenItemsTitle": "Скрытые объекты",
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import 'package:aves/theme/colors.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/theme/themes.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
|
@ -7,10 +7,12 @@ enum EntryAction {
|
|||
addShortcut,
|
||||
copyToClipboard,
|
||||
delete,
|
||||
export,
|
||||
info,
|
||||
restore,
|
||||
convert,
|
||||
print,
|
||||
rename,
|
||||
copy,
|
||||
move,
|
||||
share,
|
||||
toggleFavourite,
|
||||
// raster
|
||||
|
@ -31,25 +33,32 @@ enum EntryAction {
|
|||
}
|
||||
|
||||
class EntryActions {
|
||||
static const inApp = [
|
||||
EntryAction.info,
|
||||
EntryAction.toggleFavourite,
|
||||
static const topLevel = [
|
||||
EntryAction.share,
|
||||
EntryAction.delete,
|
||||
EntryAction.edit,
|
||||
EntryAction.rename,
|
||||
EntryAction.export,
|
||||
EntryAction.addShortcut,
|
||||
EntryAction.copyToClipboard,
|
||||
EntryAction.print,
|
||||
EntryAction.delete,
|
||||
EntryAction.copy,
|
||||
EntryAction.move,
|
||||
EntryAction.toggleFavourite,
|
||||
EntryAction.viewSource,
|
||||
EntryAction.rotateScreen,
|
||||
];
|
||||
|
||||
static const externalApp = [
|
||||
EntryAction.edit,
|
||||
static const export = [
|
||||
EntryAction.convert,
|
||||
EntryAction.addShortcut,
|
||||
EntryAction.copyToClipboard,
|
||||
EntryAction.print,
|
||||
EntryAction.open,
|
||||
EntryAction.setAs,
|
||||
EntryAction.openMap,
|
||||
EntryAction.setAs,
|
||||
];
|
||||
|
||||
static const exportExternal = [
|
||||
EntryAction.open,
|
||||
EntryAction.openMap,
|
||||
EntryAction.setAs,
|
||||
];
|
||||
|
||||
static const pageActions = [
|
||||
|
@ -57,6 +66,12 @@ class EntryActions {
|
|||
EntryAction.rotateCW,
|
||||
EntryAction.flip,
|
||||
];
|
||||
|
||||
static const trashed = [
|
||||
EntryAction.delete,
|
||||
EntryAction.restore,
|
||||
EntryAction.debug,
|
||||
];
|
||||
}
|
||||
|
||||
extension ExtraEntryAction on EntryAction {
|
||||
|
@ -68,14 +83,18 @@ extension ExtraEntryAction on EntryAction {
|
|||
return context.l10n.entryActionCopyToClipboard;
|
||||
case EntryAction.delete:
|
||||
return context.l10n.entryActionDelete;
|
||||
case EntryAction.export:
|
||||
return context.l10n.entryActionExport;
|
||||
case EntryAction.info:
|
||||
return context.l10n.entryActionInfo;
|
||||
case EntryAction.restore:
|
||||
return context.l10n.entryActionRestore;
|
||||
case EntryAction.convert:
|
||||
return context.l10n.entryActionConvert;
|
||||
case EntryAction.print:
|
||||
return context.l10n.entryActionPrint;
|
||||
case EntryAction.rename:
|
||||
return context.l10n.entryActionRename;
|
||||
case EntryAction.copy:
|
||||
return context.l10n.collectionActionCopy;
|
||||
case EntryAction.move:
|
||||
return context.l10n.collectionActionMove;
|
||||
case EntryAction.share:
|
||||
return context.l10n.entryActionShare;
|
||||
case EntryAction.toggleFavourite:
|
||||
|
@ -109,15 +128,12 @@ extension ExtraEntryAction on EntryAction {
|
|||
}
|
||||
}
|
||||
|
||||
Widget? getIcon() {
|
||||
final icon = getIconData();
|
||||
if (icon == null) return null;
|
||||
|
||||
final child = Icon(icon);
|
||||
Widget getIcon() {
|
||||
final child = Icon(getIconData());
|
||||
switch (this) {
|
||||
case EntryAction.debug:
|
||||
return ShaderMask(
|
||||
shaderCallback: Themes.debugGradient.createShader,
|
||||
shaderCallback: AColors.debugGradient.createShader,
|
||||
child: child,
|
||||
);
|
||||
default:
|
||||
|
@ -125,7 +141,7 @@ extension ExtraEntryAction on EntryAction {
|
|||
}
|
||||
}
|
||||
|
||||
IconData? getIconData() {
|
||||
IconData getIconData() {
|
||||
switch (this) {
|
||||
case EntryAction.addShortcut:
|
||||
return AIcons.addShortcut;
|
||||
|
@ -133,14 +149,18 @@ extension ExtraEntryAction on EntryAction {
|
|||
return AIcons.clipboard;
|
||||
case EntryAction.delete:
|
||||
return AIcons.delete;
|
||||
case EntryAction.export:
|
||||
return AIcons.saveAs;
|
||||
case EntryAction.info:
|
||||
return AIcons.info;
|
||||
case EntryAction.restore:
|
||||
return AIcons.restore;
|
||||
case EntryAction.convert:
|
||||
return AIcons.convert;
|
||||
case EntryAction.print:
|
||||
return AIcons.print;
|
||||
case EntryAction.rename:
|
||||
return AIcons.rename;
|
||||
case EntryAction.copy:
|
||||
return AIcons.copy;
|
||||
case EntryAction.move:
|
||||
return AIcons.move;
|
||||
case EntryAction.share:
|
||||
return AIcons.share;
|
||||
case EntryAction.toggleFavourite:
|
||||
|
@ -158,10 +178,13 @@ extension ExtraEntryAction on EntryAction {
|
|||
return AIcons.vector;
|
||||
// external
|
||||
case EntryAction.edit:
|
||||
return AIcons.edit;
|
||||
case EntryAction.open:
|
||||
return AIcons.openOutside;
|
||||
case EntryAction.openMap:
|
||||
return AIcons.map;
|
||||
case EntryAction.setAs:
|
||||
return null;
|
||||
return AIcons.setAs;
|
||||
// platform
|
||||
case EntryAction.rotateScreen:
|
||||
return AIcons.rotateScreen;
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:aves/theme/colors.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
@ -11,6 +12,8 @@ enum EntryInfoAction {
|
|||
removeMetadata,
|
||||
// motion photo
|
||||
viewMotionPhotoVideo,
|
||||
// debug
|
||||
debug,
|
||||
}
|
||||
|
||||
class EntryInfoActions {
|
||||
|
@ -41,11 +44,23 @@ extension ExtraEntryInfoAction on EntryInfoAction {
|
|||
// motion photo
|
||||
case EntryInfoAction.viewMotionPhotoVideo:
|
||||
return context.l10n.entryActionViewMotionPhotoVideo;
|
||||
// debug
|
||||
case EntryInfoAction.debug:
|
||||
return 'Debug';
|
||||
}
|
||||
}
|
||||
|
||||
Widget getIcon() {
|
||||
return Icon(_getIconData());
|
||||
final child = Icon(_getIconData());
|
||||
switch (this) {
|
||||
case EntryInfoAction.debug:
|
||||
return ShaderMask(
|
||||
shaderCallback: AColors.debugGradient.createShader,
|
||||
child: child,
|
||||
);
|
||||
default:
|
||||
return child;
|
||||
}
|
||||
}
|
||||
|
||||
IconData _getIconData() {
|
||||
|
@ -64,6 +79,9 @@ extension ExtraEntryInfoAction on EntryInfoAction {
|
|||
// motion photo
|
||||
case EntryInfoAction.viewMotionPhotoVideo:
|
||||
return AIcons.motionPhoto;
|
||||
// debug
|
||||
case EntryInfoAction.debug:
|
||||
return AIcons.debug;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,6 +12,7 @@ enum EntrySetAction {
|
|||
searchCollection,
|
||||
toggleTitleSearch,
|
||||
addShortcut,
|
||||
emptyBin,
|
||||
// browsing or selecting
|
||||
map,
|
||||
stats,
|
||||
|
@ -19,6 +20,7 @@ enum EntrySetAction {
|
|||
// selecting
|
||||
share,
|
||||
delete,
|
||||
restore,
|
||||
copy,
|
||||
move,
|
||||
toggleFavourite,
|
||||
|
@ -40,7 +42,18 @@ class EntrySetActions {
|
|||
EntrySetAction.selectNone,
|
||||
];
|
||||
|
||||
static const browsing = [
|
||||
static const pageBrowsing = [
|
||||
EntrySetAction.searchCollection,
|
||||
EntrySetAction.toggleTitleSearch,
|
||||
EntrySetAction.addShortcut,
|
||||
EntrySetAction.map,
|
||||
EntrySetAction.stats,
|
||||
EntrySetAction.rescan,
|
||||
EntrySetAction.emptyBin,
|
||||
];
|
||||
|
||||
// exclude bin related actions
|
||||
static const collectionEditorBrowsing = [
|
||||
EntrySetAction.searchCollection,
|
||||
EntrySetAction.toggleTitleSearch,
|
||||
EntrySetAction.addShortcut,
|
||||
|
@ -49,7 +62,21 @@ class EntrySetActions {
|
|||
EntrySetAction.rescan,
|
||||
];
|
||||
|
||||
static const selection = [
|
||||
static const pageSelection = [
|
||||
EntrySetAction.share,
|
||||
EntrySetAction.delete,
|
||||
EntrySetAction.restore,
|
||||
EntrySetAction.copy,
|
||||
EntrySetAction.move,
|
||||
EntrySetAction.toggleFavourite,
|
||||
EntrySetAction.map,
|
||||
EntrySetAction.stats,
|
||||
EntrySetAction.rescan,
|
||||
// editing actions are in their subsection
|
||||
];
|
||||
|
||||
// exclude bin related actions
|
||||
static const collectionEditorSelection = [
|
||||
EntrySetAction.share,
|
||||
EntrySetAction.delete,
|
||||
EntrySetAction.copy,
|
||||
|
@ -60,6 +87,14 @@ class EntrySetActions {
|
|||
EntrySetAction.rescan,
|
||||
// editing actions are in their subsection
|
||||
];
|
||||
|
||||
static const edit = [
|
||||
EntrySetAction.editDate,
|
||||
EntrySetAction.editLocation,
|
||||
EntrySetAction.editRating,
|
||||
EntrySetAction.editTags,
|
||||
EntrySetAction.removeMetadata,
|
||||
];
|
||||
}
|
||||
|
||||
extension ExtraEntrySetAction on EntrySetAction {
|
||||
|
@ -82,6 +117,8 @@ extension ExtraEntrySetAction on EntrySetAction {
|
|||
return context.l10n.collectionActionShowTitleSearch;
|
||||
case EntrySetAction.addShortcut:
|
||||
return context.l10n.collectionActionAddShortcut;
|
||||
case EntrySetAction.emptyBin:
|
||||
return context.l10n.collectionActionEmptyBin;
|
||||
// browsing or selecting
|
||||
case EntrySetAction.map:
|
||||
return context.l10n.menuActionMap;
|
||||
|
@ -94,6 +131,8 @@ extension ExtraEntrySetAction on EntrySetAction {
|
|||
return context.l10n.entryActionShare;
|
||||
case EntrySetAction.delete:
|
||||
return context.l10n.entryActionDelete;
|
||||
case EntrySetAction.restore:
|
||||
return context.l10n.entryActionRestore;
|
||||
case EntrySetAction.copy:
|
||||
return context.l10n.collectionActionCopy;
|
||||
case EntrySetAction.move:
|
||||
|
@ -143,6 +182,8 @@ extension ExtraEntrySetAction on EntrySetAction {
|
|||
return AIcons.filter;
|
||||
case EntrySetAction.addShortcut:
|
||||
return AIcons.addShortcut;
|
||||
case EntrySetAction.emptyBin:
|
||||
return AIcons.emptyBin;
|
||||
// browsing or selecting
|
||||
case EntrySetAction.map:
|
||||
return AIcons.map;
|
||||
|
@ -155,6 +196,8 @@ extension ExtraEntrySetAction on EntrySetAction {
|
|||
return AIcons.share;
|
||||
case EntrySetAction.delete:
|
||||
return AIcons.delete;
|
||||
case EntrySetAction.restore:
|
||||
return AIcons.restore;
|
||||
case EntrySetAction.copy:
|
||||
return AIcons.copy;
|
||||
case EntrySetAction.move:
|
||||
|
|
|
@ -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);
|
||||
|
||||
int? coverContentId(CollectionFilter filter) => _rows.firstWhereOrNull((row) => row.filter == filter)?.contentId;
|
||||
int? coverEntryId(CollectionFilter filter) => _rows.firstWhereOrNull((row) => row.filter == filter)?.entryId;
|
||||
|
||||
Future<void> set(CollectionFilter filter, int? contentId) async {
|
||||
Future<void> set(CollectionFilter filter, int? entryId) async {
|
||||
// erase contextual properties from filters before saving them
|
||||
if (filter is AlbumFilter) {
|
||||
filter = AlbumFilter(filter.album, null);
|
||||
}
|
||||
|
||||
_rows.removeWhere((row) => row.filter == filter);
|
||||
if (contentId == null) {
|
||||
if (entryId == null) {
|
||||
await metadataDb.removeCovers({filter});
|
||||
} else {
|
||||
final row = CoverRow(filter: filter, contentId: contentId);
|
||||
final row = CoverRow(filter: filter, entryId: entryId);
|
||||
_rows.add(row);
|
||||
await metadataDb.addCovers({row});
|
||||
}
|
||||
|
@ -43,28 +43,26 @@ class Covers with ChangeNotifier {
|
|||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> moveEntry(int oldContentId, AvesEntry entry) async {
|
||||
final oldRows = _rows.where((row) => row.contentId == oldContentId).toSet();
|
||||
if (oldRows.isEmpty) return;
|
||||
|
||||
for (final oldRow in oldRows) {
|
||||
final filter = oldRow.filter;
|
||||
_rows.remove(oldRow);
|
||||
if (filter.test(entry)) {
|
||||
final newRow = CoverRow(filter: filter, contentId: entry.contentId!);
|
||||
await metadataDb.updateCoverEntryId(oldRow.contentId, newRow);
|
||||
_rows.add(newRow);
|
||||
} else {
|
||||
await metadataDb.removeCovers({filter});
|
||||
Future<void> moveEntry(AvesEntry entry, {required bool persist}) async {
|
||||
final entryId = entry.id;
|
||||
final rows = _rows.where((row) => row.entryId == entryId).toSet();
|
||||
for (final row in rows) {
|
||||
final filter = row.filter;
|
||||
if (!filter.test(entry)) {
|
||||
_rows.remove(row);
|
||||
if (persist) {
|
||||
await metadataDb.removeCovers({filter});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> removeEntries(Set<AvesEntry> entries) async {
|
||||
final contentIds = entries.map((entry) => entry.contentId).toSet();
|
||||
final removedRows = _rows.where((row) => contentIds.contains(row.contentId)).toSet();
|
||||
Future<void> removeEntries(Set<AvesEntry> entries) => removeIds(entries.map((entry) => entry.id).toSet());
|
||||
|
||||
Future<void> removeIds(Set<int> entryIds) async {
|
||||
final removedRows = _rows.where((row) => entryIds.contains(row.entryId)).toSet();
|
||||
|
||||
await metadataDb.removeCovers(removedRows.map((row) => row.filter).toSet());
|
||||
_rows.removeAll(removedRows);
|
||||
|
@ -85,8 +83,8 @@ class Covers with ChangeNotifier {
|
|||
final visibleEntries = source.visibleEntries;
|
||||
final jsonList = covers.all
|
||||
.map((row) {
|
||||
final id = row.contentId;
|
||||
final path = visibleEntries.firstWhereOrNull((entry) => id == entry.contentId)?.path;
|
||||
final entryId = row.entryId;
|
||||
final path = visibleEntries.firstWhereOrNull((entry) => entryId == entry.id)?.path;
|
||||
if (path == null) return null;
|
||||
|
||||
final volume = androidFileUtils.getStorageVolume(path)?.path;
|
||||
|
@ -124,7 +122,7 @@ class Covers with ChangeNotifier {
|
|||
final path = pContext.join(volume, relativePath);
|
||||
final entry = visibleEntries.firstWhereOrNull((entry) => entry.path == path && filter.test(entry));
|
||||
if (entry != null) {
|
||||
covers.set(filter, entry.contentId);
|
||||
covers.set(filter, entry.id);
|
||||
} else {
|
||||
debugPrint('failed to import cover for path=$path, filter=$filter');
|
||||
}
|
||||
|
@ -138,14 +136,14 @@ class Covers with ChangeNotifier {
|
|||
@immutable
|
||||
class CoverRow extends Equatable {
|
||||
final CollectionFilter filter;
|
||||
final int contentId;
|
||||
final int entryId;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [filter, contentId];
|
||||
List<Object?> get props => [filter, entryId];
|
||||
|
||||
const CoverRow({
|
||||
required this.filter,
|
||||
required this.contentId,
|
||||
required this.entryId,
|
||||
});
|
||||
|
||||
static CoverRow? fromMap(Map map) {
|
||||
|
@ -153,12 +151,12 @@ class CoverRow extends Equatable {
|
|||
if (filter == null) return null;
|
||||
return CoverRow(
|
||||
filter: filter,
|
||||
contentId: map['contentId'],
|
||||
entryId: map['entryId'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'filter': filter.toJson(),
|
||||
'contentId': contentId,
|
||||
'entryId': entryId,
|
||||
};
|
||||
}
|
||||
|
|
108
lib/model/db/db_metadata.dart
Normal file
|
@ -0,0 +1,108 @@
|
|||
import 'package:aves/model/covers.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/favourites.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/metadata/address.dart';
|
||||
import 'package:aves/model/metadata/catalog.dart';
|
||||
import 'package:aves/model/metadata/trash.dart';
|
||||
import 'package:aves/model/video_playback.dart';
|
||||
|
||||
abstract class MetadataDb {
|
||||
int get nextId;
|
||||
|
||||
Future<void> init();
|
||||
|
||||
Future<int> dbFileSize();
|
||||
|
||||
Future<void> reset();
|
||||
|
||||
Future<void> removeIds(Iterable<int> ids, {Set<EntryDataType>? dataTypes});
|
||||
|
||||
// entries
|
||||
|
||||
Future<void> clearEntries();
|
||||
|
||||
Future<Set<AvesEntry>> loadEntries({String? directory});
|
||||
|
||||
Future<Set<AvesEntry>> loadEntriesById(Iterable<int> ids);
|
||||
|
||||
Future<void> saveEntries(Iterable<AvesEntry> entries);
|
||||
|
||||
Future<void> updateEntry(int id, AvesEntry entry);
|
||||
|
||||
Future<Set<AvesEntry>> searchEntries(String query, {int? limit});
|
||||
|
||||
// date taken
|
||||
|
||||
Future<void> clearDates();
|
||||
|
||||
Future<Map<int?, int?>> loadDates();
|
||||
|
||||
// catalog metadata
|
||||
|
||||
Future<void> clearCatalogMetadata();
|
||||
|
||||
Future<Set<CatalogMetadata>> loadCatalogMetadata();
|
||||
|
||||
Future<Set<CatalogMetadata>> loadCatalogMetadataById(Iterable<int> ids);
|
||||
|
||||
Future<void> saveCatalogMetadata(Set<CatalogMetadata> metadataEntries);
|
||||
|
||||
Future<void> updateCatalogMetadata(int id, CatalogMetadata? metadata);
|
||||
|
||||
// address
|
||||
|
||||
Future<void> clearAddresses();
|
||||
|
||||
Future<Set<AddressDetails>> loadAddresses();
|
||||
|
||||
Future<Set<AddressDetails>> loadAddressesById(Iterable<int> ids);
|
||||
|
||||
Future<void> saveAddresses(Set<AddressDetails> addresses);
|
||||
|
||||
Future<void> updateAddress(int id, AddressDetails? address);
|
||||
|
||||
// trash
|
||||
|
||||
Future<void> clearTrashDetails();
|
||||
|
||||
Future<Set<TrashDetails>> loadAllTrashDetails();
|
||||
|
||||
Future<void> updateTrash(int id, TrashDetails? details);
|
||||
|
||||
// favourites
|
||||
|
||||
Future<void> clearFavourites();
|
||||
|
||||
Future<Set<FavouriteRow>> loadAllFavourites();
|
||||
|
||||
Future<void> addFavourites(Iterable<FavouriteRow> rows);
|
||||
|
||||
Future<void> updateFavouriteId(int id, FavouriteRow row);
|
||||
|
||||
Future<void> removeFavourites(Iterable<FavouriteRow> rows);
|
||||
|
||||
// covers
|
||||
|
||||
Future<void> clearCovers();
|
||||
|
||||
Future<Set<CoverRow>> loadAllCovers();
|
||||
|
||||
Future<void> addCovers(Iterable<CoverRow> rows);
|
||||
|
||||
Future<void> updateCoverEntryId(int id, CoverRow row);
|
||||
|
||||
Future<void> removeCovers(Set<CollectionFilter> filters);
|
||||
|
||||
// video playback
|
||||
|
||||
Future<void> clearVideoPlayback();
|
||||
|
||||
Future<Set<VideoPlaybackRow>> loadAllVideoPlayback();
|
||||
|
||||
Future<VideoPlaybackRow?> loadVideoPlayback(int? id);
|
||||
|
||||
Future<void> addVideoPlayback(Set<VideoPlaybackRow> rows);
|
||||
|
||||
Future<void> removeVideoPlayback(Iterable<int> ids);
|
||||
}
|
|
@ -1,106 +1,22 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:aves/model/covers.dart';
|
||||
import 'package:aves/model/db/db_metadata.dart';
|
||||
import 'package:aves/model/db/db_metadata_sqflite_upgrade.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/favourites.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/metadata/address.dart';
|
||||
import 'package:aves/model/metadata/catalog.dart';
|
||||
import 'package:aves/model/metadata_db_upgrade.dart';
|
||||
import 'package:aves/model/metadata/trash.dart';
|
||||
import 'package:aves/model/video_playback.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
abstract class MetadataDb {
|
||||
Future<void> init();
|
||||
|
||||
Future<int> dbFileSize();
|
||||
|
||||
Future<void> reset();
|
||||
|
||||
Future<void> removeIds(Set<int> contentIds, {Set<EntryDataType>? dataTypes});
|
||||
|
||||
// entries
|
||||
|
||||
Future<void> clearEntries();
|
||||
|
||||
Future<Set<AvesEntry>> loadAllEntries();
|
||||
|
||||
Future<void> saveEntries(Iterable<AvesEntry> entries);
|
||||
|
||||
Future<void> updateEntryId(int oldId, AvesEntry entry);
|
||||
|
||||
Future<Set<AvesEntry>> searchEntries(String query, {int? limit});
|
||||
|
||||
// date taken
|
||||
|
||||
Future<void> clearDates();
|
||||
|
||||
Future<Map<int?, int?>> loadDates();
|
||||
|
||||
// catalog metadata
|
||||
|
||||
Future<void> clearMetadataEntries();
|
||||
|
||||
Future<List<CatalogMetadata>> loadAllMetadataEntries();
|
||||
|
||||
Future<void> saveMetadata(Set<CatalogMetadata> metadataEntries);
|
||||
|
||||
Future<void> updateMetadataId(int oldId, CatalogMetadata? metadata);
|
||||
|
||||
// address
|
||||
|
||||
Future<void> clearAddresses();
|
||||
|
||||
Future<List<AddressDetails>> loadAllAddresses();
|
||||
|
||||
Future<void> saveAddresses(Set<AddressDetails> addresses);
|
||||
|
||||
Future<void> updateAddressId(int oldId, AddressDetails? address);
|
||||
|
||||
// favourites
|
||||
|
||||
Future<void> clearFavourites();
|
||||
|
||||
Future<Set<FavouriteRow>> loadAllFavourites();
|
||||
|
||||
Future<void> addFavourites(Iterable<FavouriteRow> rows);
|
||||
|
||||
Future<void> updateFavouriteId(int oldId, FavouriteRow row);
|
||||
|
||||
Future<void> removeFavourites(Iterable<FavouriteRow> rows);
|
||||
|
||||
// covers
|
||||
|
||||
Future<void> clearCovers();
|
||||
|
||||
Future<Set<CoverRow>> loadAllCovers();
|
||||
|
||||
Future<void> addCovers(Iterable<CoverRow> rows);
|
||||
|
||||
Future<void> updateCoverEntryId(int oldId, CoverRow row);
|
||||
|
||||
Future<void> removeCovers(Set<CollectionFilter> filters);
|
||||
|
||||
// video playback
|
||||
|
||||
Future<void> clearVideoPlayback();
|
||||
|
||||
Future<Set<VideoPlaybackRow>> loadAllVideoPlayback();
|
||||
|
||||
Future<VideoPlaybackRow?> loadVideoPlayback(int? contentId);
|
||||
|
||||
Future<void> addVideoPlayback(Set<VideoPlaybackRow> rows);
|
||||
|
||||
Future<void> updateVideoPlaybackId(int oldId, int? newId);
|
||||
|
||||
Future<void> removeVideoPlayback(Set<int> contentIds);
|
||||
}
|
||||
|
||||
class SqfliteMetadataDb implements MetadataDb {
|
||||
late Future<Database> _database;
|
||||
late Database _db;
|
||||
|
||||
Future<String> get path async => pContext.join(await getDatabasesPath(), 'metadata.db');
|
||||
|
||||
|
@ -110,15 +26,22 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
static const addressTable = 'address';
|
||||
static const favouriteTable = 'favourites';
|
||||
static const coverTable = 'covers';
|
||||
static const trashTable = 'trash';
|
||||
static const videoPlaybackTable = 'videoPlayback';
|
||||
|
||||
static int _lastId = 0;
|
||||
|
||||
@override
|
||||
int get nextId => ++_lastId;
|
||||
|
||||
@override
|
||||
Future<void> init() async {
|
||||
_database = openDatabase(
|
||||
_db = await openDatabase(
|
||||
await path,
|
||||
onCreate: (db, version) async {
|
||||
await db.execute('CREATE TABLE $entryTable('
|
||||
'contentId INTEGER PRIMARY KEY'
|
||||
'id INTEGER PRIMARY KEY'
|
||||
', contentId INTEGER'
|
||||
', uri TEXT'
|
||||
', path TEXT'
|
||||
', sourceMimeType TEXT'
|
||||
|
@ -130,13 +53,14 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
', dateModifiedSecs INTEGER'
|
||||
', sourceDateTakenMillis INTEGER'
|
||||
', durationMillis INTEGER'
|
||||
', trashed INTEGER DEFAULT 0'
|
||||
')');
|
||||
await db.execute('CREATE TABLE $dateTakenTable('
|
||||
'contentId INTEGER PRIMARY KEY'
|
||||
'id INTEGER PRIMARY KEY'
|
||||
', dateMillis INTEGER'
|
||||
')');
|
||||
await db.execute('CREATE TABLE $metadataTable('
|
||||
'contentId INTEGER PRIMARY KEY'
|
||||
'id INTEGER PRIMARY KEY'
|
||||
', mimeType TEXT'
|
||||
', dateMillis INTEGER'
|
||||
', flags INTEGER'
|
||||
|
@ -148,7 +72,7 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
', rating INTEGER'
|
||||
')');
|
||||
await db.execute('CREATE TABLE $addressTable('
|
||||
'contentId INTEGER PRIMARY KEY'
|
||||
'id INTEGER PRIMARY KEY'
|
||||
', addressLine TEXT'
|
||||
', countryCode TEXT'
|
||||
', countryName TEXT'
|
||||
|
@ -156,21 +80,28 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
', locality TEXT'
|
||||
')');
|
||||
await db.execute('CREATE TABLE $favouriteTable('
|
||||
'contentId INTEGER PRIMARY KEY'
|
||||
', path TEXT'
|
||||
'id INTEGER PRIMARY KEY'
|
||||
')');
|
||||
await db.execute('CREATE TABLE $coverTable('
|
||||
'filter TEXT PRIMARY KEY'
|
||||
', contentId INTEGER'
|
||||
', entryId INTEGER'
|
||||
')');
|
||||
await db.execute('CREATE TABLE $trashTable('
|
||||
'id INTEGER PRIMARY KEY'
|
||||
', path TEXT'
|
||||
', dateMillis INTEGER'
|
||||
')');
|
||||
await db.execute('CREATE TABLE $videoPlaybackTable('
|
||||
'contentId INTEGER PRIMARY KEY'
|
||||
'id INTEGER PRIMARY KEY'
|
||||
', resumeTimeMillis INTEGER'
|
||||
')');
|
||||
},
|
||||
onUpgrade: MetadataDbUpgrader.upgradeDb,
|
||||
version: 6,
|
||||
version: 7,
|
||||
);
|
||||
|
||||
final maxIdRows = await _db.rawQuery('SELECT max(id) AS maxId FROM $entryTable');
|
||||
_lastId = (maxIdRows.firstOrNull?['maxId'] as int?) ?? 0;
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -182,22 +113,22 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
@override
|
||||
Future<void> reset() async {
|
||||
debugPrint('$runtimeType reset');
|
||||
await (await _database).close();
|
||||
await _db.close();
|
||||
await deleteDatabase(await path);
|
||||
await init();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> removeIds(Set<int> contentIds, {Set<EntryDataType>? dataTypes}) async {
|
||||
if (contentIds.isEmpty) return;
|
||||
Future<void> removeIds(Iterable<int> ids, {Set<EntryDataType>? dataTypes}) async {
|
||||
if (ids.isEmpty) return;
|
||||
|
||||
final _dataTypes = dataTypes ?? EntryDataType.values.toSet();
|
||||
|
||||
final db = await _database;
|
||||
// using array in `whereArgs` and using it with `where contentId IN ?` is a pain, so we prefer `batch` instead
|
||||
final batch = db.batch();
|
||||
const where = 'contentId = ?';
|
||||
contentIds.forEach((id) {
|
||||
// using array in `whereArgs` and using it with `where id IN ?` is a pain, so we prefer `batch` instead
|
||||
final batch = _db.batch();
|
||||
const where = 'id = ?';
|
||||
const coverWhere = 'entryId = ?';
|
||||
ids.forEach((id) {
|
||||
final whereArgs = [id];
|
||||
if (_dataTypes.contains(EntryDataType.basic)) {
|
||||
batch.delete(entryTable, where: where, whereArgs: whereArgs);
|
||||
|
@ -211,7 +142,8 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
}
|
||||
if (_dataTypes.contains(EntryDataType.references)) {
|
||||
batch.delete(favouriteTable, where: where, whereArgs: whereArgs);
|
||||
batch.delete(coverTable, where: where, whereArgs: whereArgs);
|
||||
batch.delete(coverTable, where: coverWhere, whereArgs: whereArgs);
|
||||
batch.delete(trashTable, where: where, whereArgs: whereArgs);
|
||||
batch.delete(videoPlaybackTable, where: where, whereArgs: whereArgs);
|
||||
}
|
||||
});
|
||||
|
@ -222,35 +154,54 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
|
||||
@override
|
||||
Future<void> clearEntries() async {
|
||||
final db = await _database;
|
||||
final count = await db.delete(entryTable, where: '1');
|
||||
final count = await _db.delete(entryTable, where: '1');
|
||||
debugPrint('$runtimeType clearEntries deleted $count rows');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Set<AvesEntry>> loadAllEntries() async {
|
||||
final db = await _database;
|
||||
final maps = await db.query(entryTable);
|
||||
final entries = maps.map(AvesEntry.fromMap).toSet();
|
||||
return entries;
|
||||
Future<Set<AvesEntry>> loadEntries({String? directory}) async {
|
||||
if (directory != null) {
|
||||
final separator = pContext.separator;
|
||||
if (!directory.endsWith(separator)) {
|
||||
directory = '$directory$separator';
|
||||
}
|
||||
|
||||
const where = 'path LIKE ?';
|
||||
final whereArgs = ['$directory%'];
|
||||
final rows = await _db.query(entryTable, where: where, whereArgs: whereArgs);
|
||||
|
||||
final dirLength = directory.length;
|
||||
return rows
|
||||
.whereNot((row) {
|
||||
// skip entries in subfolders
|
||||
final path = row['path'] as String?;
|
||||
return path == null || path.substring(dirLength).contains(separator);
|
||||
})
|
||||
.map(AvesEntry.fromMap)
|
||||
.toSet();
|
||||
}
|
||||
|
||||
final rows = await _db.query(entryTable);
|
||||
return rows.map(AvesEntry.fromMap).toSet();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Set<AvesEntry>> loadEntriesById(Iterable<int> ids) => _getByIds(ids, entryTable, AvesEntry.fromMap);
|
||||
|
||||
@override
|
||||
Future<void> saveEntries(Iterable<AvesEntry> entries) async {
|
||||
if (entries.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final db = await _database;
|
||||
final batch = db.batch();
|
||||
final batch = _db.batch();
|
||||
entries.forEach((entry) => _batchInsertEntry(batch, entry));
|
||||
await batch.commit(noResult: true);
|
||||
debugPrint('$runtimeType saveEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateEntryId(int oldId, AvesEntry entry) async {
|
||||
final db = await _database;
|
||||
final batch = db.batch();
|
||||
batch.delete(entryTable, where: 'contentId = ?', whereArgs: [oldId]);
|
||||
Future<void> updateEntry(int id, AvesEntry entry) async {
|
||||
final batch = _db.batch();
|
||||
batch.delete(entryTable, where: 'id = ?', whereArgs: [id]);
|
||||
_batchInsertEntry(batch, entry);
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
@ -265,58 +216,53 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
|
||||
@override
|
||||
Future<Set<AvesEntry>> searchEntries(String query, {int? limit}) async {
|
||||
final db = await _database;
|
||||
final maps = await db.query(
|
||||
final rows = await _db.query(
|
||||
entryTable,
|
||||
where: 'title LIKE ?',
|
||||
whereArgs: ['%$query%'],
|
||||
orderBy: 'sourceDateTakenMillis DESC',
|
||||
limit: limit,
|
||||
);
|
||||
return maps.map(AvesEntry.fromMap).toSet();
|
||||
return rows.map(AvesEntry.fromMap).toSet();
|
||||
}
|
||||
|
||||
// date taken
|
||||
|
||||
@override
|
||||
Future<void> clearDates() async {
|
||||
final db = await _database;
|
||||
final count = await db.delete(dateTakenTable, where: '1');
|
||||
final count = await _db.delete(dateTakenTable, where: '1');
|
||||
debugPrint('$runtimeType clearDates deleted $count rows');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<int?, int?>> loadDates() async {
|
||||
final db = await _database;
|
||||
final maps = await db.query(dateTakenTable);
|
||||
final metadataEntries = Map.fromEntries(maps.map((map) => MapEntry(map['contentId'] as int, (map['dateMillis'] ?? 0) as int)));
|
||||
return metadataEntries;
|
||||
final rows = await _db.query(dateTakenTable);
|
||||
return Map.fromEntries(rows.map((map) => MapEntry(map['id'] as int, (map['dateMillis'] ?? 0) as int)));
|
||||
}
|
||||
|
||||
// catalog metadata
|
||||
|
||||
@override
|
||||
Future<void> clearMetadataEntries() async {
|
||||
final db = await _database;
|
||||
final count = await db.delete(metadataTable, where: '1');
|
||||
Future<void> clearCatalogMetadata() async {
|
||||
final count = await _db.delete(metadataTable, where: '1');
|
||||
debugPrint('$runtimeType clearMetadataEntries deleted $count rows');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<CatalogMetadata>> loadAllMetadataEntries() async {
|
||||
final db = await _database;
|
||||
final maps = await db.query(metadataTable);
|
||||
final metadataEntries = maps.map(CatalogMetadata.fromMap).toList();
|
||||
return metadataEntries;
|
||||
Future<Set<CatalogMetadata>> loadCatalogMetadata() async {
|
||||
final rows = await _db.query(metadataTable);
|
||||
return rows.map(CatalogMetadata.fromMap).toSet();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveMetadata(Set<CatalogMetadata> metadataEntries) async {
|
||||
Future<Set<CatalogMetadata>> loadCatalogMetadataById(Iterable<int> ids) => _getByIds(ids, metadataTable, CatalogMetadata.fromMap);
|
||||
|
||||
@override
|
||||
Future<void> saveCatalogMetadata(Set<CatalogMetadata> metadataEntries) async {
|
||||
if (metadataEntries.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
try {
|
||||
final db = await _database;
|
||||
final batch = db.batch();
|
||||
final batch = _db.batch();
|
||||
metadataEntries.forEach((metadata) => _batchInsertMetadata(batch, metadata));
|
||||
await batch.commit(noResult: true);
|
||||
debugPrint('$runtimeType saveMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries');
|
||||
|
@ -326,11 +272,10 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
}
|
||||
|
||||
@override
|
||||
Future<void> updateMetadataId(int oldId, CatalogMetadata? metadata) async {
|
||||
final db = await _database;
|
||||
final batch = db.batch();
|
||||
batch.delete(dateTakenTable, where: 'contentId = ?', whereArgs: [oldId]);
|
||||
batch.delete(metadataTable, where: 'contentId = ?', whereArgs: [oldId]);
|
||||
Future<void> updateCatalogMetadata(int id, CatalogMetadata? metadata) async {
|
||||
final batch = _db.batch();
|
||||
batch.delete(dateTakenTable, where: 'id = ?', whereArgs: [id]);
|
||||
batch.delete(metadataTable, where: 'id = ?', whereArgs: [id]);
|
||||
_batchInsertMetadata(batch, metadata);
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
@ -341,7 +286,7 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
batch.insert(
|
||||
dateTakenTable,
|
||||
{
|
||||
'contentId': metadata.contentId,
|
||||
'id': metadata.id,
|
||||
'dateMillis': metadata.dateMillis,
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
|
@ -358,35 +303,33 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
|
||||
@override
|
||||
Future<void> clearAddresses() async {
|
||||
final db = await _database;
|
||||
final count = await db.delete(addressTable, where: '1');
|
||||
final count = await _db.delete(addressTable, where: '1');
|
||||
debugPrint('$runtimeType clearAddresses deleted $count rows');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<AddressDetails>> loadAllAddresses() async {
|
||||
final db = await _database;
|
||||
final maps = await db.query(addressTable);
|
||||
final addresses = maps.map(AddressDetails.fromMap).toList();
|
||||
return addresses;
|
||||
Future<Set<AddressDetails>> loadAddresses() async {
|
||||
final rows = await _db.query(addressTable);
|
||||
return rows.map(AddressDetails.fromMap).toSet();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Set<AddressDetails>> loadAddressesById(Iterable<int> ids) => _getByIds(ids, addressTable, AddressDetails.fromMap);
|
||||
|
||||
@override
|
||||
Future<void> saveAddresses(Set<AddressDetails> addresses) async {
|
||||
if (addresses.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final db = await _database;
|
||||
final batch = db.batch();
|
||||
final batch = _db.batch();
|
||||
addresses.forEach((address) => _batchInsertAddress(batch, address));
|
||||
await batch.commit(noResult: true);
|
||||
debugPrint('$runtimeType saveAddresses complete in ${stopwatch.elapsed.inMilliseconds}ms for ${addresses.length} entries');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateAddressId(int oldId, AddressDetails? address) async {
|
||||
final db = await _database;
|
||||
final batch = db.batch();
|
||||
batch.delete(addressTable, where: 'contentId = ?', whereArgs: [oldId]);
|
||||
Future<void> updateAddress(int id, AddressDetails? address) async {
|
||||
final batch = _db.batch();
|
||||
batch.delete(addressTable, where: 'id = ?', whereArgs: [id]);
|
||||
_batchInsertAddress(batch, address);
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
@ -400,37 +343,63 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
);
|
||||
}
|
||||
|
||||
// trash
|
||||
|
||||
@override
|
||||
Future<void> clearTrashDetails() async {
|
||||
final count = await _db.delete(trashTable, where: '1');
|
||||
debugPrint('$runtimeType clearTrashDetails deleted $count rows');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Set<TrashDetails>> loadAllTrashDetails() async {
|
||||
final rows = await _db.query(trashTable);
|
||||
return rows.map(TrashDetails.fromMap).toSet();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateTrash(int id, TrashDetails? details) async {
|
||||
final batch = _db.batch();
|
||||
batch.delete(trashTable, where: 'id = ?', whereArgs: [id]);
|
||||
_batchInsertTrashDetails(batch, details);
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
void _batchInsertTrashDetails(Batch batch, TrashDetails? details) {
|
||||
if (details == null) return;
|
||||
batch.insert(
|
||||
trashTable,
|
||||
details.toMap(),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
// favourites
|
||||
|
||||
@override
|
||||
Future<void> clearFavourites() async {
|
||||
final db = await _database;
|
||||
final count = await db.delete(favouriteTable, where: '1');
|
||||
final count = await _db.delete(favouriteTable, where: '1');
|
||||
debugPrint('$runtimeType clearFavourites deleted $count rows');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Set<FavouriteRow>> loadAllFavourites() async {
|
||||
final db = await _database;
|
||||
final maps = await db.query(favouriteTable);
|
||||
final rows = maps.map(FavouriteRow.fromMap).toSet();
|
||||
return rows;
|
||||
final rows = await _db.query(favouriteTable);
|
||||
return rows.map(FavouriteRow.fromMap).toSet();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> addFavourites(Iterable<FavouriteRow> rows) async {
|
||||
if (rows.isEmpty) return;
|
||||
final db = await _database;
|
||||
final batch = db.batch();
|
||||
final batch = _db.batch();
|
||||
rows.forEach((row) => _batchInsertFavourite(batch, row));
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateFavouriteId(int oldId, FavouriteRow row) async {
|
||||
final db = await _database;
|
||||
final batch = db.batch();
|
||||
batch.delete(favouriteTable, where: 'contentId = ?', whereArgs: [oldId]);
|
||||
Future<void> updateFavouriteId(int id, FavouriteRow row) async {
|
||||
final batch = _db.batch();
|
||||
batch.delete(favouriteTable, where: 'id = ?', whereArgs: [id]);
|
||||
_batchInsertFavourite(batch, row);
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
@ -446,13 +415,12 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
@override
|
||||
Future<void> removeFavourites(Iterable<FavouriteRow> rows) async {
|
||||
if (rows.isEmpty) return;
|
||||
final ids = rows.map((row) => row.contentId);
|
||||
final ids = rows.map((row) => row.entryId);
|
||||
if (ids.isEmpty) return;
|
||||
|
||||
final db = await _database;
|
||||
// using array in `whereArgs` and using it with `where contentId IN ?` is a pain, so we prefer `batch` instead
|
||||
final batch = db.batch();
|
||||
ids.forEach((id) => batch.delete(favouriteTable, where: 'contentId = ?', whereArgs: [id]));
|
||||
// using array in `whereArgs` and using it with `where id IN ?` is a pain, so we prefer `batch` instead
|
||||
final batch = _db.batch();
|
||||
ids.forEach((id) => batch.delete(favouriteTable, where: 'id = ?', whereArgs: [id]));
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
|
@ -460,34 +428,29 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
|
||||
@override
|
||||
Future<void> clearCovers() async {
|
||||
final db = await _database;
|
||||
final count = await db.delete(coverTable, where: '1');
|
||||
final count = await _db.delete(coverTable, where: '1');
|
||||
debugPrint('$runtimeType clearCovers deleted $count rows');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Set<CoverRow>> loadAllCovers() async {
|
||||
final db = await _database;
|
||||
final maps = await db.query(coverTable);
|
||||
final rows = maps.map(CoverRow.fromMap).whereNotNull().toSet();
|
||||
return rows;
|
||||
final rows = await _db.query(coverTable);
|
||||
return rows.map(CoverRow.fromMap).whereNotNull().toSet();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> addCovers(Iterable<CoverRow> rows) async {
|
||||
if (rows.isEmpty) return;
|
||||
|
||||
final db = await _database;
|
||||
final batch = db.batch();
|
||||
final batch = _db.batch();
|
||||
rows.forEach((row) => _batchInsertCover(batch, row));
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateCoverEntryId(int oldId, CoverRow row) async {
|
||||
final db = await _database;
|
||||
final batch = db.batch();
|
||||
batch.delete(coverTable, where: 'contentId = ?', whereArgs: [oldId]);
|
||||
Future<void> updateCoverEntryId(int id, CoverRow row) async {
|
||||
final batch = _db.batch();
|
||||
batch.delete(coverTable, where: 'entryId = ?', whereArgs: [id]);
|
||||
_batchInsertCover(batch, row);
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
@ -504,9 +467,8 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
Future<void> removeCovers(Set<CollectionFilter> filters) async {
|
||||
if (filters.isEmpty) return;
|
||||
|
||||
final db = await _database;
|
||||
// using array in `whereArgs` and using it with `where filter IN ?` is a pain, so we prefer `batch` instead
|
||||
final batch = db.batch();
|
||||
final batch = _db.batch();
|
||||
filters.forEach((filter) => batch.delete(coverTable, where: 'filter = ?', whereArgs: [filter.toJson()]));
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
@ -515,36 +477,31 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
|
||||
@override
|
||||
Future<void> clearVideoPlayback() async {
|
||||
final db = await _database;
|
||||
final count = await db.delete(videoPlaybackTable, where: '1');
|
||||
final count = await _db.delete(videoPlaybackTable, where: '1');
|
||||
debugPrint('$runtimeType clearVideoPlayback deleted $count rows');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Set<VideoPlaybackRow>> loadAllVideoPlayback() async {
|
||||
final db = await _database;
|
||||
final maps = await db.query(videoPlaybackTable);
|
||||
final rows = maps.map(VideoPlaybackRow.fromMap).whereNotNull().toSet();
|
||||
return rows;
|
||||
final rows = await _db.query(videoPlaybackTable);
|
||||
return rows.map(VideoPlaybackRow.fromMap).whereNotNull().toSet();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<VideoPlaybackRow?> loadVideoPlayback(int? contentId) async {
|
||||
if (contentId == null) return null;
|
||||
Future<VideoPlaybackRow?> loadVideoPlayback(int? id) async {
|
||||
if (id == null) return null;
|
||||
|
||||
final db = await _database;
|
||||
final maps = await db.query(videoPlaybackTable, where: 'contentId = ?', whereArgs: [contentId]);
|
||||
if (maps.isEmpty) return null;
|
||||
final rows = await _db.query(videoPlaybackTable, where: 'id = ?', whereArgs: [id]);
|
||||
if (rows.isEmpty) return null;
|
||||
|
||||
return VideoPlaybackRow.fromMap(maps.first);
|
||||
return VideoPlaybackRow.fromMap(rows.first);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> addVideoPlayback(Set<VideoPlaybackRow> rows) async {
|
||||
if (rows.isEmpty) return;
|
||||
|
||||
final db = await _database;
|
||||
final batch = db.batch();
|
||||
final batch = _db.batch();
|
||||
rows.forEach((row) => _batchInsertVideoPlayback(batch, row));
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
@ -558,23 +515,23 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
}
|
||||
|
||||
@override
|
||||
Future<void> updateVideoPlaybackId(int oldId, int? newId) async {
|
||||
if (newId != null) {
|
||||
final db = await _database;
|
||||
await db.update(videoPlaybackTable, {'contentId': newId}, where: 'contentId = ?', whereArgs: [oldId]);
|
||||
} else {
|
||||
await removeVideoPlayback({oldId});
|
||||
}
|
||||
}
|
||||
Future<void> removeVideoPlayback(Iterable<int> ids) async {
|
||||
if (ids.isEmpty) return;
|
||||
|
||||
@override
|
||||
Future<void> removeVideoPlayback(Set<int> contentIds) async {
|
||||
if (contentIds.isEmpty) return;
|
||||
|
||||
final db = await _database;
|
||||
// using array in `whereArgs` and using it with `where filter IN ?` is a pain, so we prefer `batch` instead
|
||||
final batch = db.batch();
|
||||
contentIds.forEach((id) => batch.delete(videoPlaybackTable, where: 'contentId = ?', whereArgs: [id]));
|
||||
final batch = _db.batch();
|
||||
ids.forEach((id) => batch.delete(videoPlaybackTable, where: 'id = ?', whereArgs: [id]));
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
// convenience methods
|
||||
|
||||
Future<Set<T>> _getByIds<T>(Iterable<int> ids, String table, T Function(Map<String, Object?> row) mapRow) async {
|
||||
if (ids.isEmpty) return {};
|
||||
final rows = await _db.query(
|
||||
table,
|
||||
where: 'id IN (${ids.join(',')})',
|
||||
);
|
||||
return rows.map(mapRow).toSet();
|
||||
}
|
||||
}
|
272
lib/model/db/db_metadata_sqflite_upgrade.dart
Normal file
|
@ -0,0 +1,272 @@
|
|||
import 'package:aves/model/db/db_metadata_sqflite.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
class MetadataDbUpgrader {
|
||||
static const entryTable = SqfliteMetadataDb.entryTable;
|
||||
static const dateTakenTable = SqfliteMetadataDb.dateTakenTable;
|
||||
static const metadataTable = SqfliteMetadataDb.metadataTable;
|
||||
static const addressTable = SqfliteMetadataDb.addressTable;
|
||||
static const favouriteTable = SqfliteMetadataDb.favouriteTable;
|
||||
static const coverTable = SqfliteMetadataDb.coverTable;
|
||||
static const trashTable = SqfliteMetadataDb.trashTable;
|
||||
static const videoPlaybackTable = SqfliteMetadataDb.videoPlaybackTable;
|
||||
|
||||
// warning: "ALTER TABLE ... RENAME COLUMN ..." is not supported
|
||||
// on SQLite <3.25.0, bundled on older Android devices
|
||||
static Future<void> upgradeDb(Database db, int oldVersion, int newVersion) async {
|
||||
while (oldVersion < newVersion) {
|
||||
switch (oldVersion) {
|
||||
case 1:
|
||||
await _upgradeFrom1(db);
|
||||
break;
|
||||
case 2:
|
||||
await _upgradeFrom2(db);
|
||||
break;
|
||||
case 3:
|
||||
await _upgradeFrom3(db);
|
||||
break;
|
||||
case 4:
|
||||
await _upgradeFrom4(db);
|
||||
break;
|
||||
case 5:
|
||||
await _upgradeFrom5(db);
|
||||
break;
|
||||
case 6:
|
||||
await _upgradeFrom6(db);
|
||||
break;
|
||||
}
|
||||
oldVersion++;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _upgradeFrom1(Database db) async {
|
||||
debugPrint('upgrading DB from v1');
|
||||
// rename column 'orientationDegrees' to 'sourceRotationDegrees'
|
||||
await db.transaction((txn) async {
|
||||
const newEntryTable = '${entryTable}TEMP';
|
||||
await db.execute('CREATE TABLE $newEntryTable('
|
||||
'contentId INTEGER PRIMARY KEY'
|
||||
', uri TEXT'
|
||||
', path TEXT'
|
||||
', sourceMimeType TEXT'
|
||||
', width INTEGER'
|
||||
', height INTEGER'
|
||||
', sourceRotationDegrees INTEGER'
|
||||
', sizeBytes INTEGER'
|
||||
', title TEXT'
|
||||
', dateModifiedSecs INTEGER'
|
||||
', sourceDateTakenMillis INTEGER'
|
||||
', durationMillis INTEGER'
|
||||
')');
|
||||
await db.rawInsert('INSERT INTO $newEntryTable(contentId,uri,path,sourceMimeType,width,height,sourceRotationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis)'
|
||||
' SELECT contentId,uri,path,sourceMimeType,width,height,orientationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis'
|
||||
' FROM $entryTable;');
|
||||
await db.execute('DROP TABLE $entryTable;');
|
||||
await db.execute('ALTER TABLE $newEntryTable RENAME TO $entryTable;');
|
||||
});
|
||||
|
||||
// rename column 'videoRotation' to 'rotationDegrees'
|
||||
await db.transaction((txn) async {
|
||||
const newMetadataTable = '${metadataTable}TEMP';
|
||||
await db.execute('CREATE TABLE $newMetadataTable('
|
||||
'contentId INTEGER PRIMARY KEY'
|
||||
', mimeType TEXT'
|
||||
', dateMillis INTEGER'
|
||||
', isAnimated INTEGER'
|
||||
', rotationDegrees INTEGER'
|
||||
', xmpSubjects TEXT'
|
||||
', xmpTitleDescription TEXT'
|
||||
', latitude REAL'
|
||||
', longitude REAL'
|
||||
')');
|
||||
await db.rawInsert('INSERT INTO $newMetadataTable(contentId,mimeType,dateMillis,isAnimated,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude)'
|
||||
' SELECT contentId,mimeType,dateMillis,isAnimated,videoRotation,xmpSubjects,xmpTitleDescription,latitude,longitude'
|
||||
' FROM $metadataTable;');
|
||||
await db.rawInsert('UPDATE $newMetadataTable SET rotationDegrees = NULL WHERE rotationDegrees = 0;');
|
||||
await db.execute('DROP TABLE $metadataTable;');
|
||||
await db.execute('ALTER TABLE $newMetadataTable RENAME TO $metadataTable;');
|
||||
});
|
||||
|
||||
// new column 'isFlipped'
|
||||
await db.execute('ALTER TABLE $metadataTable ADD COLUMN isFlipped INTEGER;');
|
||||
}
|
||||
|
||||
static Future<void> _upgradeFrom2(Database db) async {
|
||||
debugPrint('upgrading DB from v2');
|
||||
// merge columns 'isAnimated' and 'isFlipped' into 'flags'
|
||||
await db.transaction((txn) async {
|
||||
const newMetadataTable = '${metadataTable}TEMP';
|
||||
await db.execute('CREATE TABLE $newMetadataTable('
|
||||
'contentId INTEGER PRIMARY KEY'
|
||||
', mimeType TEXT'
|
||||
', dateMillis INTEGER'
|
||||
', flags INTEGER'
|
||||
', rotationDegrees INTEGER'
|
||||
', xmpSubjects TEXT'
|
||||
', xmpTitleDescription TEXT'
|
||||
', latitude REAL'
|
||||
', longitude REAL'
|
||||
')');
|
||||
await db.rawInsert('INSERT INTO $newMetadataTable(contentId,mimeType,dateMillis,flags,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude)'
|
||||
' SELECT contentId,mimeType,dateMillis,ifnull(isAnimated,0)+ifnull(isFlipped,0)*2,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude'
|
||||
' FROM $metadataTable;');
|
||||
await db.execute('DROP TABLE $metadataTable;');
|
||||
await db.execute('ALTER TABLE $newMetadataTable RENAME TO $metadataTable;');
|
||||
});
|
||||
}
|
||||
|
||||
static Future<void> _upgradeFrom3(Database db) async {
|
||||
debugPrint('upgrading DB from v3');
|
||||
await db.execute('CREATE TABLE $coverTable('
|
||||
'filter TEXT PRIMARY KEY'
|
||||
', contentId INTEGER'
|
||||
')');
|
||||
}
|
||||
|
||||
static Future<void> _upgradeFrom4(Database db) async {
|
||||
debugPrint('upgrading DB from v4');
|
||||
await db.execute('CREATE TABLE $videoPlaybackTable('
|
||||
'contentId INTEGER PRIMARY KEY'
|
||||
', resumeTimeMillis INTEGER'
|
||||
')');
|
||||
}
|
||||
|
||||
static Future<void> _upgradeFrom5(Database db) async {
|
||||
debugPrint('upgrading DB from v5');
|
||||
await db.execute('ALTER TABLE $metadataTable ADD COLUMN rating INTEGER;');
|
||||
}
|
||||
|
||||
static Future<void> _upgradeFrom6(Database db) async {
|
||||
debugPrint('upgrading DB from v6');
|
||||
// new primary key column `id` instead of `contentId`
|
||||
// new column `trashed`
|
||||
await db.transaction((txn) async {
|
||||
const newEntryTable = '${entryTable}TEMP';
|
||||
await db.execute('CREATE TABLE $newEntryTable('
|
||||
'id INTEGER PRIMARY KEY'
|
||||
', contentId INTEGER'
|
||||
', uri TEXT'
|
||||
', path TEXT'
|
||||
', sourceMimeType TEXT'
|
||||
', width INTEGER'
|
||||
', height INTEGER'
|
||||
', sourceRotationDegrees INTEGER'
|
||||
', sizeBytes INTEGER'
|
||||
', title TEXT'
|
||||
', dateModifiedSecs INTEGER'
|
||||
', sourceDateTakenMillis INTEGER'
|
||||
', durationMillis INTEGER'
|
||||
', trashed INTEGER DEFAULT 0'
|
||||
')');
|
||||
await db.rawInsert('INSERT INTO $newEntryTable(id,contentId,uri,path,sourceMimeType,width,height,sourceRotationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis)'
|
||||
' SELECT contentId,contentId,uri,path,sourceMimeType,width,height,sourceRotationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis'
|
||||
' FROM $entryTable;');
|
||||
await db.execute('DROP TABLE $entryTable;');
|
||||
await db.execute('ALTER TABLE $newEntryTable RENAME TO $entryTable;');
|
||||
});
|
||||
|
||||
// rename column `contentId` to `id`
|
||||
await db.transaction((txn) async {
|
||||
const newDateTakenTable = '${dateTakenTable}TEMP';
|
||||
await db.execute('CREATE TABLE $newDateTakenTable('
|
||||
'id INTEGER PRIMARY KEY'
|
||||
', dateMillis INTEGER'
|
||||
')');
|
||||
await db.rawInsert('INSERT INTO $newDateTakenTable(id,dateMillis)'
|
||||
' SELECT contentId,dateMillis'
|
||||
' FROM $dateTakenTable;');
|
||||
await db.execute('DROP TABLE $dateTakenTable;');
|
||||
await db.execute('ALTER TABLE $newDateTakenTable RENAME TO $dateTakenTable;');
|
||||
});
|
||||
|
||||
// rename column `contentId` to `id`
|
||||
await db.transaction((txn) async {
|
||||
const newMetadataTable = '${metadataTable}TEMP';
|
||||
await db.execute('CREATE TABLE $newMetadataTable('
|
||||
'id INTEGER PRIMARY KEY'
|
||||
', mimeType TEXT'
|
||||
', dateMillis INTEGER'
|
||||
', flags INTEGER'
|
||||
', rotationDegrees INTEGER'
|
||||
', xmpSubjects TEXT'
|
||||
', xmpTitleDescription TEXT'
|
||||
', latitude REAL'
|
||||
', longitude REAL'
|
||||
', rating INTEGER'
|
||||
')');
|
||||
await db.rawInsert('INSERT INTO $newMetadataTable(id,mimeType,dateMillis,flags,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude,rating)'
|
||||
' SELECT contentId,mimeType,dateMillis,flags,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude,rating'
|
||||
' FROM $metadataTable;');
|
||||
await db.execute('DROP TABLE $metadataTable;');
|
||||
await db.execute('ALTER TABLE $newMetadataTable RENAME TO $metadataTable;');
|
||||
});
|
||||
|
||||
// rename column `contentId` to `id`
|
||||
await db.transaction((txn) async {
|
||||
const newAddressTable = '${addressTable}TEMP';
|
||||
await db.execute('CREATE TABLE $newAddressTable('
|
||||
'id INTEGER PRIMARY KEY'
|
||||
', addressLine TEXT'
|
||||
', countryCode TEXT'
|
||||
', countryName TEXT'
|
||||
', adminArea TEXT'
|
||||
', locality TEXT'
|
||||
')');
|
||||
await db.rawInsert('INSERT INTO $newAddressTable(id,addressLine,countryCode,countryName,adminArea,locality)'
|
||||
' SELECT contentId,addressLine,countryCode,countryName,adminArea,locality'
|
||||
' FROM $addressTable;');
|
||||
await db.execute('DROP TABLE $addressTable;');
|
||||
await db.execute('ALTER TABLE $newAddressTable RENAME TO $addressTable;');
|
||||
});
|
||||
|
||||
// rename column `contentId` to `id`
|
||||
await db.transaction((txn) async {
|
||||
const newVideoPlaybackTable = '${videoPlaybackTable}TEMP';
|
||||
await db.execute('CREATE TABLE $newVideoPlaybackTable('
|
||||
'id INTEGER PRIMARY KEY'
|
||||
', resumeTimeMillis INTEGER'
|
||||
')');
|
||||
await db.rawInsert('INSERT INTO $newVideoPlaybackTable(id,resumeTimeMillis)'
|
||||
' SELECT contentId,resumeTimeMillis'
|
||||
' FROM $videoPlaybackTable;');
|
||||
await db.execute('DROP TABLE $videoPlaybackTable;');
|
||||
await db.execute('ALTER TABLE $newVideoPlaybackTable RENAME TO $videoPlaybackTable;');
|
||||
});
|
||||
|
||||
// rename column `contentId` to `id`
|
||||
// remove column `path`
|
||||
await db.transaction((txn) async {
|
||||
const newFavouriteTable = '${favouriteTable}TEMP';
|
||||
await db.execute('CREATE TABLE $newFavouriteTable('
|
||||
'id INTEGER PRIMARY KEY'
|
||||
')');
|
||||
await db.rawInsert('INSERT INTO $newFavouriteTable(id)'
|
||||
' SELECT contentId'
|
||||
' FROM $favouriteTable;');
|
||||
await db.execute('DROP TABLE $favouriteTable;');
|
||||
await db.execute('ALTER TABLE $newFavouriteTable RENAME TO $favouriteTable;');
|
||||
});
|
||||
|
||||
// rename column `contentId` to `entryId`
|
||||
await db.transaction((txn) async {
|
||||
const newCoverTable = '${coverTable}TEMP';
|
||||
await db.execute('CREATE TABLE $newCoverTable('
|
||||
'filter TEXT PRIMARY KEY'
|
||||
', entryId INTEGER'
|
||||
')');
|
||||
await db.rawInsert('INSERT INTO $newCoverTable(filter,entryId)'
|
||||
' SELECT filter,contentId'
|
||||
' FROM $coverTable;');
|
||||
await db.execute('DROP TABLE $coverTable;');
|
||||
await db.execute('ALTER TABLE $newCoverTable RENAME TO $coverTable;');
|
||||
});
|
||||
|
||||
// new table
|
||||
await db.execute('CREATE TABLE $trashTable('
|
||||
'id INTEGER PRIMARY KEY'
|
||||
', path TEXT'
|
||||
', dateMillis INTEGER'
|
||||
')');
|
||||
}
|
||||
}
|
|
@ -7,7 +7,9 @@ import 'package:aves/model/entry_cache.dart';
|
|||
import 'package:aves/model/favourites.dart';
|
||||
import 'package:aves/model/metadata/address.dart';
|
||||
import 'package:aves/model/metadata/catalog.dart';
|
||||
import 'package:aves/model/metadata/trash.dart';
|
||||
import 'package:aves/model/multipage.dart';
|
||||
import 'package:aves/model/source/trash.dart';
|
||||
import 'package:aves/model/video/metadata.dart';
|
||||
import 'package:aves/ref/mime_types.dart';
|
||||
import 'package:aves/services/common/service_policy.dart';
|
||||
|
@ -25,29 +27,27 @@ import 'package:latlong2/latlong.dart';
|
|||
enum EntryDataType { basic, catalog, address, references }
|
||||
|
||||
class AvesEntry {
|
||||
// `sizeBytes`, `dateModifiedSecs` can be missing in viewer mode
|
||||
int id;
|
||||
String uri;
|
||||
String? _path, _directory, _filename, _extension;
|
||||
String? _path, _directory, _filename, _extension, _sourceTitle;
|
||||
int? pageId, contentId;
|
||||
final String sourceMimeType;
|
||||
int width;
|
||||
int height;
|
||||
int sourceRotationDegrees;
|
||||
int? sizeBytes;
|
||||
String? _sourceTitle;
|
||||
int width, height, sourceRotationDegrees;
|
||||
int? sizeBytes, _dateModifiedSecs, sourceDateTakenMillis, _durationMillis;
|
||||
bool trashed;
|
||||
|
||||
// `dateModifiedSecs` can be missing in viewer mode
|
||||
int? _dateModifiedSecs;
|
||||
int? sourceDateTakenMillis;
|
||||
int? _durationMillis;
|
||||
int? _catalogDateMillis;
|
||||
CatalogMetadata? _catalogMetadata;
|
||||
AddressDetails? _addressDetails;
|
||||
TrashDetails? trashDetails;
|
||||
|
||||
List<AvesEntry>? burstEntries;
|
||||
|
||||
final AChangeNotifier imageChangeNotifier = AChangeNotifier(), metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier();
|
||||
|
||||
AvesEntry({
|
||||
required int? id,
|
||||
required this.uri,
|
||||
required String? path,
|
||||
required this.contentId,
|
||||
|
@ -61,8 +61,9 @@ class AvesEntry {
|
|||
required int? dateModifiedSecs,
|
||||
required this.sourceDateTakenMillis,
|
||||
required int? durationMillis,
|
||||
required this.trashed,
|
||||
this.burstEntries,
|
||||
}) {
|
||||
}) : id = id ?? 0 {
|
||||
this.path = path;
|
||||
this.sourceTitle = sourceTitle;
|
||||
this.dateModifiedSecs = dateModifiedSecs;
|
||||
|
@ -74,6 +75,7 @@ class AvesEntry {
|
|||
bool get canHaveAlpha => MimeTypes.alphaImages.contains(mimeType);
|
||||
|
||||
AvesEntry copyWith({
|
||||
int? id,
|
||||
String? uri,
|
||||
String? path,
|
||||
int? contentId,
|
||||
|
@ -81,11 +83,12 @@ class AvesEntry {
|
|||
int? dateModifiedSecs,
|
||||
List<AvesEntry>? burstEntries,
|
||||
}) {
|
||||
final copyContentId = contentId ?? this.contentId;
|
||||
final copyEntryId = id ?? this.id;
|
||||
final copied = AvesEntry(
|
||||
id: copyEntryId,
|
||||
uri: uri ?? this.uri,
|
||||
path: path ?? this.path,
|
||||
contentId: copyContentId,
|
||||
contentId: contentId ?? this.contentId,
|
||||
pageId: null,
|
||||
sourceMimeType: sourceMimeType,
|
||||
width: width,
|
||||
|
@ -96,10 +99,12 @@ class AvesEntry {
|
|||
dateModifiedSecs: dateModifiedSecs ?? this.dateModifiedSecs,
|
||||
sourceDateTakenMillis: sourceDateTakenMillis,
|
||||
durationMillis: durationMillis,
|
||||
trashed: trashed,
|
||||
burstEntries: burstEntries ?? this.burstEntries,
|
||||
)
|
||||
..catalogMetadata = _catalogMetadata?.copyWith(contentId: copyContentId)
|
||||
..addressDetails = _addressDetails?.copyWith(contentId: copyContentId);
|
||||
..catalogMetadata = _catalogMetadata?.copyWith(id: copyEntryId)
|
||||
..addressDetails = _addressDetails?.copyWith(id: copyEntryId)
|
||||
..trashDetails = trashDetails?.copyWith(id: copyEntryId);
|
||||
|
||||
return copied;
|
||||
}
|
||||
|
@ -107,6 +112,7 @@ class AvesEntry {
|
|||
// from DB or platform source entry
|
||||
factory AvesEntry.fromMap(Map map) {
|
||||
return AvesEntry(
|
||||
id: map['id'] as int?,
|
||||
uri: map['uri'] as String,
|
||||
path: map['path'] as String?,
|
||||
pageId: null,
|
||||
|
@ -120,12 +126,14 @@ class AvesEntry {
|
|||
dateModifiedSecs: map['dateModifiedSecs'] as int?,
|
||||
sourceDateTakenMillis: map['sourceDateTakenMillis'] as int?,
|
||||
durationMillis: map['durationMillis'] as int?,
|
||||
trashed: (map['trashed'] as int? ?? 0) != 0,
|
||||
);
|
||||
}
|
||||
|
||||
// for DB only
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'id': id,
|
||||
'uri': uri,
|
||||
'path': path,
|
||||
'contentId': contentId,
|
||||
|
@ -138,6 +146,7 @@ class AvesEntry {
|
|||
'dateModifiedSecs': dateModifiedSecs,
|
||||
'sourceDateTakenMillis': sourceDateTakenMillis,
|
||||
'durationMillis': durationMillis,
|
||||
'trashed': trashed ? 1 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -151,7 +160,7 @@ class AvesEntry {
|
|||
// so that we can reliably use instances in a `Set`, which requires consistent hash codes over time
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, path=$path, pageId=$pageId}';
|
||||
String toString() => '$runtimeType#${shortHash(this)}{id=$id, uri=$uri, path=$path, pageId=$pageId}';
|
||||
|
||||
set path(String? path) {
|
||||
_path = path;
|
||||
|
@ -179,7 +188,10 @@ class AvesEntry {
|
|||
return _extension;
|
||||
}
|
||||
|
||||
bool get isMissingAtPath => path != null && !File(path!).existsSync();
|
||||
bool get isMissingAtPath {
|
||||
final effectivePath = trashed ? trashDetails?.path : path;
|
||||
return effectivePath != null && !File(effectivePath).existsSync();
|
||||
}
|
||||
|
||||
// the MIME type reported by the Media Store is unreliable
|
||||
// so we use the one found during cataloguing if possible
|
||||
|
@ -233,7 +245,7 @@ class AvesEntry {
|
|||
|
||||
bool get is360 => _catalogMetadata?.is360 ?? false;
|
||||
|
||||
bool get canEdit => path != null;
|
||||
bool get canEdit => path != null && !trashed;
|
||||
|
||||
bool get canEditDate => canEdit && (canEditExif || canEditXmp);
|
||||
|
||||
|
@ -408,6 +420,18 @@ class AvesEntry {
|
|||
return _durationText!;
|
||||
}
|
||||
|
||||
bool get isExpiredTrash {
|
||||
final dateMillis = trashDetails?.dateMillis;
|
||||
if (dateMillis == null) return false;
|
||||
return DateTime.fromMillisecondsSinceEpoch(dateMillis).add(TrashMixin.binKeepDuration).isBefore(DateTime.now());
|
||||
}
|
||||
|
||||
int? get trashDaysLeft {
|
||||
final dateMillis = trashDetails?.dateMillis;
|
||||
if (dateMillis == null) return null;
|
||||
return DateTime.fromMillisecondsSinceEpoch(dateMillis).add(TrashMixin.binKeepDuration).difference(DateTime.now()).inDays;
|
||||
}
|
||||
|
||||
// returns whether this entry has GPS coordinates
|
||||
// (0, 0) coordinates are considered invalid, as it is likely a default value
|
||||
bool get hasGps => (_catalogMetadata?.latitude ?? 0) != 0 || (_catalogMetadata?.longitude ?? 0) != 0;
|
||||
|
@ -476,7 +500,7 @@ class AvesEntry {
|
|||
};
|
||||
await applyNewFields(fields, persist: persist);
|
||||
}
|
||||
catalogMetadata = CatalogMetadata(contentId: contentId);
|
||||
catalogMetadata = CatalogMetadata(id: id);
|
||||
} else {
|
||||
if (isVideo && (!isSized || durationMillis == 0)) {
|
||||
// exotic video that is not sized during loading
|
||||
|
@ -519,7 +543,7 @@ class AvesEntry {
|
|||
void setCountry(CountryCode? countryCode) {
|
||||
if (hasFineAddress || countryCode == null) return;
|
||||
addressDetails = AddressDetails(
|
||||
contentId: contentId,
|
||||
id: id,
|
||||
countryCode: countryCode.alpha2,
|
||||
countryName: countryCode.alpha3,
|
||||
);
|
||||
|
@ -542,7 +566,7 @@ class AvesEntry {
|
|||
final cn = address.countryName;
|
||||
final aa = address.adminArea;
|
||||
addressDetails = AddressDetails(
|
||||
contentId: contentId,
|
||||
id: id,
|
||||
countryCode: cc,
|
||||
countryName: cn,
|
||||
adminArea: aa,
|
||||
|
@ -619,7 +643,7 @@ class AvesEntry {
|
|||
|
||||
if (persist) {
|
||||
await metadataDb.saveEntries({this});
|
||||
if (catalogMetadata != null) await metadataDb.saveMetadata({catalogMetadata!});
|
||||
if (catalogMetadata != null) await metadataDb.saveCatalogMetadata({catalogMetadata!});
|
||||
}
|
||||
|
||||
await _onVisualFieldChanged(oldMimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
|
||||
|
@ -638,7 +662,7 @@ class AvesEntry {
|
|||
_tags = null;
|
||||
|
||||
if (persist) {
|
||||
await metadataDb.removeIds({contentId!}, dataTypes: dataTypes);
|
||||
await metadataDb.removeIds({id}, dataTypes: dataTypes);
|
||||
}
|
||||
|
||||
final updatedEntry = await mediaFileService.getEntry(uri, mimeType);
|
||||
|
@ -689,7 +713,7 @@ class AvesEntry {
|
|||
|
||||
Future<void> removeFromFavourites() async {
|
||||
if (isFavourite) {
|
||||
await favourites.remove({this});
|
||||
await favourites.removeEntries({this});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -720,7 +744,7 @@ class AvesEntry {
|
|||
pages: burstEntries!
|
||||
.mapIndexed((index, entry) => SinglePageInfo(
|
||||
index: index,
|
||||
pageId: entry.contentId!,
|
||||
pageId: entry.id,
|
||||
isDefault: index == 0,
|
||||
uri: entry.uri,
|
||||
mimeType: entry.mimeType,
|
||||
|
|
|
@ -22,7 +22,7 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
|
|||
|
||||
final appliedModifier = await _applyDateModifierToEntry(userModifier);
|
||||
if (appliedModifier == null) {
|
||||
await reportService.recordError('failed to get date for modifier=$userModifier, uri=$uri', null);
|
||||
await reportService.recordError('failed to get date for modifier=$userModifier, entry=$this', null);
|
||||
return {};
|
||||
}
|
||||
|
||||
|
@ -259,7 +259,7 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
|
|||
);
|
||||
}
|
||||
|
||||
// convenience
|
||||
// convenience methods
|
||||
|
||||
// This method checks whether the item already has a metadata date,
|
||||
// and adds a date (the file modified date) via Exif if possible.
|
||||
|
|
|
@ -19,11 +19,11 @@ class Favourites with ChangeNotifier {
|
|||
|
||||
int get count => _rows.length;
|
||||
|
||||
Set<int> get all => Set.unmodifiable(_rows.map((v) => v.contentId));
|
||||
Set<int> get all => Set.unmodifiable(_rows.map((v) => v.entryId));
|
||||
|
||||
bool isFavourite(AvesEntry entry) => _rows.any((row) => row.contentId == entry.contentId);
|
||||
bool isFavourite(AvesEntry entry) => _rows.any((row) => row.entryId == entry.id);
|
||||
|
||||
FavouriteRow _entryToRow(AvesEntry entry) => FavouriteRow(contentId: entry.contentId!, path: entry.path!);
|
||||
FavouriteRow _entryToRow(AvesEntry entry) => FavouriteRow(entryId: entry.id);
|
||||
|
||||
Future<void> add(Set<AvesEntry> entries) async {
|
||||
final newRows = entries.map(_entryToRow);
|
||||
|
@ -34,9 +34,10 @@ class Favourites with ChangeNotifier {
|
|||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> remove(Set<AvesEntry> entries) async {
|
||||
final contentIds = entries.map((entry) => entry.contentId).toSet();
|
||||
final removedRows = _rows.where((row) => contentIds.contains(row.contentId)).toSet();
|
||||
Future<void> removeEntries(Set<AvesEntry> entries) => removeIds(entries.map((entry) => entry.id).toSet());
|
||||
|
||||
Future<void> removeIds(Set<int> entryIds) async {
|
||||
final removedRows = _rows.where((row) => entryIds.contains(row.entryId)).toSet();
|
||||
|
||||
await metadataDb.removeFavourites(removedRows);
|
||||
removedRows.forEach(_rows.remove);
|
||||
|
@ -44,19 +45,6 @@ class Favourites with ChangeNotifier {
|
|||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> moveEntry(int oldContentId, AvesEntry entry) async {
|
||||
final oldRow = _rows.firstWhereOrNull((row) => row.contentId == oldContentId);
|
||||
if (oldRow == null) return;
|
||||
|
||||
final newRow = _entryToRow(entry);
|
||||
|
||||
await metadataDb.updateFavouriteId(oldContentId, newRow);
|
||||
_rows.remove(oldRow);
|
||||
_rows.add(newRow);
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
await metadataDb.clearFavourites();
|
||||
_rows.clear();
|
||||
|
@ -69,7 +57,7 @@ class Favourites with ChangeNotifier {
|
|||
Map<String, List<String>>? export(CollectionSource source) {
|
||||
final visibleEntries = source.visibleEntries;
|
||||
final ids = favourites.all;
|
||||
final paths = visibleEntries.where((entry) => ids.contains(entry.contentId)).map((entry) => entry.path).whereNotNull().toSet();
|
||||
final paths = visibleEntries.where((entry) => ids.contains(entry.id)).map((entry) => entry.path).whereNotNull().toSet();
|
||||
final byVolume = groupBy<String, StorageVolume?>(paths, androidFileUtils.getStorageVolume);
|
||||
final jsonMap = Map.fromEntries(byVolume.entries.map((kv) {
|
||||
final volume = kv.key?.path;
|
||||
|
@ -117,26 +105,22 @@ class Favourites with ChangeNotifier {
|
|||
|
||||
@immutable
|
||||
class FavouriteRow extends Equatable {
|
||||
final int contentId;
|
||||
final String path;
|
||||
final int entryId;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [contentId, path];
|
||||
List<Object?> get props => [entryId];
|
||||
|
||||
const FavouriteRow({
|
||||
required this.contentId,
|
||||
required this.path,
|
||||
required this.entryId,
|
||||
});
|
||||
|
||||
factory FavouriteRow.fromMap(Map map) {
|
||||
return FavouriteRow(
|
||||
contentId: map['contentId'] ?? 0,
|
||||
path: map['path'] ?? '',
|
||||
entryId: map['id'] as int,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'contentId': contentId,
|
||||
'path': path,
|
||||
'id': entryId,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:aves/image_providers/app_icon_image_provider.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/theme/colors.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_icons.dart';
|
||||
|
@ -57,21 +58,36 @@ class AlbumFilter extends CollectionFilter {
|
|||
Future<Color> color(BuildContext context) {
|
||||
// do not use async/await and rely on `SynchronousFuture`
|
||||
// to prevent rebuilding of the `FutureBuilder` listening on this future
|
||||
if (androidFileUtils.getAlbumType(album) == AlbumType.app) {
|
||||
if (_appColors.containsKey(album)) return SynchronousFuture(_appColors[album]!);
|
||||
final albumType = androidFileUtils.getAlbumType(album);
|
||||
switch (albumType) {
|
||||
case AlbumType.regular:
|
||||
break;
|
||||
case AlbumType.app:
|
||||
if (_appColors.containsKey(album)) return SynchronousFuture(_appColors[album]!);
|
||||
|
||||
final packageName = androidFileUtils.getAlbumAppPackageName(album);
|
||||
if (packageName != null) {
|
||||
return PaletteGenerator.fromImageProvider(
|
||||
AppIconImage(packageName: packageName, size: 24),
|
||||
).then((palette) async {
|
||||
// `dominantColor` is most representative but can have low contrast with a dark background
|
||||
// `vibrantColor` is usually representative and has good contrast with a dark background
|
||||
final color = palette.vibrantColor?.color ?? (await super.color(context));
|
||||
_appColors[album] = color;
|
||||
return color;
|
||||
});
|
||||
}
|
||||
final packageName = androidFileUtils.getAlbumAppPackageName(album);
|
||||
if (packageName != null) {
|
||||
return PaletteGenerator.fromImageProvider(
|
||||
AppIconImage(packageName: packageName, size: 24),
|
||||
).then((palette) async {
|
||||
// `dominantColor` is most representative but can have low contrast with a dark background
|
||||
// `vibrantColor` is usually representative and has good contrast with a dark background
|
||||
final color = palette.vibrantColor?.color ?? (await super.color(context));
|
||||
_appColors[album] = color;
|
||||
return color;
|
||||
});
|
||||
}
|
||||
break;
|
||||
case AlbumType.camera:
|
||||
return SynchronousFuture(AColors.albumCamera);
|
||||
case AlbumType.download:
|
||||
return SynchronousFuture(AColors.albumDownload);
|
||||
case AlbumType.screenRecordings:
|
||||
return SynchronousFuture(AColors.albumScreenRecordings);
|
||||
case AlbumType.screenshots:
|
||||
return SynchronousFuture(AColors.albumScreenshots);
|
||||
case AlbumType.videoCaptures:
|
||||
return SynchronousFuture(AColors.albumVideoCaptures);
|
||||
}
|
||||
return super.color(context);
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:aves/l10n/l10n.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/settings/coordinate_format.dart';
|
||||
import 'package:aves/model/settings/enums.dart';
|
||||
import 'package:aves/model/settings/enums/coordinate_format.dart';
|
||||
import 'package:aves/model/settings/enums/enums.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/utils/geo_utils.dart';
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/theme/colors.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
@ -32,7 +33,7 @@ class FavouriteFilter extends CollectionFilter {
|
|||
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => Icon(AIcons.favourite, size: size);
|
||||
|
||||
@override
|
||||
Future<Color> color(BuildContext context) => SynchronousFuture(Colors.red);
|
||||
Future<Color> color(BuildContext context) => SynchronousFuture(AColors.favourite);
|
||||
|
||||
@override
|
||||
String get category => type;
|
||||
|
|
|
@ -10,6 +10,7 @@ import 'package:aves/model/filters/path.dart';
|
|||
import 'package:aves/model/filters/query.dart';
|
||||
import 'package:aves/model/filters/rating.dart';
|
||||
import 'package:aves/model/filters/tag.dart';
|
||||
import 'package:aves/model/filters/trash.dart';
|
||||
import 'package:aves/model/filters/type.dart';
|
||||
import 'package:aves/utils/color_utils.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
|
@ -20,6 +21,7 @@ import 'package:flutter/widgets.dart';
|
|||
@immutable
|
||||
abstract class CollectionFilter extends Equatable implements Comparable<CollectionFilter> {
|
||||
static const List<String> categoryOrder = [
|
||||
TrashFilter.type,
|
||||
QueryFilter.type,
|
||||
MimeFilter.type,
|
||||
AlbumFilter.type,
|
||||
|
@ -64,6 +66,8 @@ abstract class CollectionFilter extends Equatable implements Comparable<Collecti
|
|||
return TagFilter.fromMap(jsonMap);
|
||||
case TypeFilter.type:
|
||||
return TypeFilter.fromMap(jsonMap);
|
||||
case TrashFilter.type:
|
||||
return TrashFilter.instance;
|
||||
}
|
||||
}
|
||||
} catch (error, stack) {
|
||||
|
|
|
@ -1,8 +1,11 @@
|
|||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/ref/mime_types.dart';
|
||||
import 'package:aves/theme/colors.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/utils/color_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/widgets.dart';
|
||||
|
||||
class MimeFilter extends CollectionFilter {
|
||||
|
@ -12,6 +15,7 @@ class MimeFilter extends CollectionFilter {
|
|||
late final EntryFilter _test;
|
||||
late final String _label;
|
||||
late final IconData _icon;
|
||||
late final Color _color;
|
||||
|
||||
static final image = MimeFilter(MimeTypes.anyImage);
|
||||
static final video = MimeFilter(MimeTypes.anyVideo);
|
||||
|
@ -21,6 +25,7 @@ class MimeFilter extends CollectionFilter {
|
|||
|
||||
MimeFilter(this.mime) {
|
||||
IconData? icon;
|
||||
Color? color;
|
||||
var lowMime = mime.toLowerCase();
|
||||
if (lowMime.endsWith('/*')) {
|
||||
lowMime = lowMime.substring(0, lowMime.length - 2);
|
||||
|
@ -28,14 +33,17 @@ class MimeFilter extends CollectionFilter {
|
|||
_label = lowMime.toUpperCase();
|
||||
if (mime == MimeTypes.anyImage) {
|
||||
icon = AIcons.image;
|
||||
color = AColors.image;
|
||||
} else if (mime == MimeTypes.anyVideo) {
|
||||
icon = AIcons.video;
|
||||
color = AColors.video;
|
||||
}
|
||||
} else {
|
||||
_test = (entry) => entry.mimeType == lowMime;
|
||||
_label = MimeUtils.displayType(lowMime);
|
||||
}
|
||||
_icon = icon ?? AIcons.vector;
|
||||
_color = color ?? stringToColor(_label);
|
||||
}
|
||||
|
||||
MimeFilter.fromMap(Map<String, dynamic> json)
|
||||
|
@ -70,6 +78,9 @@ class MimeFilter extends CollectionFilter {
|
|||
@override
|
||||
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => Icon(_icon, size: size);
|
||||
|
||||
@override
|
||||
Future<Color> color(BuildContext context) => SynchronousFuture(_color);
|
||||
|
||||
@override
|
||||
String get category => type;
|
||||
|
||||
|
|
|
@ -22,7 +22,7 @@ class QueryFilter extends CollectionFilter {
|
|||
var upQuery = query.toUpperCase();
|
||||
if (upQuery.startsWith('ID:')) {
|
||||
final id = int.tryParse(upQuery.substring(3));
|
||||
_test = (entry) => entry.contentId == id;
|
||||
_test = (entry) => entry.id == id;
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
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.filterBinLabel;
|
||||
|
||||
@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;
|
||||
}
|
|
@ -1,6 +1,8 @@
|
|||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/theme/colors.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class TypeFilter extends CollectionFilter {
|
||||
|
@ -16,6 +18,7 @@ class TypeFilter extends CollectionFilter {
|
|||
final String itemType;
|
||||
late final EntryFilter _test;
|
||||
late final IconData _icon;
|
||||
late final Color _color;
|
||||
|
||||
static final animated = TypeFilter._private(_animated);
|
||||
static final geotiff = TypeFilter._private(_geotiff);
|
||||
|
@ -32,26 +35,32 @@ class TypeFilter extends CollectionFilter {
|
|||
case _animated:
|
||||
_test = (entry) => entry.isAnimated;
|
||||
_icon = AIcons.animated;
|
||||
_color = AColors.animated;
|
||||
break;
|
||||
case _geotiff:
|
||||
_test = (entry) => entry.isGeotiff;
|
||||
_icon = AIcons.geo;
|
||||
_color = AColors.geotiff;
|
||||
break;
|
||||
case _motionPhoto:
|
||||
_test = (entry) => entry.isMotionPhoto;
|
||||
_icon = AIcons.motionPhoto;
|
||||
_color = AColors.motionPhoto;
|
||||
break;
|
||||
case _panorama:
|
||||
_test = (entry) => entry.isImage && entry.is360;
|
||||
_icon = AIcons.threeSixty;
|
||||
_color = AColors.panorama;
|
||||
break;
|
||||
case _raw:
|
||||
_test = (entry) => entry.isRaw;
|
||||
_icon = AIcons.raw;
|
||||
_color = AColors.raw;
|
||||
break;
|
||||
case _sphericalVideo:
|
||||
_test = (entry) => entry.isVideo && entry.is360;
|
||||
_icon = AIcons.threeSixty;
|
||||
_color = AColors.sphericalVideo;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
@ -96,6 +105,9 @@ class TypeFilter extends CollectionFilter {
|
|||
@override
|
||||
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => Icon(_icon, size: size);
|
||||
|
||||
@override
|
||||
Future<Color> color(BuildContext context) => SynchronousFuture(_color);
|
||||
|
||||
@override
|
||||
String get category => type;
|
||||
|
||||
|
|
|
@ -4,16 +4,16 @@ import 'package:flutter/widgets.dart';
|
|||
|
||||
@immutable
|
||||
class AddressDetails extends Equatable {
|
||||
final int? contentId;
|
||||
final int id;
|
||||
final String? countryCode, countryName, adminArea, locality;
|
||||
|
||||
String? get place => locality != null && locality!.isNotEmpty ? locality : adminArea;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [contentId, countryCode, countryName, adminArea, locality];
|
||||
List<Object?> get props => [id, countryCode, countryName, adminArea, locality];
|
||||
|
||||
const AddressDetails({
|
||||
this.contentId,
|
||||
required this.id,
|
||||
this.countryCode,
|
||||
this.countryName,
|
||||
this.adminArea,
|
||||
|
@ -21,10 +21,10 @@ class AddressDetails extends Equatable {
|
|||
});
|
||||
|
||||
AddressDetails copyWith({
|
||||
int? contentId,
|
||||
int? id,
|
||||
}) {
|
||||
return AddressDetails(
|
||||
contentId: contentId ?? this.contentId,
|
||||
id: id ?? this.id,
|
||||
countryCode: countryCode,
|
||||
countryName: countryName,
|
||||
adminArea: adminArea,
|
||||
|
@ -34,7 +34,7 @@ class AddressDetails extends Equatable {
|
|||
|
||||
factory AddressDetails.fromMap(Map map) {
|
||||
return AddressDetails(
|
||||
contentId: map['contentId'] as int?,
|
||||
id: map['id'] as int,
|
||||
countryCode: map['countryCode'] as String?,
|
||||
countryName: map['countryName'] as String?,
|
||||
adminArea: map['adminArea'] as String?,
|
||||
|
@ -43,7 +43,7 @@ class AddressDetails extends Equatable {
|
|||
}
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'contentId': contentId,
|
||||
'id': id,
|
||||
'countryCode': countryCode,
|
||||
'countryName': countryName,
|
||||
'adminArea': adminArea,
|
||||
|
|
|
@ -2,7 +2,8 @@ import 'package:aves/services/geocoding_service.dart';
|
|||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class CatalogMetadata {
|
||||
final int? contentId, dateMillis;
|
||||
final int id;
|
||||
final int? dateMillis;
|
||||
final bool isAnimated, isGeotiff, is360, isMultiPage;
|
||||
bool isFlipped;
|
||||
int? rotationDegrees;
|
||||
|
@ -19,7 +20,7 @@ class CatalogMetadata {
|
|||
static const _isMultiPageMask = 1 << 4;
|
||||
|
||||
CatalogMetadata({
|
||||
this.contentId,
|
||||
required this.id,
|
||||
this.mimeType,
|
||||
this.dateMillis,
|
||||
this.isAnimated = false,
|
||||
|
@ -49,14 +50,14 @@ class CatalogMetadata {
|
|||
}
|
||||
|
||||
CatalogMetadata copyWith({
|
||||
int? contentId,
|
||||
int? id,
|
||||
String? mimeType,
|
||||
int? dateMillis,
|
||||
bool? isMultiPage,
|
||||
int? rotationDegrees,
|
||||
}) {
|
||||
return CatalogMetadata(
|
||||
contentId: contentId ?? this.contentId,
|
||||
id: id ?? this.id,
|
||||
mimeType: mimeType ?? this.mimeType,
|
||||
dateMillis: dateMillis ?? this.dateMillis,
|
||||
isAnimated: isAnimated,
|
||||
|
@ -76,7 +77,7 @@ class CatalogMetadata {
|
|||
factory CatalogMetadata.fromMap(Map map) {
|
||||
final flags = map['flags'] ?? 0;
|
||||
return CatalogMetadata(
|
||||
contentId: map['contentId'],
|
||||
id: map['id'],
|
||||
mimeType: map['mimeType'],
|
||||
dateMillis: map['dateMillis'] ?? 0,
|
||||
isAnimated: flags & _isAnimatedMask != 0,
|
||||
|
@ -95,7 +96,7 @@ class CatalogMetadata {
|
|||
}
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'contentId': contentId,
|
||||
'id': id,
|
||||
'mimeType': mimeType,
|
||||
'dateMillis': dateMillis,
|
||||
'flags': (isAnimated ? _isAnimatedMask : 0) | (isFlipped ? _isFlippedMask : 0) | (isGeotiff ? _isGeotiffMask : 0) | (is360 ? _is360Mask : 0) | (isMultiPage ? _isMultiPageMask : 0),
|
||||
|
@ -108,5 +109,5 @@ class CatalogMetadata {
|
|||
};
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, is360=$is360, isMultiPage=$isMultiPage, rotationDegrees=$rotationDegrees, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription, latitude=$latitude, longitude=$longitude, rating=$rating}';
|
||||
String toString() => '$runtimeType#${shortHash(this)}{id=$id, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, is360=$is360, isMultiPage=$isMultiPage, rotationDegrees=$rotationDegrees, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription, latitude=$latitude, longitude=$longitude, rating=$rating}';
|
||||
}
|
||||
|
|
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,
|
||||
};
|
||||
}
|
|
@ -1,132 +0,0 @@
|
|||
import 'package:aves/model/metadata_db.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
class MetadataDbUpgrader {
|
||||
static const entryTable = SqfliteMetadataDb.entryTable;
|
||||
static const metadataTable = SqfliteMetadataDb.metadataTable;
|
||||
static const coverTable = SqfliteMetadataDb.coverTable;
|
||||
static const videoPlaybackTable = SqfliteMetadataDb.videoPlaybackTable;
|
||||
|
||||
// warning: "ALTER TABLE ... RENAME COLUMN ..." is not supported
|
||||
// on SQLite <3.25.0, bundled on older Android devices
|
||||
static Future<void> upgradeDb(Database db, int oldVersion, int newVersion) async {
|
||||
while (oldVersion < newVersion) {
|
||||
switch (oldVersion) {
|
||||
case 1:
|
||||
await _upgradeFrom1(db);
|
||||
break;
|
||||
case 2:
|
||||
await _upgradeFrom2(db);
|
||||
break;
|
||||
case 3:
|
||||
await _upgradeFrom3(db);
|
||||
break;
|
||||
case 4:
|
||||
await _upgradeFrom4(db);
|
||||
break;
|
||||
case 5:
|
||||
await _upgradeFrom5(db);
|
||||
break;
|
||||
}
|
||||
oldVersion++;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _upgradeFrom1(Database db) async {
|
||||
debugPrint('upgrading DB from v1');
|
||||
// rename column 'orientationDegrees' to 'sourceRotationDegrees'
|
||||
await db.transaction((txn) async {
|
||||
const newEntryTable = '${entryTable}TEMP';
|
||||
await db.execute('CREATE TABLE $newEntryTable('
|
||||
'contentId INTEGER PRIMARY KEY'
|
||||
', uri TEXT'
|
||||
', path TEXT'
|
||||
', sourceMimeType TEXT'
|
||||
', width INTEGER'
|
||||
', height INTEGER'
|
||||
', sourceRotationDegrees INTEGER'
|
||||
', sizeBytes INTEGER'
|
||||
', title TEXT'
|
||||
', dateModifiedSecs INTEGER'
|
||||
', sourceDateTakenMillis INTEGER'
|
||||
', durationMillis INTEGER'
|
||||
')');
|
||||
await db.rawInsert('INSERT INTO $newEntryTable(contentId,uri,path,sourceMimeType,width,height,sourceRotationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis)'
|
||||
' SELECT contentId,uri,path,sourceMimeType,width,height,orientationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis'
|
||||
' FROM $entryTable;');
|
||||
await db.execute('DROP TABLE $entryTable;');
|
||||
await db.execute('ALTER TABLE $newEntryTable RENAME TO $entryTable;');
|
||||
});
|
||||
|
||||
// rename column 'videoRotation' to 'rotationDegrees'
|
||||
await db.transaction((txn) async {
|
||||
const newMetadataTable = '${metadataTable}TEMP';
|
||||
await db.execute('CREATE TABLE $newMetadataTable('
|
||||
'contentId INTEGER PRIMARY KEY'
|
||||
', mimeType TEXT'
|
||||
', dateMillis INTEGER'
|
||||
', isAnimated INTEGER'
|
||||
', rotationDegrees INTEGER'
|
||||
', xmpSubjects TEXT'
|
||||
', xmpTitleDescription TEXT'
|
||||
', latitude REAL'
|
||||
', longitude REAL'
|
||||
')');
|
||||
await db.rawInsert('INSERT INTO $newMetadataTable(contentId,mimeType,dateMillis,isAnimated,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude)'
|
||||
' SELECT contentId,mimeType,dateMillis,isAnimated,videoRotation,xmpSubjects,xmpTitleDescription,latitude,longitude'
|
||||
' FROM $metadataTable;');
|
||||
await db.rawInsert('UPDATE $newMetadataTable SET rotationDegrees = NULL WHERE rotationDegrees = 0;');
|
||||
await db.execute('DROP TABLE $metadataTable;');
|
||||
await db.execute('ALTER TABLE $newMetadataTable RENAME TO $metadataTable;');
|
||||
});
|
||||
|
||||
// new column 'isFlipped'
|
||||
await db.execute('ALTER TABLE $metadataTable ADD COLUMN isFlipped INTEGER;');
|
||||
}
|
||||
|
||||
static Future<void> _upgradeFrom2(Database db) async {
|
||||
debugPrint('upgrading DB from v2');
|
||||
// merge columns 'isAnimated' and 'isFlipped' into 'flags'
|
||||
await db.transaction((txn) async {
|
||||
const newMetadataTable = '${metadataTable}TEMP';
|
||||
await db.execute('CREATE TABLE $newMetadataTable('
|
||||
'contentId INTEGER PRIMARY KEY'
|
||||
', mimeType TEXT'
|
||||
', dateMillis INTEGER'
|
||||
', flags INTEGER'
|
||||
', rotationDegrees INTEGER'
|
||||
', xmpSubjects TEXT'
|
||||
', xmpTitleDescription TEXT'
|
||||
', latitude REAL'
|
||||
', longitude REAL'
|
||||
')');
|
||||
await db.rawInsert('INSERT INTO $newMetadataTable(contentId,mimeType,dateMillis,flags,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude)'
|
||||
' SELECT contentId,mimeType,dateMillis,ifnull(isAnimated,0)+ifnull(isFlipped,0)*2,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude'
|
||||
' FROM $metadataTable;');
|
||||
await db.execute('DROP TABLE $metadataTable;');
|
||||
await db.execute('ALTER TABLE $newMetadataTable RENAME TO $metadataTable;');
|
||||
});
|
||||
}
|
||||
|
||||
static Future<void> _upgradeFrom3(Database db) async {
|
||||
debugPrint('upgrading DB from v3');
|
||||
await db.execute('CREATE TABLE $coverTable('
|
||||
'filter TEXT PRIMARY KEY'
|
||||
', contentId INTEGER'
|
||||
')');
|
||||
}
|
||||
|
||||
static Future<void> _upgradeFrom4(Database db) async {
|
||||
debugPrint('upgrading DB from v4');
|
||||
await db.execute('CREATE TABLE $videoPlaybackTable('
|
||||
'contentId INTEGER PRIMARY KEY'
|
||||
', resumeTimeMillis INTEGER'
|
||||
')');
|
||||
}
|
||||
|
||||
static Future<void> _upgradeFrom5(Database db) async {
|
||||
debugPrint('upgrading DB from v5');
|
||||
await db.execute('ALTER TABLE $metadataTable ADD COLUMN rating INTEGER;');
|
||||
}
|
||||
}
|
|
@ -87,7 +87,11 @@ class MultiPageInfo {
|
|||
// and retrieve cached images for it
|
||||
final pageId = eraseDefaultPageId && pageInfo.isDefault ? null : pageInfo.pageId;
|
||||
|
||||
// dynamically extracted video is not in the trash like the original motion photo
|
||||
final trashed = (mainEntry.isMotionPhoto && pageInfo.isVideo) ? false : mainEntry.trashed;
|
||||
|
||||
return AvesEntry(
|
||||
id: mainEntry.id,
|
||||
uri: pageInfo.uri ?? mainEntry.uri,
|
||||
path: mainEntry.path,
|
||||
contentId: mainEntry.contentId,
|
||||
|
@ -101,13 +105,15 @@ class MultiPageInfo {
|
|||
dateModifiedSecs: mainEntry.dateModifiedSecs,
|
||||
sourceDateTakenMillis: mainEntry.sourceDateTakenMillis,
|
||||
durationMillis: pageInfo.durationMillis ?? mainEntry.durationMillis,
|
||||
trashed: trashed,
|
||||
)
|
||||
..catalogMetadata = mainEntry.catalogMetadata?.copyWith(
|
||||
mimeType: pageInfo.mimeType,
|
||||
isMultiPage: false,
|
||||
rotationDegrees: pageInfo.rotationDegrees,
|
||||
)
|
||||
..addressDetails = mainEntry.addressDetails?.copyWith();
|
||||
..addressDetails = mainEntry.addressDetails?.copyWith()
|
||||
..trashDetails = trashed ? mainEntry.trashDetails : null;
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
@ -3,7 +3,7 @@ import 'package:aves/model/actions/entry_set_actions.dart';
|
|||
import 'package:aves/model/actions/video_actions.dart';
|
||||
import 'package:aves/model/filters/favourite.dart';
|
||||
import 'package:aves/model/filters/mime.dart';
|
||||
import 'package:aves/model/settings/enums.dart';
|
||||
import 'package:aves/model/settings/enums/enums.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/widgets/filter_grids/albums_page.dart';
|
||||
import 'package:aves/widgets/filter_grids/countries_page.dart';
|
||||
|
@ -16,12 +16,13 @@ class SettingsDefaults {
|
|||
static const canUseAnalysisService = true;
|
||||
static const isInstalledAppAccessAllowed = false;
|
||||
static const isErrorReportingAllowed = false;
|
||||
static const tileLayout = TileLayout.grid;
|
||||
|
||||
// navigation
|
||||
static const mustBackTwiceToExit = true;
|
||||
static const keepScreenOn = KeepScreenOn.viewerOnly;
|
||||
static const homePage = HomePageSetting.collection;
|
||||
static const tileLayout = TileLayout.grid;
|
||||
|
||||
// drawer
|
||||
static const confirmationDialogs = ConfirmationDialog.values;
|
||||
static final drawerTypeBookmarks = [
|
||||
null,
|
||||
MimeFilter.video,
|
||||
|
@ -100,10 +101,17 @@ class SettingsDefaults {
|
|||
// search
|
||||
static const saveSearchHistory = true;
|
||||
|
||||
// bin
|
||||
static const enableBin = true;
|
||||
|
||||
// accessibility
|
||||
static const accessibilityAnimations = AccessibilityAnimations.system;
|
||||
static const timeToTakeAction = AccessibilityTimeout.appDefault; // `timeToTakeAction` has a contextual default value
|
||||
|
||||
// file picker
|
||||
static const filePickerShowHiddenFiles = false;
|
||||
|
||||
// platform settings
|
||||
static const isRotationLocked = false;
|
||||
static const areAnimationsRemoved = false;
|
||||
}
|
||||
|
|
15
lib/model/settings/enums/confirmation_dialogs.dart
Normal file
|
@ -0,0 +1,15 @@
|
|||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'enums.dart';
|
||||
|
||||
extension ExtraConfirmationDialog on ConfirmationDialog {
|
||||
String getName(BuildContext context) {
|
||||
switch (this) {
|
||||
case ConfirmationDialog.delete:
|
||||
return context.l10n.settingsConfirmationDialogDeleteItems;
|
||||
case ConfirmationDialog.moveToBin:
|
||||
return context.l10n.settingsConfirmationDialogMoveToBinItems;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,16 +1,18 @@
|
|||
enum CoordinateFormat { dms, decimal }
|
||||
|
||||
enum AccessibilityAnimations { system, disabled, enabled }
|
||||
|
||||
enum AccessibilityTimeout { system, appDefault, s10, s30, s60, s120 }
|
||||
|
||||
enum EntryBackground { black, white, checkered }
|
||||
enum ConfirmationDialog { delete, moveToBin }
|
||||
|
||||
enum HomePageSetting { collection, albums }
|
||||
enum CoordinateFormat { dms, decimal }
|
||||
|
||||
enum EntryBackground { black, white, checkered }
|
||||
|
||||
// browse providers at https://leaflet-extras.github.io/leaflet-providers/preview/
|
||||
enum EntryMapStyle { googleNormal, googleHybrid, googleTerrain, osmHot, stamenToner, stamenWatercolor }
|
||||
|
||||
enum HomePageSetting { collection, albums }
|
||||
|
||||
enum KeepScreenOn { never, viewerOnly, always }
|
||||
|
||||
enum UnitSystem { metric, imperial }
|
|
@ -7,25 +7,22 @@ import 'package:aves/model/actions/entry_set_actions.dart';
|
|||
import 'package:aves/model/actions/video_actions.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/settings/defaults.dart';
|
||||
import 'package:aves/model/settings/enums.dart';
|
||||
import 'package:aves/model/settings/map_style.dart';
|
||||
import 'package:aves/model/settings/enums/enums.dart';
|
||||
import 'package:aves/model/settings/enums/map_style.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/services/accessibility_service.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
final Settings settings = Settings._private();
|
||||
|
||||
class Settings extends ChangeNotifier {
|
||||
final EventChannel _platformSettingsChangeChannel = const EventChannel('deckers.thibault/aves/settings_change');
|
||||
final StreamController<String> _updateStreamController = StreamController<String>.broadcast();
|
||||
final StreamController<SettingsChangedEvent> _updateStreamController = StreamController<SettingsChangedEvent>.broadcast();
|
||||
|
||||
Stream<String> get updateStream => _updateStreamController.stream;
|
||||
|
||||
static SharedPreferences? _prefs;
|
||||
Stream<SettingsChangedEvent> get updateStream => _updateStreamController.stream;
|
||||
|
||||
Settings._private();
|
||||
|
||||
|
@ -34,6 +31,9 @@ class Settings extends ChangeNotifier {
|
|||
catalogTimeZoneKey,
|
||||
videoShowRawTimedTextKey,
|
||||
searchHistoryKey,
|
||||
platformAccelerometerRotationKey,
|
||||
platformTransitionAnimationScaleKey,
|
||||
topEntryIdsKey,
|
||||
};
|
||||
|
||||
// app
|
||||
|
@ -42,14 +42,16 @@ class Settings extends ChangeNotifier {
|
|||
static const isInstalledAppAccessAllowedKey = 'is_installed_app_access_allowed';
|
||||
static const isErrorReportingAllowedKey = 'is_crashlytics_enabled';
|
||||
static const localeKey = 'locale';
|
||||
static const mustBackTwiceToExitKey = 'must_back_twice_to_exit';
|
||||
static const keepScreenOnKey = 'keep_screen_on';
|
||||
static const homePageKey = 'home_page';
|
||||
static const catalogTimeZoneKey = 'catalog_time_zone';
|
||||
static const tileExtentPrefixKey = 'tile_extent_';
|
||||
static const tileLayoutPrefixKey = 'tile_layout_';
|
||||
static const topEntryIdsKey = 'top_entry_ids';
|
||||
|
||||
// drawer
|
||||
// navigation
|
||||
static const mustBackTwiceToExitKey = 'must_back_twice_to_exit';
|
||||
static const keepScreenOnKey = 'keep_screen_on';
|
||||
static const homePageKey = 'home_page';
|
||||
static const confirmationDialogsKey = 'confirmation_dialogs';
|
||||
static const drawerTypeBookmarksKey = 'drawer_type_bookmarks';
|
||||
static const drawerAlbumBookmarksKey = 'drawer_album_bookmarks';
|
||||
static const drawerPageBookmarksKey = 'drawer_page_bookmarks';
|
||||
|
@ -110,6 +112,9 @@ class Settings extends ChangeNotifier {
|
|||
static const saveSearchHistoryKey = 'save_search_history';
|
||||
static const searchHistoryKey = 'search_history';
|
||||
|
||||
// bin
|
||||
static const enableBinKey = 'enable_bin';
|
||||
|
||||
// accessibility
|
||||
static const accessibilityAnimationsKey = 'accessibility_animations';
|
||||
static const timeToTakeActionKey = 'time_to_take_action';
|
||||
|
@ -124,16 +129,10 @@ class Settings extends ChangeNotifier {
|
|||
// cf Android `Settings.Global.TRANSITION_ANIMATION_SCALE`
|
||||
static const platformTransitionAnimationScaleKey = 'transition_animation_scale';
|
||||
|
||||
bool get initialized => _prefs != null;
|
||||
bool get initialized => settingsStore.initialized;
|
||||
|
||||
Future<void> init({
|
||||
required bool monitorPlatformSettings,
|
||||
bool isRotationLocked = false,
|
||||
bool areAnimationsRemoved = false,
|
||||
}) async {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
_isRotationLocked = isRotationLocked;
|
||||
_areAnimationsRemoved = areAnimationsRemoved;
|
||||
Future<void> init({required bool monitorPlatformSettings}) async {
|
||||
await settingsStore.init();
|
||||
if (monitorPlatformSettings) {
|
||||
_platformSettingsChangeChannel.receiveBroadcastStream().listen((event) => _onPlatformSettingsChange(event as Map?));
|
||||
}
|
||||
|
@ -141,9 +140,9 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
Future<void> reset({required bool includeInternalKeys}) async {
|
||||
if (includeInternalKeys) {
|
||||
await _prefs!.clear();
|
||||
await settingsStore.clear();
|
||||
} else {
|
||||
await Future.forEach<String>(_prefs!.getKeys().whereNot(internalKeys.contains), _prefs!.remove);
|
||||
await Future.forEach<String>(settingsStore.getKeys().whereNot(Settings.internalKeys.contains), settingsStore.remove);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -189,7 +188,7 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
Locale? get locale {
|
||||
// exceptionally allow getting locale before settings are initialized
|
||||
final tag = _prefs?.getString(localeKey);
|
||||
final tag = initialized ? getString(localeKey) : null;
|
||||
if (tag != null) {
|
||||
final codes = tag.split(localeSeparator);
|
||||
return Locale.fromSubtags(
|
||||
|
@ -238,6 +237,24 @@ class Settings extends ChangeNotifier {
|
|||
return _appliedLocale!;
|
||||
}
|
||||
|
||||
String get catalogTimeZone => getString(catalogTimeZoneKey) ?? '';
|
||||
|
||||
set catalogTimeZone(String newValue) => setAndNotify(catalogTimeZoneKey, newValue);
|
||||
|
||||
double getTileExtent(String routeName) => getDouble(tileExtentPrefixKey + routeName) ?? 0;
|
||||
|
||||
void setTileExtent(String routeName, double newValue) => setAndNotify(tileExtentPrefixKey + routeName, newValue);
|
||||
|
||||
TileLayout getTileLayout(String routeName) => getEnumOrDefault(tileLayoutPrefixKey + routeName, SettingsDefaults.tileLayout, TileLayout.values);
|
||||
|
||||
void setTileLayout(String routeName, TileLayout newValue) => setAndNotify(tileLayoutPrefixKey + routeName, newValue.toString());
|
||||
|
||||
List<int>? get topEntryIds => getStringList(topEntryIdsKey)?.map(int.tryParse).whereNotNull().toList();
|
||||
|
||||
set topEntryIds(List<int>? newValue) => setAndNotify(topEntryIdsKey, newValue?.map((id) => id.toString()).whereNotNull().toList());
|
||||
|
||||
// navigation
|
||||
|
||||
bool get mustBackTwiceToExit => getBoolOrDefault(mustBackTwiceToExitKey, SettingsDefaults.mustBackTwiceToExit);
|
||||
|
||||
set mustBackTwiceToExit(bool newValue) => setAndNotify(mustBackTwiceToExitKey, newValue);
|
||||
|
@ -250,22 +267,12 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
set homePage(HomePageSetting newValue) => setAndNotify(homePageKey, newValue.toString());
|
||||
|
||||
String get catalogTimeZone => _prefs!.getString(catalogTimeZoneKey) ?? '';
|
||||
List<ConfirmationDialog> get confirmationDialogs => getEnumListOrDefault(confirmationDialogsKey, SettingsDefaults.confirmationDialogs, ConfirmationDialog.values);
|
||||
|
||||
set catalogTimeZone(String newValue) => setAndNotify(catalogTimeZoneKey, newValue);
|
||||
|
||||
double getTileExtent(String routeName) => _prefs!.getDouble(tileExtentPrefixKey + routeName) ?? 0;
|
||||
|
||||
void setTileExtent(String routeName, double newValue) => setAndNotify(tileExtentPrefixKey + routeName, newValue);
|
||||
|
||||
TileLayout getTileLayout(String routeName) => getEnumOrDefault(tileLayoutPrefixKey + routeName, SettingsDefaults.tileLayout, TileLayout.values);
|
||||
|
||||
void setTileLayout(String routeName, TileLayout newValue) => setAndNotify(tileLayoutPrefixKey + routeName, newValue.toString());
|
||||
|
||||
// drawer
|
||||
set confirmationDialogs(List<ConfirmationDialog> newValue) => setAndNotify(confirmationDialogsKey, newValue.map((v) => v.toString()).toList());
|
||||
|
||||
List<CollectionFilter?> get drawerTypeBookmarks =>
|
||||
(_prefs!.getStringList(drawerTypeBookmarksKey))?.map((v) {
|
||||
(getStringList(drawerTypeBookmarksKey))?.map((v) {
|
||||
if (v.isEmpty) return null;
|
||||
return CollectionFilter.fromJson(v);
|
||||
}).toList() ??
|
||||
|
@ -273,11 +280,11 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
set drawerTypeBookmarks(List<CollectionFilter?> newValue) => setAndNotify(drawerTypeBookmarksKey, newValue.map((filter) => filter?.toJson() ?? '').toList());
|
||||
|
||||
List<String>? get drawerAlbumBookmarks => _prefs!.getStringList(drawerAlbumBookmarksKey);
|
||||
List<String>? get drawerAlbumBookmarks => getStringList(drawerAlbumBookmarksKey);
|
||||
|
||||
set drawerAlbumBookmarks(List<String>? newValue) => setAndNotify(drawerAlbumBookmarksKey, newValue);
|
||||
|
||||
List<String> get drawerPageBookmarks => _prefs!.getStringList(drawerPageBookmarksKey) ?? SettingsDefaults.drawerPageBookmarks;
|
||||
List<String> get drawerPageBookmarks => getStringList(drawerPageBookmarksKey) ?? SettingsDefaults.drawerPageBookmarks;
|
||||
|
||||
set drawerPageBookmarks(List<String> newValue) => setAndNotify(drawerPageBookmarksKey, newValue);
|
||||
|
||||
|
@ -341,14 +348,25 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
set tagSortFactor(ChipSortFactor newValue) => setAndNotify(tagSortFactorKey, newValue.toString());
|
||||
|
||||
Set<CollectionFilter> get pinnedFilters => (_prefs!.getStringList(pinnedFiltersKey) ?? []).map(CollectionFilter.fromJson).whereNotNull().toSet();
|
||||
Set<CollectionFilter> get pinnedFilters => (getStringList(pinnedFiltersKey) ?? []).map(CollectionFilter.fromJson).whereNotNull().toSet();
|
||||
|
||||
set pinnedFilters(Set<CollectionFilter> newValue) => setAndNotify(pinnedFiltersKey, newValue.map((filter) => filter.toJson()).toList());
|
||||
|
||||
Set<CollectionFilter> get hiddenFilters => (_prefs!.getStringList(hiddenFiltersKey) ?? []).map(CollectionFilter.fromJson).whereNotNull().toSet();
|
||||
Set<CollectionFilter> get hiddenFilters => (getStringList(hiddenFiltersKey) ?? []).map(CollectionFilter.fromJson).whereNotNull().toSet();
|
||||
|
||||
set hiddenFilters(Set<CollectionFilter> newValue) => setAndNotify(hiddenFiltersKey, newValue.map((filter) => filter.toJson()).toList());
|
||||
|
||||
void changeFilterVisibility(Set<CollectionFilter> filters, bool visible) {
|
||||
final _hiddenFilters = hiddenFilters;
|
||||
if (visible) {
|
||||
_hiddenFilters.removeAll(filters);
|
||||
} else {
|
||||
_hiddenFilters.addAll(filters);
|
||||
searchHistory = searchHistory..removeWhere(filters.contains);
|
||||
}
|
||||
hiddenFilters = _hiddenFilters;
|
||||
}
|
||||
|
||||
// viewer
|
||||
|
||||
List<EntryAction> get viewerQuickActions => getEnumListOrDefault(viewerQuickActionsKey, SettingsDefaults.viewerQuickActions, EntryAction.values);
|
||||
|
@ -415,7 +433,7 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
// subtitles
|
||||
|
||||
double get subtitleFontSize => _prefs!.getDouble(subtitleFontSizeKey) ?? SettingsDefaults.subtitleFontSize;
|
||||
double get subtitleFontSize => getDouble(subtitleFontSizeKey) ?? SettingsDefaults.subtitleFontSize;
|
||||
|
||||
set subtitleFontSize(double newValue) => setAndNotify(subtitleFontSizeKey, newValue);
|
||||
|
||||
|
@ -427,11 +445,11 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
set subtitleShowOutline(bool newValue) => setAndNotify(subtitleShowOutlineKey, newValue);
|
||||
|
||||
Color get subtitleTextColor => Color(_prefs!.getInt(subtitleTextColorKey) ?? SettingsDefaults.subtitleTextColor.value);
|
||||
Color get subtitleTextColor => Color(getInt(subtitleTextColorKey) ?? SettingsDefaults.subtitleTextColor.value);
|
||||
|
||||
set subtitleTextColor(Color newValue) => setAndNotify(subtitleTextColorKey, newValue.value);
|
||||
|
||||
Color get subtitleBackgroundColor => Color(_prefs!.getInt(subtitleBackgroundColorKey) ?? SettingsDefaults.subtitleBackgroundColor.value);
|
||||
Color get subtitleBackgroundColor => Color(getInt(subtitleBackgroundColorKey) ?? SettingsDefaults.subtitleBackgroundColor.value);
|
||||
|
||||
set subtitleBackgroundColor(Color newValue) => setAndNotify(subtitleBackgroundColorKey, newValue.value);
|
||||
|
||||
|
@ -441,7 +459,7 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
set infoMapStyle(EntryMapStyle newValue) => setAndNotify(infoMapStyleKey, newValue.toString());
|
||||
|
||||
double get infoMapZoom => _prefs!.getDouble(infoMapZoomKey) ?? SettingsDefaults.infoMapZoom;
|
||||
double get infoMapZoom => getDouble(infoMapZoomKey) ?? SettingsDefaults.infoMapZoom;
|
||||
|
||||
set infoMapZoom(double newValue) => setAndNotify(infoMapZoomKey, newValue);
|
||||
|
||||
|
@ -459,10 +477,16 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
set saveSearchHistory(bool newValue) => setAndNotify(saveSearchHistoryKey, newValue);
|
||||
|
||||
List<CollectionFilter> get searchHistory => (_prefs!.getStringList(searchHistoryKey) ?? []).map(CollectionFilter.fromJson).whereNotNull().toList();
|
||||
List<CollectionFilter> get searchHistory => (getStringList(searchHistoryKey) ?? []).map(CollectionFilter.fromJson).whereNotNull().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
|
||||
|
||||
AccessibilityAnimations get accessibilityAnimations => getEnumOrDefault(accessibilityAnimationsKey, SettingsDefaults.accessibilityAnimations, AccessibilityAnimations.values);
|
||||
|
@ -481,11 +505,19 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
// convenience methods
|
||||
|
||||
int? getInt(String key) => settingsStore.getInt(key);
|
||||
|
||||
double? getDouble(String key) => settingsStore.getDouble(key);
|
||||
|
||||
String? getString(String key) => settingsStore.getString(key);
|
||||
|
||||
List<String>? getStringList(String key) => settingsStore.getStringList(key);
|
||||
|
||||
// ignore: avoid_positional_boolean_parameters
|
||||
bool getBoolOrDefault(String key, bool defaultValue) => _prefs!.getBool(key) ?? defaultValue;
|
||||
bool getBoolOrDefault(String key, bool defaultValue) => settingsStore.getBool(key) ?? defaultValue;
|
||||
|
||||
T getEnumOrDefault<T>(String key, T defaultValue, Iterable<T> values) {
|
||||
final valueString = _prefs!.getString(key);
|
||||
final valueString = settingsStore.getString(key);
|
||||
for (final v in values) {
|
||||
if (v.toString() == valueString) {
|
||||
return v;
|
||||
|
@ -495,31 +527,31 @@ class Settings extends ChangeNotifier {
|
|||
}
|
||||
|
||||
List<T> getEnumListOrDefault<T extends Object>(String key, List<T> defaultValue, Iterable<T> values) {
|
||||
return _prefs!.getStringList(key)?.map((s) => values.firstWhereOrNull((v) => v.toString() == s)).whereNotNull().toList() ?? defaultValue;
|
||||
return settingsStore.getStringList(key)?.map((s) => values.firstWhereOrNull((v) => v.toString() == s)).whereNotNull().toList() ?? defaultValue;
|
||||
}
|
||||
|
||||
void setAndNotify(String key, dynamic newValue) {
|
||||
var oldValue = _prefs!.get(key);
|
||||
var oldValue = settingsStore.get(key);
|
||||
if (newValue == null) {
|
||||
_prefs!.remove(key);
|
||||
settingsStore.remove(key);
|
||||
} else if (newValue is String) {
|
||||
oldValue = _prefs!.getString(key);
|
||||
_prefs!.setString(key, newValue);
|
||||
oldValue = settingsStore.getString(key);
|
||||
settingsStore.setString(key, newValue);
|
||||
} else if (newValue is List<String>) {
|
||||
oldValue = _prefs!.getStringList(key);
|
||||
_prefs!.setStringList(key, newValue);
|
||||
oldValue = settingsStore.getStringList(key);
|
||||
settingsStore.setStringList(key, newValue);
|
||||
} else if (newValue is int) {
|
||||
oldValue = _prefs!.getInt(key);
|
||||
_prefs!.setInt(key, newValue);
|
||||
oldValue = settingsStore.getInt(key);
|
||||
settingsStore.setInt(key, newValue);
|
||||
} else if (newValue is double) {
|
||||
oldValue = _prefs!.getDouble(key);
|
||||
_prefs!.setDouble(key, newValue);
|
||||
oldValue = settingsStore.getDouble(key);
|
||||
settingsStore.setDouble(key, newValue);
|
||||
} else if (newValue is bool) {
|
||||
oldValue = _prefs!.getBool(key);
|
||||
_prefs!.setBool(key, newValue);
|
||||
oldValue = settingsStore.getBool(key);
|
||||
settingsStore.setBool(key, newValue);
|
||||
}
|
||||
if (oldValue != newValue) {
|
||||
_updateStreamController.add(key);
|
||||
_updateStreamController.add(SettingsChangedEvent(key, oldValue, newValue));
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
@ -527,50 +559,33 @@ class Settings extends ChangeNotifier {
|
|||
// platform settings
|
||||
|
||||
void _onPlatformSettingsChange(Map? fields) {
|
||||
var changed = false;
|
||||
fields?.forEach((key, value) {
|
||||
switch (key) {
|
||||
case platformAccelerometerRotationKey:
|
||||
if (value is num) {
|
||||
final newValue = value == 0;
|
||||
if (_isRotationLocked != newValue) {
|
||||
_isRotationLocked = newValue;
|
||||
if (!_isRotationLocked) {
|
||||
windowService.requestOrientation();
|
||||
}
|
||||
_updateStreamController.add(key);
|
||||
changed = true;
|
||||
}
|
||||
isRotationLocked = value == 0;
|
||||
}
|
||||
break;
|
||||
case platformTransitionAnimationScaleKey:
|
||||
if (value is num) {
|
||||
final newValue = value == 0;
|
||||
if (_areAnimationsRemoved != newValue) {
|
||||
_areAnimationsRemoved = newValue;
|
||||
_updateStreamController.add(key);
|
||||
changed = true;
|
||||
}
|
||||
areAnimationsRemoved = value == 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (changed) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
bool _isRotationLocked = false;
|
||||
bool get isRotationLocked => getBoolOrDefault(platformAccelerometerRotationKey, SettingsDefaults.isRotationLocked);
|
||||
|
||||
bool get isRotationLocked => _isRotationLocked;
|
||||
set isRotationLocked(bool newValue) => setAndNotify(platformAccelerometerRotationKey, newValue);
|
||||
|
||||
bool _areAnimationsRemoved = false;
|
||||
bool get areAnimationsRemoved => getBoolOrDefault(platformTransitionAnimationScaleKey, SettingsDefaults.areAnimationsRemoved);
|
||||
|
||||
bool get areAnimationsRemoved => _areAnimationsRemoved;
|
||||
set areAnimationsRemoved(bool newValue) => setAndNotify(platformTransitionAnimationScaleKey, newValue);
|
||||
|
||||
// import/export
|
||||
|
||||
Map<String, dynamic> export() => Map.fromEntries(
|
||||
_prefs!.getKeys().whereNot(internalKeys.contains).map((k) => MapEntry(k, _prefs!.get(k))),
|
||||
settingsStore.getKeys().whereNot(internalKeys.contains).map((k) => MapEntry(k, settingsStore.get(k))),
|
||||
);
|
||||
|
||||
Future<void> import(dynamic jsonMap) async {
|
||||
|
@ -579,37 +594,39 @@ class Settings extends ChangeNotifier {
|
|||
await reset(includeInternalKeys: false);
|
||||
|
||||
// apply user modifications
|
||||
jsonMap.forEach((key, value) {
|
||||
if (value == null) {
|
||||
_prefs!.remove(key);
|
||||
jsonMap.forEach((key, newValue) {
|
||||
final oldValue = settingsStore.get(key);
|
||||
|
||||
if (newValue == null) {
|
||||
settingsStore.remove(key);
|
||||
} else if (key.startsWith(tileExtentPrefixKey)) {
|
||||
if (value is double) {
|
||||
_prefs!.setDouble(key, value);
|
||||
if (newValue is double) {
|
||||
settingsStore.setDouble(key, newValue);
|
||||
} else {
|
||||
debugPrint('failed to import key=$key, value=$value is not a double');
|
||||
debugPrint('failed to import key=$key, value=$newValue is not a double');
|
||||
}
|
||||
} else if (key.startsWith(tileLayoutPrefixKey)) {
|
||||
if (value is String) {
|
||||
_prefs!.setString(key, value);
|
||||
if (newValue is String) {
|
||||
settingsStore.setString(key, newValue);
|
||||
} else {
|
||||
debugPrint('failed to import key=$key, value=$value is not a string');
|
||||
debugPrint('failed to import key=$key, value=$newValue is not a string');
|
||||
}
|
||||
} else {
|
||||
switch (key) {
|
||||
case subtitleTextColorKey:
|
||||
case subtitleBackgroundColorKey:
|
||||
if (value is int) {
|
||||
_prefs!.setInt(key, value);
|
||||
if (newValue is int) {
|
||||
settingsStore.setInt(key, newValue);
|
||||
} else {
|
||||
debugPrint('failed to import key=$key, value=$value is not an int');
|
||||
debugPrint('failed to import key=$key, value=$newValue is not an int');
|
||||
}
|
||||
break;
|
||||
case subtitleFontSizeKey:
|
||||
case infoMapZoomKey:
|
||||
if (value is double) {
|
||||
_prefs!.setDouble(key, value);
|
||||
if (newValue is double) {
|
||||
settingsStore.setDouble(key, newValue);
|
||||
} else {
|
||||
debugPrint('failed to import key=$key, value=$value is not a double');
|
||||
debugPrint('failed to import key=$key, value=$newValue is not a double');
|
||||
}
|
||||
break;
|
||||
case isInstalledAppAccessAllowedKey:
|
||||
|
@ -634,10 +651,10 @@ class Settings extends ChangeNotifier {
|
|||
case subtitleShowOutlineKey:
|
||||
case saveSearchHistoryKey:
|
||||
case filePickerShowHiddenFilesKey:
|
||||
if (value is bool) {
|
||||
_prefs!.setBool(key, value);
|
||||
if (newValue is bool) {
|
||||
settingsStore.setBool(key, newValue);
|
||||
} else {
|
||||
debugPrint('failed to import key=$key, value=$value is not a bool');
|
||||
debugPrint('failed to import key=$key, value=$newValue is not a bool');
|
||||
}
|
||||
break;
|
||||
case localeKey:
|
||||
|
@ -657,12 +674,13 @@ class Settings extends ChangeNotifier {
|
|||
case unitSystemKey:
|
||||
case accessibilityAnimationsKey:
|
||||
case timeToTakeActionKey:
|
||||
if (value is String) {
|
||||
_prefs!.setString(key, value);
|
||||
if (newValue is String) {
|
||||
settingsStore.setString(key, newValue);
|
||||
} else {
|
||||
debugPrint('failed to import key=$key, value=$value is not a string');
|
||||
debugPrint('failed to import key=$key, value=$newValue is not a string');
|
||||
}
|
||||
break;
|
||||
case confirmationDialogsKey:
|
||||
case drawerTypeBookmarksKey:
|
||||
case drawerAlbumBookmarksKey:
|
||||
case drawerPageBookmarksKey:
|
||||
|
@ -672,17 +690,29 @@ class Settings extends ChangeNotifier {
|
|||
case collectionSelectionQuickActionsKey:
|
||||
case viewerQuickActionsKey:
|
||||
case videoQuickActionsKey:
|
||||
if (value is List) {
|
||||
_prefs!.setStringList(key, value.cast<String>());
|
||||
if (newValue is List) {
|
||||
settingsStore.setStringList(key, newValue.cast<String>());
|
||||
} else {
|
||||
debugPrint('failed to import key=$key, value=$value is not a list');
|
||||
debugPrint('failed to import key=$key, value=$newValue is not a list');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
_updateStreamController.add(key);
|
||||
if (oldValue != newValue) {
|
||||
_updateStreamController.add(SettingsChangedEvent(key, oldValue, newValue));
|
||||
}
|
||||
});
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class SettingsChangedEvent {
|
||||
final String key;
|
||||
final dynamic oldValue;
|
||||
final dynamic newValue;
|
||||
|
||||
// old and new values as stored, e.g. `List<String>` for collections
|
||||
const SettingsChangedEvent(this.key, this.oldValue, this.newValue);
|
||||
}
|
||||
|
|
37
lib/model/settings/store/store.dart
Normal file
|
@ -0,0 +1,37 @@
|
|||
abstract class SettingsStore {
|
||||
bool get initialized;
|
||||
|
||||
Future<void> init();
|
||||
|
||||
Future<bool> clear();
|
||||
|
||||
Future<bool> remove(String key);
|
||||
|
||||
// get
|
||||
|
||||
Set<String> getKeys();
|
||||
|
||||
Object? get(String key);
|
||||
|
||||
bool? getBool(String key);
|
||||
|
||||
int? getInt(String key);
|
||||
|
||||
double? getDouble(String key);
|
||||
|
||||
String? getString(String key);
|
||||
|
||||
List<String>? getStringList(String key);
|
||||
|
||||
// set
|
||||
|
||||
Future<bool> setBool(String key, bool value);
|
||||
|
||||
Future<bool> setInt(String key, int value);
|
||||
|
||||
Future<bool> setDouble(String key, double value);
|
||||
|
||||
Future<bool> setString(String key, String value);
|
||||
|
||||
Future<bool> setStringList(String key, List<String> value);
|
||||
}
|
65
lib/model/settings/store/store_shared_pref.dart
Normal file
|
@ -0,0 +1,65 @@
|
|||
import 'package:aves/model/settings/store/store.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class SharedPrefSettingsStore implements SettingsStore {
|
||||
static SharedPreferences? _prefs;
|
||||
|
||||
@override
|
||||
bool get initialized => _prefs != null;
|
||||
|
||||
@override
|
||||
Future<void> init() async {
|
||||
try {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
} catch (error, stack) {
|
||||
debugPrint('$runtimeType init error=$error\n$stack');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> clear() => _prefs!.clear();
|
||||
|
||||
@override
|
||||
Future<bool> remove(String key) => _prefs!.remove(key);
|
||||
|
||||
// get
|
||||
|
||||
@override
|
||||
Set<String> getKeys() => _prefs!.getKeys();
|
||||
|
||||
@override
|
||||
Object? get(String key) => _prefs!.get(key);
|
||||
|
||||
@override
|
||||
bool? getBool(String key) => _prefs!.getBool(key);
|
||||
|
||||
@override
|
||||
int? getInt(String key) => _prefs!.getInt(key);
|
||||
|
||||
@override
|
||||
double? getDouble(String key) => _prefs!.getDouble(key);
|
||||
|
||||
@override
|
||||
String? getString(String key) => _prefs!.getString(key);
|
||||
|
||||
@override
|
||||
List<String>? getStringList(String key) => _prefs!.getStringList(key);
|
||||
|
||||
// set
|
||||
|
||||
@override
|
||||
Future<bool> setBool(String key, bool value) => _prefs!.setBool(key, value);
|
||||
|
||||
@override
|
||||
Future<bool> setInt(String key, int value) => _prefs!.setInt(key, value);
|
||||
|
||||
@override
|
||||
Future<bool> setDouble(String key, double value) => _prefs!.setDouble(key, value);
|
||||
|
||||
@override
|
||||
Future<bool> setString(String key, String value) => _prefs!.setString(key, value);
|
||||
|
||||
@override
|
||||
Future<bool> setStringList(String key, List<String> value) => _prefs!.setStringList(key, value);
|
||||
}
|
|
@ -26,56 +26,9 @@ mixin AlbumMixin on SourceBase {
|
|||
return compareAsciiUpperCase(va, vb);
|
||||
}
|
||||
|
||||
void _notifyAlbumChange() => eventBus.fire(AlbumsChangedEvent());
|
||||
|
||||
String getAlbumDisplayName(BuildContext? context, String dirPath) {
|
||||
final separator = pContext.separator;
|
||||
assert(!dirPath.endsWith(separator));
|
||||
|
||||
if (context != null) {
|
||||
final type = androidFileUtils.getAlbumType(dirPath);
|
||||
if (type == AlbumType.camera) return context.l10n.albumCamera;
|
||||
if (type == AlbumType.download) return context.l10n.albumDownload;
|
||||
if (type == AlbumType.screenshots) return context.l10n.albumScreenshots;
|
||||
if (type == AlbumType.screenRecordings) return context.l10n.albumScreenRecordings;
|
||||
if (type == AlbumType.videoCaptures) return context.l10n.albumVideoCaptures;
|
||||
}
|
||||
|
||||
final dir = VolumeRelativeDirectory.fromPath(dirPath);
|
||||
if (dir == null) return dirPath;
|
||||
|
||||
final relativeDir = dir.relativeDir;
|
||||
if (relativeDir.isEmpty) {
|
||||
final volume = androidFileUtils.getStorageVolume(dirPath)!;
|
||||
return volume.getDescription(context);
|
||||
}
|
||||
|
||||
String unique(String dirPath, Set<String?> others) {
|
||||
final parts = pContext.split(dirPath);
|
||||
for (var i = parts.length - 1; i > 0; i--) {
|
||||
final name = pContext.joinAll(['', ...parts.skip(i)]);
|
||||
final testName = '$separator$name';
|
||||
if (others.every((item) => !item!.endsWith(testName))) return name;
|
||||
}
|
||||
return dirPath;
|
||||
}
|
||||
|
||||
final otherAlbumsOnDevice = _directories.where((item) => item != dirPath).toSet();
|
||||
final uniqueNameInDevice = unique(dirPath, otherAlbumsOnDevice);
|
||||
if (uniqueNameInDevice.length <= relativeDir.length) {
|
||||
return uniqueNameInDevice;
|
||||
}
|
||||
|
||||
final volumePath = dir.volumePath;
|
||||
String trimVolumePath(String? path) => path!.substring(dir.volumePath.length);
|
||||
final otherAlbumsOnVolume = otherAlbumsOnDevice.where((path) => path!.startsWith(volumePath)).map(trimVolumePath).toSet();
|
||||
final uniqueNameInVolume = unique(trimVolumePath(dirPath), otherAlbumsOnVolume);
|
||||
final volume = androidFileUtils.getStorageVolume(dirPath)!;
|
||||
if (volume.isPrimary) {
|
||||
return uniqueNameInVolume;
|
||||
} else {
|
||||
return '$uniqueNameInVolume (${volume.getDescription(context)})';
|
||||
}
|
||||
void _onAlbumChanged() {
|
||||
invalidateAlbumDisplayNames();
|
||||
eventBus.fire(AlbumsChangedEvent());
|
||||
}
|
||||
|
||||
Map<String, AvesEntry?> getAlbumEntries() {
|
||||
|
@ -109,7 +62,7 @@ mixin AlbumMixin on SourceBase {
|
|||
void addDirectories(Set<String?> albums) {
|
||||
if (!_directories.containsAll(albums)) {
|
||||
_directories.addAll(albums);
|
||||
_notifyAlbumChange();
|
||||
_onAlbumChanged();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -117,7 +70,7 @@ mixin AlbumMixin on SourceBase {
|
|||
final emptyAlbums = (albums ?? _directories).where((v) => _isEmptyAlbum(v) && !_newAlbums.contains(v)).toSet();
|
||||
if (emptyAlbums.isNotEmpty) {
|
||||
_directories.removeAll(emptyAlbums);
|
||||
_notifyAlbumChange();
|
||||
_onAlbumChanged();
|
||||
invalidateAlbumFilterSummary(directories: emptyAlbums);
|
||||
|
||||
final bookmarks = settings.drawerAlbumBookmarks;
|
||||
|
@ -166,6 +119,8 @@ mixin AlbumMixin on SourceBase {
|
|||
return _filterRecentEntryMap.putIfAbsent(filter.album, () => sortedEntriesByDate.firstWhereOrNull(filter.test));
|
||||
}
|
||||
|
||||
// new albums
|
||||
|
||||
void createAlbum(String directory) {
|
||||
_newAlbums.add(directory);
|
||||
addDirectories({directory});
|
||||
|
@ -181,6 +136,80 @@ mixin AlbumMixin on SourceBase {
|
|||
void forgetNewAlbums(Set<String> directories) {
|
||||
_newAlbums.removeAll(directories);
|
||||
}
|
||||
|
||||
// display names
|
||||
|
||||
final Map<String, String> _albumDisplayNamesWithContext = {}, _albumDisplayNamesWithoutContext = {};
|
||||
|
||||
void invalidateAlbumDisplayNames() {
|
||||
_albumDisplayNamesWithContext.clear();
|
||||
_albumDisplayNamesWithoutContext.clear();
|
||||
}
|
||||
|
||||
String _computeDisplayName(BuildContext? context, String dirPath) {
|
||||
final separator = pContext.separator;
|
||||
assert(!dirPath.endsWith(separator));
|
||||
|
||||
if (context != null) {
|
||||
final type = androidFileUtils.getAlbumType(dirPath);
|
||||
switch (type) {
|
||||
case AlbumType.camera:
|
||||
return context.l10n.albumCamera;
|
||||
case AlbumType.download:
|
||||
return context.l10n.albumDownload;
|
||||
case AlbumType.screenshots:
|
||||
return context.l10n.albumScreenshots;
|
||||
case AlbumType.screenRecordings:
|
||||
return context.l10n.albumScreenRecordings;
|
||||
case AlbumType.videoCaptures:
|
||||
return context.l10n.albumVideoCaptures;
|
||||
case AlbumType.regular:
|
||||
case AlbumType.app:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
final dir = VolumeRelativeDirectory.fromPath(dirPath);
|
||||
if (dir == null) return dirPath;
|
||||
|
||||
final relativeDir = dir.relativeDir;
|
||||
if (relativeDir.isEmpty) {
|
||||
final volume = androidFileUtils.getStorageVolume(dirPath)!;
|
||||
return volume.getDescription(context);
|
||||
}
|
||||
|
||||
String unique(String dirPath, Set<String?> others) {
|
||||
final parts = pContext.split(dirPath);
|
||||
for (var i = parts.length - 1; i > 0; i--) {
|
||||
final name = pContext.joinAll(['', ...parts.skip(i)]);
|
||||
final testName = '$separator$name';
|
||||
if (others.every((item) => !item!.endsWith(testName))) return name;
|
||||
}
|
||||
return dirPath;
|
||||
}
|
||||
|
||||
final otherAlbumsOnDevice = _directories.where((item) => item != dirPath).toSet();
|
||||
final uniqueNameInDevice = unique(dirPath, otherAlbumsOnDevice);
|
||||
if (uniqueNameInDevice.length <= relativeDir.length) {
|
||||
return uniqueNameInDevice;
|
||||
}
|
||||
|
||||
final volumePath = dir.volumePath;
|
||||
String trimVolumePath(String? path) => path!.substring(dir.volumePath.length);
|
||||
final otherAlbumsOnVolume = otherAlbumsOnDevice.where((path) => path!.startsWith(volumePath)).map(trimVolumePath).toSet();
|
||||
final uniqueNameInVolume = unique(trimVolumePath(dirPath), otherAlbumsOnVolume);
|
||||
final volume = androidFileUtils.getStorageVolume(dirPath)!;
|
||||
if (volume.isPrimary) {
|
||||
return uniqueNameInVolume;
|
||||
} else {
|
||||
return '$uniqueNameInVolume (${volume.getDescription(context)})';
|
||||
}
|
||||
}
|
||||
|
||||
String getAlbumDisplayName(BuildContext? context, String dirPath) {
|
||||
final names = (context != null ? _albumDisplayNamesWithContext : _albumDisplayNamesWithoutContext);
|
||||
return names.putIfAbsent(dirPath, () => _computeDisplayName(context, dirPath));
|
||||
}
|
||||
}
|
||||
|
||||
class AlbumsChangedEvent {}
|
||||
|
|
|
@ -2,12 +2,12 @@ import 'package:flutter/foundation.dart';
|
|||
|
||||
class AnalysisController {
|
||||
final bool canStartService, force;
|
||||
final List<int>? contentIds;
|
||||
final List<int>? entryIds;
|
||||
final ValueNotifier<bool> stopSignal;
|
||||
|
||||
AnalysisController({
|
||||
this.canStartService = true,
|
||||
this.contentIds,
|
||||
this.entryIds,
|
||||
this.force = false,
|
||||
ValueNotifier<bool>? stopSignal,
|
||||
}) : stopSignal = stopSignal ?? ValueNotifier(false);
|
||||
|
|
|
@ -10,6 +10,7 @@ import 'package:aves/model/filters/filters.dart';
|
|||
import 'package:aves/model/filters/location.dart';
|
||||
import 'package:aves/model/filters/query.dart';
|
||||
import 'package:aves/model/filters/rating.dart';
|
||||
import 'package:aves/model/filters/trash.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/events.dart';
|
||||
|
@ -31,7 +32,7 @@ class CollectionLens with ChangeNotifier {
|
|||
final AChangeNotifier filterChangeNotifier = AChangeNotifier(), sortSectionChangeNotifier = AChangeNotifier();
|
||||
final List<StreamSubscription> _subscriptions = [];
|
||||
int? id;
|
||||
bool listenToSource;
|
||||
bool listenToSource, groupBursts;
|
||||
List<AvesEntry>? fixedSelection;
|
||||
|
||||
List<AvesEntry> _filteredSortedEntries = [];
|
||||
|
@ -43,6 +44,7 @@ class CollectionLens with ChangeNotifier {
|
|||
Set<CollectionFilter?>? filters,
|
||||
this.id,
|
||||
this.listenToSource = true,
|
||||
this.groupBursts = true,
|
||||
this.fixedSelection,
|
||||
}) : filters = (filters ?? {}).whereNotNull().toSet(),
|
||||
sectionFactor = settings.collectionSectionFactor,
|
||||
|
@ -53,9 +55,18 @@ class CollectionLens with ChangeNotifier {
|
|||
_subscriptions.add(sourceEvents.on<EntryAddedEvent>().listen((e) => _onEntryAdded(e.entries)));
|
||||
_subscriptions.add(sourceEvents.on<EntryRemovedEvent>().listen((e) => _onEntryRemoved(e.entries)));
|
||||
_subscriptions.add(sourceEvents.on<EntryMovedEvent>().listen((e) {
|
||||
if (e.type == MoveType.move) {
|
||||
// refreshing copied items is already handled via `EntryAddedEvent`s
|
||||
_refresh();
|
||||
switch (e.type) {
|
||||
case MoveType.copy:
|
||||
case MoveType.export:
|
||||
// refreshing new items is already handled via `EntryAddedEvent`s
|
||||
break;
|
||||
case MoveType.move:
|
||||
case MoveType.fromBin:
|
||||
_refresh();
|
||||
break;
|
||||
case MoveType.toBin:
|
||||
_onEntryRemoved(e.entries);
|
||||
break;
|
||||
}
|
||||
}));
|
||||
_subscriptions.add(sourceEvents.on<EntryRefreshedEvent>().listen((e) => _refresh()));
|
||||
|
@ -68,7 +79,12 @@ class CollectionLens with ChangeNotifier {
|
|||
}));
|
||||
favourites.addListener(_onFavouritesChanged);
|
||||
}
|
||||
settings.addListener(_onSettingsChanged);
|
||||
_subscriptions.add(settings.updateStream
|
||||
.where((event) => [
|
||||
Settings.collectionSortFactorKey,
|
||||
Settings.collectionGroupFactorKey,
|
||||
].contains(event.key))
|
||||
.listen((_) => _onSettingsChanged()));
|
||||
_refresh();
|
||||
}
|
||||
|
||||
|
@ -78,7 +94,6 @@ class CollectionLens with ChangeNotifier {
|
|||
..forEach((sub) => sub.cancel())
|
||||
..clear();
|
||||
favourites.removeListener(_onFavouritesChanged);
|
||||
settings.removeListener(_onSettingsChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
@ -160,10 +175,8 @@ class CollectionLens with ChangeNotifier {
|
|||
filterChangeNotifier.notifyListeners();
|
||||
}
|
||||
|
||||
final bool groupBursts = true;
|
||||
|
||||
void _applyFilters() {
|
||||
final entries = fixedSelection ?? source.visibleEntries;
|
||||
final entries = fixedSelection ?? (filters.contains(TrashFilter.instance) ? source.trashedEntries : source.visibleEntries);
|
||||
_filteredSortedEntries = List.of(filters.isEmpty ? entries : entries.where((entry) => filters.every((filter) => filter.test(entry))));
|
||||
|
||||
if (groupBursts) {
|
||||
|
|
|
@ -8,6 +8,8 @@ import 'package:aves/model/filters/album.dart';
|
|||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/filters/location.dart';
|
||||
import 'package:aves/model/filters/tag.dart';
|
||||
import 'package:aves/model/filters/trash.dart';
|
||||
import 'package:aves/model/metadata/trash.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/album.dart';
|
||||
import 'package:aves/model/source/analysis_controller.dart';
|
||||
|
@ -15,6 +17,7 @@ import 'package:aves/model/source/enums.dart';
|
|||
import 'package:aves/model/source/events.dart';
|
||||
import 'package:aves/model/source/location.dart';
|
||||
import 'package:aves/model/source/tag.dart';
|
||||
import 'package:aves/model/source/trash.dart';
|
||||
import 'package:aves/services/analysis_service.dart';
|
||||
import 'package:aves/services/common/image_op_events.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
|
@ -22,13 +25,17 @@ import 'package:collection/collection.dart';
|
|||
import 'package:event_bus/event_bus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
enum SourceInitializationState { none, directory, full }
|
||||
|
||||
mixin SourceBase {
|
||||
EventBus get eventBus;
|
||||
|
||||
Map<int?, AvesEntry> get entryById;
|
||||
Map<int, AvesEntry> get entryById;
|
||||
|
||||
Set<AvesEntry> get visibleEntries;
|
||||
|
||||
Set<AvesEntry> get trashedEntries;
|
||||
|
||||
List<AvesEntry> get sortedEntriesByDate;
|
||||
|
||||
ValueNotifier<SourceState> stateNotifier = ValueNotifier(SourceState.ready);
|
||||
|
@ -38,22 +45,33 @@ mixin SourceBase {
|
|||
void setProgress({required int done, required int total}) => progressNotifier.value = ProgressEvent(done: done, total: total);
|
||||
}
|
||||
|
||||
abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin {
|
||||
abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin, TrashMixin {
|
||||
CollectionSource() {
|
||||
settings.updateStream.where((event) => event.key == Settings.localeKey).listen((_) => invalidateAlbumDisplayNames());
|
||||
settings.updateStream.where((event) => event.key == Settings.hiddenFiltersKey).listen((event) {
|
||||
final oldValue = event.oldValue;
|
||||
if (oldValue is List<String>?) {
|
||||
final oldHiddenFilters = (oldValue ?? []).map(CollectionFilter.fromJson).whereNotNull().toSet();
|
||||
_onFilterVisibilityChanged(oldHiddenFilters, settings.hiddenFilters);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
final EventBus _eventBus = EventBus();
|
||||
|
||||
@override
|
||||
EventBus get eventBus => _eventBus;
|
||||
|
||||
final Map<int?, AvesEntry> _entryById = {};
|
||||
final Map<int, AvesEntry> _entryById = {};
|
||||
|
||||
@override
|
||||
Map<int?, AvesEntry> get entryById => Map.unmodifiable(_entryById);
|
||||
Map<int, AvesEntry> get entryById => Map.unmodifiable(_entryById);
|
||||
|
||||
final Set<AvesEntry> _rawEntries = {};
|
||||
|
||||
Set<AvesEntry> get allEntries => Set.unmodifiable(_rawEntries);
|
||||
|
||||
Set<AvesEntry>? _visibleEntries;
|
||||
Set<AvesEntry>? _visibleEntries, _trashedEntries;
|
||||
|
||||
@override
|
||||
Set<AvesEntry> get visibleEntries {
|
||||
|
@ -61,6 +79,12 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
return _visibleEntries!;
|
||||
}
|
||||
|
||||
@override
|
||||
Set<AvesEntry> get trashedEntries {
|
||||
_trashedEntries ??= Set.unmodifiable(_applyTrashFilter(_rawEntries));
|
||||
return _trashedEntries!;
|
||||
}
|
||||
|
||||
List<AvesEntry>? _sortedEntriesByDate;
|
||||
|
||||
@override
|
||||
|
@ -69,6 +93,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
return _sortedEntriesByDate!;
|
||||
}
|
||||
|
||||
// known date by entry ID
|
||||
late Map<int?, int?> _savedDates;
|
||||
|
||||
Future<void> loadDates() async {
|
||||
|
@ -76,12 +101,20 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
}
|
||||
|
||||
Iterable<AvesEntry> _applyHiddenFilters(Iterable<AvesEntry> entries) {
|
||||
final hiddenFilters = settings.hiddenFilters;
|
||||
return hiddenFilters.isEmpty ? entries : entries.where((entry) => !hiddenFilters.any((filter) => filter.test(entry)));
|
||||
final hiddenFilters = {
|
||||
TrashFilter.instance,
|
||||
...settings.hiddenFilters,
|
||||
};
|
||||
return entries.where((entry) => !hiddenFilters.any((filter) => filter.test(entry)));
|
||||
}
|
||||
|
||||
Iterable<AvesEntry> _applyTrashFilter(Iterable<AvesEntry> entries) {
|
||||
return entries.where(TrashFilter.instance.test);
|
||||
}
|
||||
|
||||
void _invalidate([Set<AvesEntry>? entries]) {
|
||||
_visibleEntries = null;
|
||||
_trashedEntries = null;
|
||||
_sortedEntriesByDate = null;
|
||||
invalidateAlbumFilterSummary(entries: entries);
|
||||
invalidateCountryFilterSummary(entries: entries);
|
||||
|
@ -100,14 +133,14 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
void addEntries(Set<AvesEntry> entries) {
|
||||
if (entries.isEmpty) return;
|
||||
|
||||
final newIdMapEntries = Map.fromEntries(entries.map((v) => MapEntry(v.contentId, v)));
|
||||
final newIdMapEntries = Map.fromEntries(entries.map((entry) => MapEntry(entry.id, entry)));
|
||||
if (_rawEntries.isNotEmpty) {
|
||||
final newContentIds = newIdMapEntries.keys.toSet();
|
||||
_rawEntries.removeWhere((entry) => newContentIds.contains(entry.contentId));
|
||||
final newIds = newIdMapEntries.keys.toSet();
|
||||
_rawEntries.removeWhere((entry) => newIds.contains(entry.id));
|
||||
}
|
||||
|
||||
entries.where((entry) => entry.catalogDateMillis == null).forEach((entry) {
|
||||
entry.catalogDateMillis = _savedDates[entry.contentId];
|
||||
entry.catalogDateMillis = _savedDates[entry.id];
|
||||
});
|
||||
|
||||
_entryById.addAll(newIdMapEntries);
|
||||
|
@ -118,14 +151,21 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
eventBus.fire(EntryAddedEvent(entries));
|
||||
}
|
||||
|
||||
Future<void> removeEntries(Set<String> uris) async {
|
||||
Future<void> removeEntries(Set<String> uris, {required bool includeTrash}) async {
|
||||
if (uris.isEmpty) return;
|
||||
final entries = _rawEntries.where((entry) => uris.contains(entry.uri)).toSet();
|
||||
await favourites.remove(entries);
|
||||
await covers.removeEntries(entries);
|
||||
await metadataDb.removeVideoPlayback(entries.map((entry) => entry.contentId).whereNotNull().toSet());
|
||||
|
||||
entries.forEach((v) => _entryById.remove(v.contentId));
|
||||
final entries = _rawEntries.where((entry) => uris.contains(entry.uri)).toSet();
|
||||
if (!includeTrash) {
|
||||
entries.removeWhere(TrashFilter.instance.test);
|
||||
}
|
||||
if (entries.isEmpty) return;
|
||||
|
||||
final ids = entries.map((entry) => entry.id).toSet();
|
||||
await favourites.removeIds(ids);
|
||||
await covers.removeIds(ids);
|
||||
await metadataDb.removeIds(ids);
|
||||
|
||||
ids.forEach((id) => _entryById.remove);
|
||||
_rawEntries.removeAll(entries);
|
||||
updateDerivedFilters(entries);
|
||||
eventBus.fire(EntryRemovedEvent(entries));
|
||||
|
@ -142,27 +182,51 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
}
|
||||
|
||||
Future<void> _moveEntry(AvesEntry entry, Map newFields, {required bool persist}) async {
|
||||
final oldContentId = entry.contentId!;
|
||||
final newContentId = newFields['contentId'] as int?;
|
||||
newFields.keys.forEach((key) {
|
||||
switch (key) {
|
||||
case 'contentId':
|
||||
entry.contentId = newFields['contentId'] as int?;
|
||||
break;
|
||||
case 'dateModifiedSecs':
|
||||
// `dateModifiedSecs` changes when moving entries to another directory,
|
||||
// but it does not change when renaming the containing directory
|
||||
entry.dateModifiedSecs = newFields['dateModifiedSecs'] as int?;
|
||||
break;
|
||||
case 'path':
|
||||
entry.path = newFields['path'] as String?;
|
||||
break;
|
||||
case 'title':
|
||||
entry.sourceTitle = newFields['title'] as String?;
|
||||
break;
|
||||
case 'trashed':
|
||||
final trashed = newFields['trashed'] as bool;
|
||||
entry.trashed = trashed;
|
||||
entry.trashDetails = trashed
|
||||
? TrashDetails(
|
||||
id: entry.id,
|
||||
path: newFields['trashPath'] as String,
|
||||
dateMillis: DateTime.now().millisecondsSinceEpoch,
|
||||
)
|
||||
: null;
|
||||
break;
|
||||
case 'uri':
|
||||
entry.uri = newFields['uri'] as String;
|
||||
break;
|
||||
}
|
||||
});
|
||||
if (entry.trashed) {
|
||||
entry.contentId = null;
|
||||
entry.uri = 'file://${entry.trashDetails?.path}';
|
||||
}
|
||||
|
||||
entry.contentId = newContentId;
|
||||
// `dateModifiedSecs` changes when moving entries to another directory,
|
||||
// but it does not change when renaming the containing directory
|
||||
if (newFields.containsKey('dateModifiedSecs')) entry.dateModifiedSecs = newFields['dateModifiedSecs'] as int?;
|
||||
if (newFields.containsKey('path')) entry.path = newFields['path'] as String?;
|
||||
if (newFields.containsKey('uri')) entry.uri = newFields['uri'] as String;
|
||||
if (newFields.containsKey('title')) entry.sourceTitle = newFields['title'] as String?;
|
||||
|
||||
entry.catalogMetadata = entry.catalogMetadata?.copyWith(contentId: newContentId);
|
||||
entry.addressDetails = entry.addressDetails?.copyWith(contentId: newContentId);
|
||||
await covers.moveEntry(entry, persist: persist);
|
||||
|
||||
if (persist) {
|
||||
await metadataDb.updateEntryId(oldContentId, entry);
|
||||
await metadataDb.updateMetadataId(oldContentId, entry.catalogMetadata);
|
||||
await metadataDb.updateAddressId(oldContentId, entry.addressDetails);
|
||||
await favourites.moveEntry(oldContentId, entry);
|
||||
await covers.moveEntry(oldContentId, entry);
|
||||
await metadataDb.updateVideoPlaybackId(oldContentId, entry.contentId);
|
||||
final id = entry.id;
|
||||
await metadataDb.updateEntry(id, entry);
|
||||
await metadataDb.updateCatalogMetadata(id, entry.catalogMetadata);
|
||||
await metadataDb.updateAddress(id, entry.addressDetails);
|
||||
await metadataDb.updateTrash(id, entry.trashDetails);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -198,42 +262,40 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
return success;
|
||||
}
|
||||
|
||||
Future<void> renameAlbum(String sourceAlbum, String destinationAlbum, Set<AvesEntry> todoEntries, Set<MoveOpEvent> movedOps) async {
|
||||
Future<void> renameAlbum(String sourceAlbum, String destinationAlbum, Set<AvesEntry> entries, Set<MoveOpEvent> movedOps) async {
|
||||
final oldFilter = AlbumFilter(sourceAlbum, null);
|
||||
final bookmarked = settings.drawerAlbumBookmarks?.contains(sourceAlbum) == true;
|
||||
final newFilter = AlbumFilter(destinationAlbum, null);
|
||||
|
||||
final bookmark = settings.drawerAlbumBookmarks?.indexOf(sourceAlbum);
|
||||
final pinned = settings.pinnedFilters.contains(oldFilter);
|
||||
final oldCoverContentId = covers.coverContentId(oldFilter);
|
||||
final coverEntry = oldCoverContentId != null ? todoEntries.firstWhereOrNull((entry) => entry.contentId == oldCoverContentId) : null;
|
||||
await covers.set(newFilter, covers.coverEntryId(oldFilter));
|
||||
renameNewAlbum(sourceAlbum, destinationAlbum);
|
||||
await updateAfterMove(
|
||||
todoEntries: todoEntries,
|
||||
copy: false,
|
||||
destinationAlbum: destinationAlbum,
|
||||
todoEntries: entries,
|
||||
moveType: MoveType.move,
|
||||
destinationAlbums: {destinationAlbum},
|
||||
movedOps: movedOps,
|
||||
);
|
||||
// restore bookmark, pin and cover, as the obsolete album got removed and its associated state cleaned
|
||||
final newFilter = AlbumFilter(destinationAlbum, null);
|
||||
if (bookmarked) {
|
||||
settings.drawerAlbumBookmarks = settings.drawerAlbumBookmarks?..add(destinationAlbum);
|
||||
// restore bookmark and pin, as the obsolete album got removed and its associated state cleaned
|
||||
if (bookmark != null && bookmark != -1) {
|
||||
settings.drawerAlbumBookmarks = settings.drawerAlbumBookmarks?..insert(bookmark, destinationAlbum);
|
||||
}
|
||||
if (pinned) {
|
||||
settings.pinnedFilters = settings.pinnedFilters..add(newFilter);
|
||||
}
|
||||
if (coverEntry != null) {
|
||||
await covers.set(newFilter, coverEntry.contentId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateAfterMove({
|
||||
required Set<AvesEntry> todoEntries,
|
||||
required bool copy,
|
||||
required String destinationAlbum,
|
||||
required MoveType moveType,
|
||||
required Set<String> destinationAlbums,
|
||||
required Set<MoveOpEvent> movedOps,
|
||||
}) async {
|
||||
if (movedOps.isEmpty) return;
|
||||
|
||||
final fromAlbums = <String?>{};
|
||||
final movedEntries = <AvesEntry>{};
|
||||
final copy = moveType == MoveType.copy;
|
||||
if (copy) {
|
||||
movedOps.forEach((movedOp) {
|
||||
final sourceUri = movedOp.uri;
|
||||
|
@ -242,6 +304,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
if (sourceEntry != null) {
|
||||
fromAlbums.add(sourceEntry.directory);
|
||||
movedEntries.add(sourceEntry.copyWith(
|
||||
id: metadataDb.nextId,
|
||||
uri: newFields['uri'] as String?,
|
||||
path: newFields['path'] as String?,
|
||||
contentId: newFields['contentId'] as int?,
|
||||
|
@ -254,7 +317,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
}
|
||||
});
|
||||
await metadataDb.saveEntries(movedEntries);
|
||||
await metadataDb.saveMetadata(movedEntries.map((entry) => entry.catalogMetadata).whereNotNull().toSet());
|
||||
await metadataDb.saveCatalogMetadata(movedEntries.map((entry) => entry.catalogMetadata).whereNotNull().toSet());
|
||||
await metadataDb.saveAddresses(movedEntries.map((entry) => entry.addressDetails).whereNotNull().toSet());
|
||||
} else {
|
||||
await Future.forEach<MoveOpEvent>(movedOps, (movedOp) async {
|
||||
|
@ -263,7 +326,11 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
final sourceUri = movedOp.uri;
|
||||
final entry = todoEntries.firstWhereOrNull((entry) => entry.uri == sourceUri);
|
||||
if (entry != null) {
|
||||
fromAlbums.add(entry.directory);
|
||||
if (moveType == MoveType.fromBin) {
|
||||
newFields['trashed'] = false;
|
||||
} else {
|
||||
fromAlbums.add(entry.directory);
|
||||
}
|
||||
movedEntries.add(entry);
|
||||
await _moveEntry(entry, newFields, persist: true);
|
||||
}
|
||||
|
@ -275,18 +342,22 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
addEntries(movedEntries);
|
||||
} else {
|
||||
cleanEmptyAlbums(fromAlbums);
|
||||
addDirectories({destinationAlbum});
|
||||
if (moveType != MoveType.toBin) {
|
||||
addDirectories(destinationAlbums);
|
||||
}
|
||||
}
|
||||
invalidateAlbumFilterSummary(directories: fromAlbums);
|
||||
_invalidate(movedEntries);
|
||||
eventBus.fire(EntryMovedEvent(copy ? MoveType.copy : MoveType.move, movedEntries));
|
||||
eventBus.fire(EntryMovedEvent(moveType, movedEntries));
|
||||
}
|
||||
|
||||
bool get initialized => false;
|
||||
SourceInitializationState get initState => SourceInitializationState.none;
|
||||
|
||||
Future<void> init();
|
||||
|
||||
Future<void> refresh({AnalysisController? analysisController});
|
||||
Future<void> init({
|
||||
AnalysisController? analysisController,
|
||||
String? directory,
|
||||
bool loadTopEntriesFirst = false,
|
||||
});
|
||||
|
||||
Future<Set<String>> refreshUris(Set<String> changedUris, {AnalysisController? analysisController});
|
||||
|
||||
|
@ -294,13 +365,13 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
await entry.refresh(background: false, persist: true, dataTypes: dataTypes, geocoderLocale: settings.appliedLocale);
|
||||
|
||||
// update/delete in DB
|
||||
final contentId = entry.contentId!;
|
||||
final id = entry.id;
|
||||
if (dataTypes.contains(EntryDataType.catalog)) {
|
||||
await metadataDb.updateMetadataId(contentId, entry.catalogMetadata);
|
||||
await metadataDb.updateCatalogMetadata(id, entry.catalogMetadata);
|
||||
onCatalogMetadataChanged();
|
||||
}
|
||||
if (dataTypes.contains(EntryDataType.address)) {
|
||||
await metadataDb.updateAddressId(contentId, entry.addressDetails);
|
||||
await metadataDb.updateAddress(id, entry.addressDetails);
|
||||
onAddressMetadataChanged();
|
||||
}
|
||||
|
||||
|
@ -334,7 +405,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
if (startAnalysisService) {
|
||||
await AnalysisService.startService(
|
||||
force: force,
|
||||
contentIds: entries?.map((entry) => entry.contentId).whereNotNull().toList(),
|
||||
entryIds: entries?.map((entry) => entry.id).toList(),
|
||||
);
|
||||
} else {
|
||||
await catalogEntries(_analysisController, todoEntries);
|
||||
|
@ -373,28 +444,21 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
}
|
||||
|
||||
AvesEntry? coverEntry(CollectionFilter filter) {
|
||||
final contentId = covers.coverContentId(filter);
|
||||
if (contentId != null) {
|
||||
final entry = visibleEntries.firstWhereOrNull((entry) => entry.contentId == contentId);
|
||||
final id = covers.coverEntryId(filter);
|
||||
if (id != null) {
|
||||
final entry = visibleEntries.firstWhereOrNull((entry) => entry.id == id);
|
||||
if (entry != null) return entry;
|
||||
}
|
||||
return recentEntry(filter);
|
||||
}
|
||||
|
||||
void changeFilterVisibility(Set<CollectionFilter> filters, bool visible) {
|
||||
final hiddenFilters = settings.hiddenFilters;
|
||||
if (visible) {
|
||||
hiddenFilters.removeAll(filters);
|
||||
} else {
|
||||
hiddenFilters.addAll(filters);
|
||||
settings.searchHistory = settings.searchHistory..removeWhere(filters.contains);
|
||||
}
|
||||
settings.hiddenFilters = hiddenFilters;
|
||||
void _onFilterVisibilityChanged(Set<CollectionFilter> oldHiddenFilters, Set<CollectionFilter> currentHiddenFilters) {
|
||||
updateDerivedFilters();
|
||||
eventBus.fire(FilterVisibilityChangedEvent(filters, visible));
|
||||
eventBus.fire(const FilterVisibilityChangedEvent());
|
||||
|
||||
if (visible) {
|
||||
final candidateEntries = visibleEntries.where((entry) => filters.any((f) => f.test(entry))).toSet();
|
||||
final newlyVisibleFilters = oldHiddenFilters.whereNot(currentHiddenFilters.contains).toSet();
|
||||
if (newlyVisibleFilters.isNotEmpty) {
|
||||
final candidateEntries = visibleEntries.where((entry) => newlyVisibleFilters.any((f) => f.test(entry))).toSet();
|
||||
analyze(null, entries: candidateEntries);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import 'package:aves/model/actions/move_type.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
@immutable
|
||||
|
@ -34,10 +33,7 @@ class EntryRefreshedEvent {
|
|||
|
||||
@immutable
|
||||
class FilterVisibilityChangedEvent {
|
||||
final Set<CollectionFilter> filters;
|
||||
final bool visible;
|
||||
|
||||
const FilterVisibilityChangedEvent(this.filters, this.visible);
|
||||
const FilterVisibilityChangedEvent();
|
||||
}
|
||||
|
||||
@immutable
|
||||
|
|
|
@ -20,10 +20,10 @@ mixin LocationMixin on SourceBase {
|
|||
List<String> sortedCountries = List.unmodifiable([]);
|
||||
List<String> sortedPlaces = List.unmodifiable([]);
|
||||
|
||||
Future<void> loadAddresses() async {
|
||||
final saved = await metadataDb.loadAllAddresses();
|
||||
Future<void> loadAddresses({Set<int>? ids}) async {
|
||||
final saved = await (ids != null ? metadataDb.loadAddressesById(ids) : metadataDb.loadAddresses());
|
||||
final idMap = entryById;
|
||||
saved.forEach((metadata) => idMap[metadata.contentId]?.addressDetails = metadata);
|
||||
saved.forEach((metadata) => idMap[metadata.id]?.addressDetails = metadata);
|
||||
onAddressMetadataChanged();
|
||||
}
|
||||
|
||||
|
@ -31,7 +31,7 @@ mixin LocationMixin on SourceBase {
|
|||
await _locateCountries(controller, candidateEntries);
|
||||
await _locatePlaces(controller, candidateEntries);
|
||||
|
||||
final unlocatedIds = candidateEntries.where((entry) => !entry.hasGps).map((entry) => entry.contentId).whereNotNull().toSet();
|
||||
final unlocatedIds = candidateEntries.where((entry) => !entry.hasGps).map((entry) => entry.id).toSet();
|
||||
if (unlocatedIds.isNotEmpty) {
|
||||
await metadataDb.removeIds(unlocatedIds, dataTypes: {EntryDataType.address});
|
||||
onAddressMetadataChanged();
|
||||
|
@ -115,7 +115,7 @@ mixin LocationMixin on SourceBase {
|
|||
for (final entry in todo) {
|
||||
final latLng = approximateLatLng(entry);
|
||||
if (knownLocations.containsKey(latLng)) {
|
||||
entry.addressDetails = knownLocations[latLng]?.copyWith(contentId: entry.contentId);
|
||||
entry.addressDetails = knownLocations[latLng]?.copyWith(id: entry.id);
|
||||
} else {
|
||||
await entry.locatePlace(background: true, force: force, geocoderLocale: settings.appliedLocale);
|
||||
// it is intended to insert `null` if the geocoder failed,
|
||||
|
|
|
@ -14,13 +14,31 @@ import 'package:collection/collection.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class MediaStoreSource extends CollectionSource {
|
||||
bool _initialized = false;
|
||||
SourceInitializationState _initState = SourceInitializationState.none;
|
||||
|
||||
@override
|
||||
bool get initialized => _initialized;
|
||||
SourceInitializationState get initState => _initState;
|
||||
|
||||
@override
|
||||
Future<void> init() async {
|
||||
Future<void> init({
|
||||
AnalysisController? analysisController,
|
||||
String? directory,
|
||||
bool loadTopEntriesFirst = false,
|
||||
}) async {
|
||||
if (_initState == SourceInitializationState.none) {
|
||||
await _loadEssentials();
|
||||
}
|
||||
if (_initState != SourceInitializationState.full) {
|
||||
_initState = directory != null ? SourceInitializationState.directory : SourceInitializationState.full;
|
||||
}
|
||||
unawaited(_loadEntries(
|
||||
analysisController: analysisController,
|
||||
directory: directory,
|
||||
loadTopEntriesFirst: loadTopEntriesFirst,
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> _loadEssentials() async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
stateNotifier.value = SourceState.loading;
|
||||
await metadataDb.init();
|
||||
|
@ -33,52 +51,94 @@ class MediaStoreSource extends CollectionSource {
|
|||
// clear catalog metadata to get correct date/times when moving to a different time zone
|
||||
debugPrint('$runtimeType clear catalog metadata to get correct date/times');
|
||||
await metadataDb.clearDates();
|
||||
await metadataDb.clearMetadataEntries();
|
||||
await metadataDb.clearCatalogMetadata();
|
||||
settings.catalogTimeZone = currentTimeZone;
|
||||
}
|
||||
}
|
||||
await loadDates();
|
||||
_initialized = true;
|
||||
debugPrint('$runtimeType init complete in ${stopwatch.elapsed.inMilliseconds}ms');
|
||||
debugPrint('$runtimeType load essentials complete in ${stopwatch.elapsed.inMilliseconds}ms');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> refresh({AnalysisController? analysisController}) async {
|
||||
assert(_initialized);
|
||||
Future<void> _loadEntries({
|
||||
AnalysisController? analysisController,
|
||||
String? directory,
|
||||
required bool loadTopEntriesFirst,
|
||||
}) async {
|
||||
debugPrint('$runtimeType refresh start');
|
||||
final stopwatch = Stopwatch()..start();
|
||||
stateNotifier.value = SourceState.loading;
|
||||
clearEntries();
|
||||
|
||||
final Set<AvesEntry> topEntries = {};
|
||||
if (loadTopEntriesFirst) {
|
||||
final topIds = settings.topEntryIds;
|
||||
if (topIds != null) {
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} load ${topIds.length} top entries');
|
||||
topEntries.addAll(await metadataDb.loadEntriesById(topIds));
|
||||
addEntries(topEntries);
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} fetch known entries');
|
||||
final oldEntries = await metadataDb.loadAllEntries();
|
||||
final knownEntries = await metadataDb.loadEntries(directory: directory);
|
||||
final knownLiveEntries = knownEntries.where((entry) => !entry.trashed).toSet();
|
||||
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} check obsolete entries');
|
||||
final knownDateById = Map.fromEntries(oldEntries.map((entry) => MapEntry(entry.contentId!, entry.dateModifiedSecs!)));
|
||||
final obsoleteContentIds = (await mediaStoreService.checkObsoleteContentIds(knownDateById.keys.toList())).toSet();
|
||||
oldEntries.removeWhere((entry) => obsoleteContentIds.contains(entry.contentId));
|
||||
final knownDateByContentId = Map.fromEntries(knownLiveEntries.map((entry) => MapEntry(entry.contentId, entry.dateModifiedSecs)));
|
||||
final knownContentIds = knownDateByContentId.keys.toList();
|
||||
final removedContentIds = (await mediaStoreService.checkObsoleteContentIds(knownContentIds)).toSet();
|
||||
if (topEntries.isNotEmpty) {
|
||||
final removedTopEntries = topEntries.where((entry) => removedContentIds.contains(entry.contentId));
|
||||
await removeEntries(removedTopEntries.map((entry) => entry.uri).toSet(), includeTrash: false);
|
||||
}
|
||||
final removedEntries = knownEntries.where((entry) => removedContentIds.contains(entry.contentId)).toSet();
|
||||
knownEntries.removeAll(removedEntries);
|
||||
|
||||
// show known entries
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} add known entries');
|
||||
addEntries(oldEntries);
|
||||
addEntries(knownEntries);
|
||||
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} load metadata');
|
||||
await loadCatalogMetadata();
|
||||
await loadAddresses();
|
||||
updateDerivedFilters();
|
||||
if (directory != null) {
|
||||
final ids = knownLiveEntries.map((entry) => entry.id).toSet();
|
||||
await loadCatalogMetadata(ids: ids);
|
||||
await loadAddresses(ids: ids);
|
||||
} else {
|
||||
await loadCatalogMetadata();
|
||||
await loadAddresses();
|
||||
updateDerivedFilters();
|
||||
}
|
||||
|
||||
// clean up obsolete entries
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} remove obsolete entries');
|
||||
await metadataDb.removeIds(obsoleteContentIds);
|
||||
if (removedEntries.isNotEmpty) {
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} remove obsolete entries');
|
||||
await metadataDb.removeIds(removedEntries.map((entry) => entry.id));
|
||||
}
|
||||
|
||||
if (directory != null) {
|
||||
// trash
|
||||
await loadTrashDetails();
|
||||
unawaited(deleteExpiredTrash().then(
|
||||
(deletedUris) {
|
||||
if (deletedUris.isNotEmpty) {
|
||||
debugPrint('evicted ${deletedUris.length} expired items from the trash');
|
||||
removeEntries(deletedUris, includeTrash: true);
|
||||
}
|
||||
},
|
||||
onError: (error) => debugPrint('failed to evict expired trash error=$error'),
|
||||
));
|
||||
}
|
||||
|
||||
// verify paths because some apps move files without updating their `last modified date`
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} check obsolete paths');
|
||||
final knownPathById = Map.fromEntries(allEntries.map((entry) => MapEntry(entry.contentId!, entry.path)));
|
||||
final movedContentIds = (await mediaStoreService.checkObsoletePaths(knownPathById)).toSet();
|
||||
final knownPathByContentId = Map.fromEntries(knownLiveEntries.map((entry) => MapEntry(entry.contentId, entry.path)));
|
||||
final movedContentIds = (await mediaStoreService.checkObsoletePaths(knownPathByContentId)).toSet();
|
||||
movedContentIds.forEach((contentId) {
|
||||
// make obsolete by resetting its modified date
|
||||
knownDateById[contentId] = 0;
|
||||
knownDateByContentId[contentId] = 0;
|
||||
});
|
||||
|
||||
// fetch new entries
|
||||
// fetch new & modified entries
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} fetch new entries');
|
||||
// refresh after the first 10 entries, then after 100 more, then every 1000 entries
|
||||
var refreshCount = 10;
|
||||
|
@ -90,8 +150,13 @@ class MediaStoreSource extends CollectionSource {
|
|||
pendingNewEntries.clear();
|
||||
}
|
||||
|
||||
mediaStoreService.getEntries(knownDateById).listen(
|
||||
mediaStoreService.getEntries(knownDateByContentId, directory: directory).listen(
|
||||
(entry) {
|
||||
// when discovering modified entry with known content ID,
|
||||
// reuse known entry ID to overwrite it while preserving favourites, etc.
|
||||
final contentId = entry.contentId;
|
||||
entry.id = (knownContentIds.contains(contentId) ? knownLiveEntries.firstWhereOrNull((entry) => entry.contentId == contentId)?.id : null) ?? metadataDb.nextId;
|
||||
|
||||
pendingNewEntries.add(entry);
|
||||
if (pendingNewEntries.length >= refreshCount) {
|
||||
refreshCount = min(refreshCount * 10, refreshCountMax);
|
||||
|
@ -111,14 +176,15 @@ class MediaStoreSource extends CollectionSource {
|
|||
updateDirectories();
|
||||
}
|
||||
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} analyze');
|
||||
Set<AvesEntry>? analysisEntries;
|
||||
final analysisIds = analysisController?.contentIds;
|
||||
final analysisIds = analysisController?.entryIds;
|
||||
if (analysisIds != null) {
|
||||
analysisEntries = visibleEntries.where((entry) => analysisIds.contains(entry.contentId)).toSet();
|
||||
analysisEntries = visibleEntries.where((entry) => analysisIds.contains(entry.id)).toSet();
|
||||
}
|
||||
await analyze(analysisController, entries: analysisEntries);
|
||||
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} done for ${oldEntries.length} known, ${allNewEntries.length} new, ${obsoleteContentIds.length} obsolete');
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} done for ${knownEntries.length} known, ${allNewEntries.length} new, ${removedEntries.length} removed');
|
||||
},
|
||||
onError: (error) => debugPrint('$runtimeType stream error=$error'),
|
||||
);
|
||||
|
@ -131,7 +197,7 @@ class MediaStoreSource extends CollectionSource {
|
|||
// sometimes yields an entry with its temporary path: `/data/sec/camera/!@#$%^..._temp.jpg`
|
||||
@override
|
||||
Future<Set<String>> refreshUris(Set<String> changedUris, {AnalysisController? analysisController}) async {
|
||||
if (!_initialized || !isMonitoring) return changedUris;
|
||||
if (_initState == SourceInitializationState.none || !isMonitoring) return changedUris;
|
||||
|
||||
debugPrint('$runtimeType refreshUris ${changedUris.length} uris');
|
||||
final uriByContentId = Map.fromEntries(changedUris.map((uri) {
|
||||
|
@ -147,7 +213,7 @@ class MediaStoreSource extends CollectionSource {
|
|||
// clean up obsolete entries
|
||||
final obsoleteContentIds = (await mediaStoreService.checkObsoleteContentIds(uriByContentId.keys.toList())).toSet();
|
||||
final obsoleteUris = obsoleteContentIds.map((contentId) => uriByContentId[contentId]).whereNotNull().toSet();
|
||||
await removeEntries(obsoleteUris);
|
||||
await removeEntries(obsoleteUris, includeTrash: false);
|
||||
obsoleteContentIds.forEach(uriByContentId.remove);
|
||||
|
||||
// fetch new entries
|
||||
|
@ -165,6 +231,7 @@ class MediaStoreSource extends CollectionSource {
|
|||
final newPath = sourceEntry.path;
|
||||
final volume = newPath != null ? androidFileUtils.getStorageVolume(newPath) : null;
|
||||
if (volume != null) {
|
||||
sourceEntry.id = existingEntry?.id ?? metadataDb.nextId;
|
||||
newEntries.add(sourceEntry);
|
||||
final existingDirectory = existingEntry?.directory;
|
||||
if (existingDirectory != null) {
|
||||
|
|
|
@ -14,10 +14,10 @@ mixin TagMixin on SourceBase {
|
|||
|
||||
List<String> sortedTags = List.unmodifiable([]);
|
||||
|
||||
Future<void> loadCatalogMetadata() async {
|
||||
final saved = await metadataDb.loadAllMetadataEntries();
|
||||
Future<void> loadCatalogMetadata({Set<int>? ids}) async {
|
||||
final saved = await (ids != null ? metadataDb.loadCatalogMetadataById(ids) : metadataDb.loadCatalogMetadata());
|
||||
final idMap = entryById;
|
||||
saved.forEach((metadata) => idMap[metadata.contentId]?.catalogMetadata = metadata);
|
||||
saved.forEach((metadata) => idMap[metadata.id]?.catalogMetadata = metadata);
|
||||
onCatalogMetadataChanged();
|
||||
}
|
||||
|
||||
|
@ -42,7 +42,7 @@ mixin TagMixin on SourceBase {
|
|||
if (entry.isCatalogued) {
|
||||
newMetadata.add(entry.catalogMetadata!);
|
||||
if (newMetadata.length >= commitCountThreshold) {
|
||||
await metadataDb.saveMetadata(Set.unmodifiable(newMetadata));
|
||||
await metadataDb.saveCatalogMetadata(Set.unmodifiable(newMetadata));
|
||||
onCatalogMetadataChanged();
|
||||
newMetadata.clear();
|
||||
}
|
||||
|
@ -53,7 +53,7 @@ mixin TagMixin on SourceBase {
|
|||
}
|
||||
setProgress(done: ++progressDone, total: progressTotal);
|
||||
}
|
||||
await metadataDb.saveMetadata(Set.unmodifiable(newMetadata));
|
||||
await metadataDb.saveCatalogMetadata(Set.unmodifiable(newMetadata));
|
||||
onCatalogMetadataChanged();
|
||||
}
|
||||
|
||||
|
|