diff --git a/.flutter b/.flutter
index 994429713..c07f78888 160000
--- a/.flutter
+++ b/.flutter
@@ -1 +1 @@
-Subproject commit 9944297138845a94256f1cf37beb88ff9a8e811a
+Subproject commit c07f7888888435fd9df505aa2efc38d3cf65681b
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3016c71ab..c1f2f4ffd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,25 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
+## [v1.8.2] - 2023-02-28
+
+### Added
+
+- Export: bulk converting
+- Export: write metadata when converting
+- Places: page & navigation entry
+
+### Changed
+
+- rating/tagging action icons
+- upgraded Flutter to stable v3.7.5
+
+### Fixed
+
+- viewer pan/scale gestures interpreted as fling gestures
+- replacing when moving item to vault
+- exporting item to vault
+
## [v1.8.1] - 2023-02-21
### Added
diff --git a/android/app/build.gradle b/android/app/build.gradle
index b974a5ee6..eb27e5c2e 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -188,12 +188,12 @@ dependencies {
implementation 'androidx.lifecycle:lifecycle-process:2.5.1'
implementation 'androidx.media:media:1.6.0'
implementation 'androidx.multidex:multidex:2.0.1'
- implementation 'androidx.security:security-crypto:1.1.0-alpha04'
+ implementation 'androidx.security:security-crypto:1.1.0-alpha05'
implementation 'com.caverock:androidsvg-aar:1.4'
implementation 'com.commonsware.cwac:document:0.5.0'
implementation 'com.drewnoakes:metadata-extractor:2.18.0'
- implementation 'com.github.bumptech.glide:glide:4.14.2'
+ implementation 'com.github.bumptech.glide:glide:4.15.0'
// SLF4J implementation for `mp4parser`
implementation 'org.slf4j:slf4j-simple:2.0.6'
@@ -207,10 +207,10 @@ dependencies {
implementation 'com.github.deckerst:pixymeta-android:706bd73d6e'
// huawei flavor only
- huaweiImplementation 'com.huawei.agconnect:agconnect-core:1.7.2.300'
+ huaweiImplementation 'com.huawei.agconnect:agconnect-core:1.8.0.300'
- kapt 'androidx.annotation:annotation:1.5.0'
- kapt 'com.github.bumptech.glide:compiler:4.14.2'
+ kapt 'androidx.annotation:annotation:1.6.0'
+ kapt 'com.github.bumptech.glide:compiler:4.15.0'
compileOnly rootProject.findProject(':streams_channel')
}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 7553cb54f..ef519cae2 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -224,7 +224,7 @@ This change eventually prevents building the app with Flutter v3.3.3.
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt
index dce85d755..947d7d738 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt
@@ -50,7 +50,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
when (op) {
"delete" -> ioScope.launch { delete() }
- "export" -> ioScope.launch { export() }
+ "convert" -> ioScope.launch { convert() }
"move" -> ioScope.launch { move() }
"rename" -> ioScope.launch { rename() }
else -> endOfStream()
@@ -92,19 +92,6 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
}
private suspend fun delete() {
- if (entryMapList.isEmpty()) {
- endOfStream()
- 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("delete-provider", "failed to find provider for entry=$firstEntry", null)
- return
- }
-
val entries = entryMapList.map(::AvesEntry)
for (entry in entries) {
val mimeType = entry.mimeType
@@ -119,12 +106,14 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
if (isCancelledOp()) {
result["skipped"] = true
} else {
- try {
- provider.delete(activity, uri, path, mimeType)
- result["success"] = true
- } catch (e: Exception) {
- Log.w(LOG_TAG, "failed to delete entry with path=$path", e)
- result["success"] = false
+ result["success"] = false
+ getProvider(uri)?.let { provider ->
+ try {
+ provider.delete(activity, uri, path, mimeType)
+ result["success"] = true
+ } catch (e: Exception) {
+ Log.w(LOG_TAG, "failed to delete entry with path=$path", e)
+ }
}
}
success(result)
@@ -132,7 +121,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
endOfStream()
}
- private suspend fun export() {
+ private suspend fun convert() {
if (arguments !is Map<*, *> || entryMapList.isEmpty()) {
endOfStream()
return
@@ -140,11 +129,13 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
var destinationDir = arguments["destinationPath"] as String?
val mimeType = arguments["mimeType"] as String?
+ val lengthUnit = arguments["lengthUnit"] as String?
val width = (arguments["width"] as Number?)?.toInt()
val height = (arguments["height"] as Number?)?.toInt()
+ val writeMetadata = arguments["writeMetadata"] as Boolean?
val nameConflictStrategy = NameConflictStrategy.get(arguments["nameConflictStrategy"] as String?)
- if (destinationDir == null || mimeType == null || width == null || height == null || nameConflictStrategy == null) {
- error("export-args", "missing arguments", null)
+ if (destinationDir == null || mimeType == null || lengthUnit == null || width == null || height == null || writeMetadata == null || nameConflictStrategy == null) {
+ error("convert-args", "missing arguments", null)
return
}
@@ -152,16 +143,27 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
val firstEntry = entryMapList.first()
val provider = (firstEntry["uri"] as String?)?.let { Uri.parse(it) }?.let { getProvider(it) }
if (provider == null) {
- error("export-provider", "failed to find provider for entry=$firstEntry", null)
+ error("convert-provider", "failed to find provider for entry=$firstEntry", null)
return
}
destinationDir = ensureTrailingSeparator(destinationDir)
val entries = entryMapList.map(::AvesEntry)
- provider.exportMultiple(activity, mimeType, destinationDir, entries, width, height, nameConflictStrategy, object : ImageOpCallback {
- override fun onSuccess(fields: FieldMap) = success(fields)
- override fun onFailure(throwable: Throwable) = error("export-failure", "failed to export entries", throwable)
- })
+ provider.convertMultiple(
+ activity = activity,
+ imageExportMimeType = mimeType,
+ targetDir = destinationDir,
+ entries = entries,
+ lengthUnit = lengthUnit,
+ width = width,
+ height = height,
+ writeMetadata = writeMetadata,
+ nameConflictStrategy = nameConflictStrategy,
+ callback = object : ImageOpCallback {
+ override fun onSuccess(fields: FieldMap) = success(fields)
+ override fun onFailure(throwable: Throwable) = error("convert-failure", "failed to convert entries", throwable)
+ },
+ )
endOfStream()
}
@@ -193,10 +195,17 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
// 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)
- })
+ provider.moveMultiple(
+ activity = activity,
+ copy = copy,
+ nameConflictStrategy = nameConflictStrategy,
+ entriesByTargetDir = entriesByTargetDir,
+ isCancelledOp = ::isCancelledOp,
+ callback = object : ImageOpCallback {
+ override fun onSuccess(fields: FieldMap) = success(fields)
+ override fun onFailure(throwable: Throwable) = error("move-failure", "failed to move entries", throwable)
+ },
+ )
endOfStream()
}
@@ -228,10 +237,15 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
}
val entryMap = mapOf(*entryList.map { Pair(it.key, it.value) }.toTypedArray())
- provider.renameMultiple(activity, entryMap, ::isCancelledOp, object : ImageOpCallback {
- override fun onSuccess(fields: FieldMap) = success(fields)
- override fun onFailure(throwable: Throwable) = error("rename-failure", "failed to rename", throwable.message)
- })
+ provider.renameMultiple(
+ activity = activity,
+ entriesToNewName = entryMap,
+ isCancelledOp = ::isCancelledOp,
+ callback = object : ImageOpCallback {
+ override fun onSuccess(fields: FieldMap) = success(fields)
+ override fun onFailure(throwable: Throwable) = error("rename-failure", "failed to rename", throwable.message)
+ },
+ )
}
endOfStream()
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/AvesEntry.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/AvesEntry.kt
index abb2afed5..71f126f0d 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/model/AvesEntry.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/AvesEntry.kt
@@ -15,6 +15,9 @@ class AvesEntry(map: FieldMap) {
val trashed = map["trashed"] as Boolean
val trashPath = map["trashPath"] as String?
+ val isRotated: Boolean
+ get() = rotationDegrees % 180 == 90
+
companion object {
// convenience method
private fun toLong(o: Any?): Long? = when (o) {
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/FileImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/FileImageProvider.kt
index 7ef565d8b..e9536dd6e 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/FileImageProvider.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/FileImageProvider.kt
@@ -5,6 +5,7 @@ import android.content.Context
import android.content.ContextWrapper
import android.net.Uri
import android.util.Log
+import android.webkit.MimeTypeMap
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.SourceEntry
import deckers.thibault.aves.utils.LogUtils
@@ -12,12 +13,19 @@ import java.io.File
internal class FileImageProvider : ImageProvider() {
override fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) {
- if (sourceMimeType == null) {
- callback.onFailure(Exception("MIME type is null for uri=$uri"))
- return
+ val mimeType = if (sourceMimeType != null) {
+ sourceMimeType
+ } else {
+ val fromExtension = MimeTypeMap.getFileExtensionFromUrl(uri.toString())
+ if (fromExtension != null) {
+ fromExtension
+ } else {
+ callback.onFailure(Exception("MIME type was not provided and cannot be guessed from extension of uri=$uri"))
+ return
+ }
}
- val entry = SourceEntry(SourceEntry.ORIGIN_FILE, uri, sourceMimeType)
+ val entry = SourceEntry(SourceEntry.ORIGIN_FILE, uri, mimeType)
val path = uri.path
if (path != null) {
@@ -45,13 +53,15 @@ internal class FileImageProvider : ImageProvider() {
}
override suspend fun delete(contextWrapper: ContextWrapper, uri: Uri, path: String?, mimeType: String) {
- val file = File(File(uri.path!!).path)
- if (!file.exists()) return
+ path ?: throw Exception("failed to delete file because path is null")
- Log.d(LOG_TAG, "delete file at uri=$uri")
- if (file.delete()) return
-
- throw Exception("failed to delete entry with uri=$uri path=$path")
+ val file = File(path)
+ if (file.exists()) {
+ Log.d(LOG_TAG, "delete file at path=$path")
+ if (!file.delete()) {
+ throw Exception("failed to delete entry with uri=$uri path=$path")
+ }
+ }
}
override suspend fun renameSingle(
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt
index 8e973af43..05fb4df29 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt
@@ -31,19 +31,20 @@ import deckers.thibault.aves.metadata.Mp4ParserHelper.updateRotation
import deckers.thibault.aves.metadata.Mp4ParserHelper.updateXmp
import deckers.thibault.aves.metadata.PixyMetaHelper.extendedXmpDocString
import deckers.thibault.aves.metadata.PixyMetaHelper.xmpDocString
-import deckers.thibault.aves.model.AvesEntry
-import deckers.thibault.aves.model.ExifOrientationOp
-import deckers.thibault.aves.model.FieldMap
-import deckers.thibault.aves.model.NameConflictStrategy
+import deckers.thibault.aves.model.*
import deckers.thibault.aves.utils.*
import deckers.thibault.aves.utils.FileUtils.transferFrom
import deckers.thibault.aves.utils.FileUtils.transferTo
import deckers.thibault.aves.utils.MimeTypes.canEditExif
import deckers.thibault.aves.utils.MimeTypes.canEditIptc
import deckers.thibault.aves.utils.MimeTypes.canEditXmp
+import deckers.thibault.aves.utils.MimeTypes.canReadWithExifInterface
+import deckers.thibault.aves.utils.MimeTypes.canReadWithPixyMeta
import deckers.thibault.aves.utils.MimeTypes.canRemoveMetadata
import deckers.thibault.aves.utils.MimeTypes.extensionFor
import deckers.thibault.aves.utils.MimeTypes.isVideo
+import pixy.meta.meta.Metadata
+import pixy.meta.meta.MetadataType
import java.io.*
import java.nio.channels.Channels
import java.util.*
@@ -53,6 +54,35 @@ abstract class ImageProvider {
callback.onFailure(UnsupportedOperationException("`fetchSingle` is not supported by this image provider"))
}
+ suspend fun scanNewPath(context: Context, path: String, mimeType: String): FieldMap {
+ return if (StorageUtils.isInVault(context, path)) {
+ val uri = Uri.fromFile(File(path))
+ hashMapOf(
+ "origin" to SourceEntry.ORIGIN_VAULT,
+ "uri" to uri.toString(),
+ "contentId" to null,
+ "path" to path,
+ )
+ } else {
+ MediaStoreImageProvider().scanNewPathByMediaStore(context, path, mimeType)
+ }
+ }
+
+ private suspend fun deletePath(contextWrapper: ContextWrapper, path: String, mimeType: String) {
+ if (StorageUtils.isInVault(contextWrapper, path)) {
+ FileImageProvider().apply {
+ val uri = Uri.fromFile(File(path))
+ delete(contextWrapper, uri, path, mimeType)
+ }
+ } else {
+ MediaStoreImageProvider().apply {
+ val uri = getContentUriForPath(contextWrapper, path)
+ uri ?: throw Exception("failed to find content URI for path=$path")
+ delete(contextWrapper, uri, path, mimeType)
+ }
+ }
+ }
+
open suspend fun delete(contextWrapper: ContextWrapper, uri: Uri, path: String?, mimeType: String) {
throw UnsupportedOperationException("`delete` is not supported by this image provider")
}
@@ -143,13 +173,15 @@ abstract class ImageProvider {
throw UnsupportedOperationException("`scanPostMetadataEdit` is not supported by this image provider")
}
- suspend fun exportMultiple(
+ suspend fun convertMultiple(
activity: Activity,
imageExportMimeType: String,
targetDir: String,
entries: List,
+ lengthUnit: String,
width: Int,
height: Int,
+ writeMetadata: Boolean,
nameConflictStrategy: NameConflictStrategy,
callback: ImageOpCallback,
) {
@@ -177,32 +209,36 @@ abstract class ImageProvider {
val sourceMimeType = entry.mimeType
val exportMimeType = if (isVideo(sourceMimeType)) sourceMimeType else imageExportMimeType
try {
- val newFields = exportSingle(
+ val newFields = convertSingle(
activity = activity,
sourceEntry = entry,
targetDir = targetDir,
targetDirDocFile = targetDirDocFile,
+ lengthUnit = lengthUnit,
width = width,
height = height,
+ writeMetadata = writeMetadata,
nameConflictStrategy = nameConflictStrategy,
exportMimeType = exportMimeType,
)
result["newFields"] = newFields
result["success"] = true
} catch (e: Exception) {
- Log.w(LOG_TAG, "failed to export to targetDir=$targetDir entry with sourcePath=$sourcePath pageId=$pageId", e)
+ Log.w(LOG_TAG, "failed to convert to targetDir=$targetDir entry with sourcePath=$sourcePath pageId=$pageId", e)
}
callback.onSuccess(result)
}
}
- private suspend fun exportSingle(
+ private suspend fun convertSingle(
activity: Activity,
sourceEntry: AvesEntry,
targetDir: String,
targetDirDocFile: DocumentFileCompat?,
+ lengthUnit: String,
width: Int,
height: Int,
+ writeMetadata: Boolean,
nameConflictStrategy: NameConflictStrategy,
exportMimeType: String,
): FieldMap {
@@ -240,6 +276,13 @@ abstract class ImageProvider {
sourceDocFile.copyTo(output)
}
} else {
+ var targetWidthPx: Int = if (sourceEntry.isRotated) height else width
+ var targetHeightPx: Int = if (sourceEntry.isRotated) width else height
+ if (lengthUnit == LENGTH_UNIT_PERCENT) {
+ targetWidthPx = sourceEntry.width * targetWidthPx / 100
+ targetHeightPx = sourceEntry.height * targetHeightPx / 100
+ }
+
val model: Any = if (MimeTypes.isHeic(sourceMimeType) && pageId != null) {
MultiTrackImage(activity, sourceUri, pageId)
} else if (sourceMimeType == MimeTypes.TIFF) {
@@ -260,7 +303,7 @@ abstract class ImageProvider {
.asBitmap()
.apply(glideOptions)
.load(model)
- .submit(width, height)
+ .submit(targetWidthPx, targetHeightPx)
@Suppress("BlockingMethodInNonBlockingContext")
var bitmap = target.get()
if (MimeTypes.needRotationAfterGlide(sourceMimeType)) {
@@ -294,8 +337,7 @@ abstract class ImageProvider {
}
}
- val mediaStoreImageProvider = MediaStoreImageProvider()
- val targetPath = mediaStoreImageProvider.createSingle(
+ val targetPath = MediaStoreImageProvider().createSingle(
activity = activity,
mimeType = targetMimeType,
targetDir = targetDir,
@@ -303,13 +345,114 @@ abstract class ImageProvider {
targetNameWithoutExtension = targetNameWithoutExtension,
write = write,
)
- return mediaStoreImageProvider.scanNewPath(activity, targetPath, exportMimeType)
+
+ val newFields = scanNewPath(activity, targetPath, exportMimeType)
+ val targetUri = Uri.parse(newFields["uri"] as String)
+ if (writeMetadata) {
+ copyMetadata(
+ context = activity,
+ sourceMimeType = sourceMimeType,
+ sourceUri = sourceUri,
+ targetMimeType = targetMimeType,
+ targetUri = targetUri,
+ targetPath = targetPath,
+ )
+ }
+
+ return newFields
} finally {
// clearing Glide target should happen after effectively writing the bitmap
Glide.with(activity).clear(target)
}
}
+ private fun copyMetadata(
+ context: Context,
+ sourceMimeType: String,
+ sourceUri: Uri,
+ targetMimeType: String,
+ targetUri: Uri,
+ targetPath: String,
+ ) {
+ val editableFile = File.createTempFile("aves", null).apply {
+ deleteOnExit()
+ // copy original file to a temporary file for editing
+ val inputStream = StorageUtils.openInputStream(context, targetUri)
+ transferFrom(inputStream, File(targetPath).length())
+ }
+
+ // copy IPTC / XMP via PixyMeta
+
+ var pixyIptc: pixy.meta.meta.iptc.IPTC? = null
+ var pixyXmp: pixy.meta.meta.xmp.XMP? = null
+ if (canReadWithPixyMeta(sourceMimeType)) {
+ StorageUtils.openInputStream(context, sourceUri)?.use { input ->
+ val metadata = Metadata.readMetadata(input)
+ if (canEditIptc(targetMimeType)) {
+ pixyIptc = metadata[MetadataType.IPTC] as pixy.meta.meta.iptc.IPTC?
+ }
+ if (canEditXmp(targetMimeType)) {
+ pixyXmp = metadata[MetadataType.XMP] as pixy.meta.meta.xmp.XMP?
+ }
+ }
+ }
+ if (pixyIptc != null || pixyXmp != null) {
+ editableFile.outputStream().use { output ->
+ if (pixyIptc != null) {
+ // reopen input to read from start
+ StorageUtils.openInputStream(context, targetUri)?.use { input ->
+ val iptcs = pixyIptc!!.dataSets.flatMap { it.value }
+ Metadata.insertIPTC(input, output, iptcs)
+ }
+ }
+ if (pixyXmp != null) {
+ // reopen input to read from start
+ StorageUtils.openInputStream(context, targetUri)?.use { input ->
+ val xmpString = pixyXmp!!.xmpDocString()
+ val extendedXmp = if (pixyXmp!!.hasExtendedXmp()) pixyXmp!!.extendedXmpDocString() else null
+ PixyMetaHelper.setXmp(input, output, xmpString, if (targetMimeType == MimeTypes.JPEG) extendedXmp else null)
+ }
+ }
+ }
+ }
+
+ // copy Exif via ExifInterface
+
+ val exif = HashMap()
+ val skippedTags = listOf(
+ ExifInterface.TAG_IMAGE_LENGTH,
+ ExifInterface.TAG_IMAGE_WIDTH,
+ ExifInterface.TAG_ORIENTATION,
+ // Thumbnail Offset / Length
+ ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT,
+ ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT_LENGTH,
+ // Exif Image Width / Height
+ ExifInterface.TAG_PIXEL_X_DIMENSION,
+ ExifInterface.TAG_PIXEL_Y_DIMENSION,
+ )
+ if (canReadWithExifInterface(sourceMimeType) && canEditExif(targetMimeType)) {
+ StorageUtils.openInputStream(context, sourceUri)?.use { input ->
+ ExifInterface(input).apply {
+ ExifInterfaceHelper.allTags.keys.filterNot { skippedTags.contains(it) }.filter { hasAttribute(it) }.forEach { tag ->
+ exif[tag] = getAttribute(tag)
+ }
+ }
+ }
+ }
+ if (exif.isNotEmpty()) {
+ ExifInterface(editableFile).apply {
+ exif.entries.forEach { (tag, value) ->
+ setAttribute(tag, value)
+ }
+ saveAttributes()
+ }
+ }
+
+ // copy the edited temporary file back to the original
+ editableFile.transferTo(outputStream(context, targetMimeType, targetUri, targetPath))
+ editableFile.delete()
+ }
+
@Suppress("BlockingMethodInNonBlockingContext")
suspend fun captureFrame(
contextWrapper: ContextWrapper,
@@ -422,7 +565,7 @@ abstract class ImageProvider {
val fileName = targetDocFile.name
val targetFullPath = targetDir + fileName
- val newFields = MediaStoreImageProvider().scanNewPath(contextWrapper, targetFullPath, captureMimeType)
+ val newFields = scanNewPath(contextWrapper, targetFullPath, captureMimeType)
callback.onSuccess(newFields)
} catch (e: Exception) {
callback.onFailure(e)
@@ -451,12 +594,7 @@ abstract class ImageProvider {
}
NameConflictStrategy.REPLACE -> {
if (targetFile.exists()) {
- val path = targetFile.path
- MediaStoreImageProvider().apply {
- val uri = getContentUriForPath(contextWrapper, path)
- uri ?: throw Exception("failed to find content URI for path=$path")
- delete(contextWrapper, uri, path, mimeType)
- }
+ deletePath(contextWrapper, targetFile.path, mimeType)
}
desiredNameWithoutExtension
}
@@ -1189,6 +1327,8 @@ abstract class ImageProvider {
companion object {
private val LOG_TAG = LogUtils.createTag()
+ private const val LENGTH_UNIT_PERCENT = "percent"
+
val supportedExportMimeTypes = listOf(MimeTypes.BMP, MimeTypes.JPEG, MimeTypes.PNG, MimeTypes.WEBP)
// used when skipping a move/creation op because the target file already exists
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt
index c57872841..3983ff52e 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt
@@ -9,7 +9,7 @@ import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.util.Log
-import androidx.core.net.toUri
+import androidx.annotation.RequiresApi
import com.commonsware.cwac.document.DocumentFileCompat
import deckers.thibault.aves.MainActivity
import deckers.thibault.aves.MainActivity.Companion.DELETE_SINGLE_PERMISSION_REQUEST
@@ -30,6 +30,7 @@ import deckers.thibault.aves.utils.UriUtils.tryParseId
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import java.io.File
+import java.io.FileOutputStream
import java.io.OutputStream
import java.io.SyncFailedException
import java.util.*
@@ -474,7 +475,6 @@ class MediaStoreImageProvider : ImageProvider() {
mimeType = mimeType,
copy = copy,
toBin = toBin,
- toVault = toVault,
)
}
}
@@ -501,7 +501,6 @@ class MediaStoreImageProvider : ImageProvider() {
mimeType: String,
copy: Boolean,
toBin: Boolean,
- toVault: Boolean,
): FieldMap {
val sourcePath = sourceFile.path
val sourceDir = sourceFile.parent?.let { ensureTrailingSeparator(it) }
@@ -550,21 +549,11 @@ class MediaStoreImageProvider : ImageProvider() {
"trashed" to true,
"trashPath" to targetPath,
)
- } else if (toVault) {
- hashMapOf(
- "origin" to SourceEntry.ORIGIN_VAULT,
- "uri" to File(targetPath).toUri().toString(),
- "contentId" to null,
- "path" to targetPath,
- )
} else {
scanNewPath(activity, targetPath, mimeType)
}
}
- // `DocumentsContract.moveDocument()` needs `sourceParentDocumentUri`, which could be different for each entry
- // `DocumentsContract.copyDocument()` yields "Unsupported call: android:copyDocument"
- // when used with entry URI as `sourceDocumentUri`, and targetDirDocFile URI as `targetParentDocumentUri`
fun createSingle(
activity: Activity,
mimeType: String,
@@ -573,33 +562,86 @@ class MediaStoreImageProvider : ImageProvider() {
targetNameWithoutExtension: String,
write: (OutputStream) -> Unit,
): String {
+ if (StorageUtils.isInVault(activity, targetDir)) {
+ return insertByFile(
+ targetDir = targetDir,
+ targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType)}",
+ write = write,
+ )
+ }
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val downloadDirPath = StorageUtils.getDownloadDirPath(activity, targetDir)
val isDownloadSubdir = downloadDirPath != null && targetDir.startsWith(downloadDirPath)
if (isDownloadSubdir) {
- val volumePath = StorageUtils.getVolumePath(activity, targetDir)
- val relativePath = targetDir.substring(volumePath?.length ?: 0)
-
- val targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType)}"
- val values = ContentValues().apply {
- put(MediaStore.MediaColumns.DISPLAY_NAME, targetFileName)
- put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
- put(MediaStore.MediaColumns.IS_PENDING, 1)
- }
- val resolver = activity.contentResolver
- val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
-
- uri?.let {
- resolver.openOutputStream(uri)?.use(write)
- values.clear()
- values.put(MediaStore.MediaColumns.IS_PENDING, 0)
- resolver.update(uri, values, null, null)
- } ?: throw Exception("MediaStore failed for some reason")
-
- return File(targetDir, targetFileName).path
+ return insertByMediaStore(
+ activity = activity,
+ targetDir = targetDir,
+ targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType)}",
+ write = write,
+ )
}
}
+ return insertByTreeDoc(
+ activity = activity,
+ mimeType = mimeType,
+ targetDir = targetDir,
+ targetDirDocFile = targetDirDocFile,
+ targetNameWithoutExtension = targetNameWithoutExtension,
+ write = write,
+ )
+ }
+
+ private fun insertByFile(
+ targetDir: String,
+ targetFileName: String,
+ write: (OutputStream) -> Unit,
+ ): String {
+ val file = File(targetDir, targetFileName)
+ FileOutputStream(file).use(write)
+ return file.path
+ }
+
+ @RequiresApi(Build.VERSION_CODES.Q)
+ private fun insertByMediaStore(
+ activity: Activity,
+ targetDir: String,
+ targetFileName: String,
+ write: (OutputStream) -> Unit,
+ ): String {
+ val volumePath = StorageUtils.getVolumePath(activity, targetDir)
+ val relativePath = targetDir.substring(volumePath?.length ?: 0)
+
+ val values = ContentValues().apply {
+ put(MediaStore.MediaColumns.DISPLAY_NAME, targetFileName)
+ put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
+ put(MediaStore.MediaColumns.IS_PENDING, 1)
+ }
+ val resolver = activity.contentResolver
+ val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
+
+ uri?.let {
+ resolver.openOutputStream(uri)?.use(write)
+ values.clear()
+ values.put(MediaStore.MediaColumns.IS_PENDING, 0)
+ resolver.update(uri, values, null, null)
+ } ?: throw Exception("MediaStore failed for some reason")
+
+ return File(targetDir, targetFileName).path
+ }
+
+ // `DocumentsContract.moveDocument()` needs `sourceParentDocumentUri`, which could be different for each entry
+ // `DocumentsContract.copyDocument()` yields "Unsupported call: android:copyDocument"
+ // when used with entry URI as `sourceDocumentUri`, and targetDirDocFile URI as `targetParentDocumentUri`
+ private fun insertByTreeDoc(
+ activity: Activity,
+ mimeType: String,
+ targetDir: String,
+ targetDirDocFile: DocumentFileCompat?,
+ targetNameWithoutExtension: String,
+ write: (OutputStream) -> Unit,
+ ): String {
targetDirDocFile ?: throw Exception("failed to get tree doc for directory at path=$targetDir")
// the file created from a `TreeDocumentFile` is also a `TreeDocumentFile`
@@ -670,7 +712,7 @@ class MediaStoreImageProvider : ImageProvider() {
}
// URI should not change
- return scanNewPath(activity, newFile.path, mimeType)
+ return scanNewPathByMediaStore(activity, newFile.path, mimeType)
}
private suspend fun renameSingleByTreeDoc(
@@ -690,7 +732,7 @@ class MediaStoreImageProvider : ImageProvider() {
throw Exception("failed to rename document at path=$oldPath")
}
scanObsoletePath(activity, oldMediaUri, oldPath, mimeType)
- return scanNewPath(activity, newFile.path, mimeType)
+ return scanNewPathByMediaStore(activity, newFile.path, mimeType)
}
private suspend fun renameSingleByFile(
@@ -706,7 +748,7 @@ class MediaStoreImageProvider : ImageProvider() {
throw Exception("failed to rename file at path=$oldPath")
}
scanObsoletePath(activity, oldMediaUri, oldPath, mimeType)
- return scanNewPath(activity, newFile.path, mimeType)
+ return scanNewPathByMediaStore(activity, newFile.path, mimeType)
}
override fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: FieldMap, callback: ImageOpCallback) {
@@ -757,10 +799,23 @@ class MediaStoreImageProvider : ImageProvider() {
}
}
- suspend fun scanNewPath(context: Context, path: String, mimeType: String): FieldMap =
- suspendCoroutine { cont -> tryScanNewPath(context, path = path, mimeType = mimeType, cont) }
+ suspend fun scanNewPathByMediaStore(context: Context, path: String, mimeType: String): FieldMap =
+ suspendCoroutine { cont ->
+ tryScanNewPathByMediaStore(
+ context = context,
+ path = path,
+ mimeType = mimeType,
+ cont = cont,
+ )
+ }
- private fun tryScanNewPath(context: Context, path: String, mimeType: String, cont: Continuation, iteration: Int = 0) {
+ private fun tryScanNewPathByMediaStore(
+ context: Context,
+ path: String,
+ mimeType: String,
+ cont: Continuation,
+ iteration: Int = 0,
+ ) {
// `scanFile` may (e.g. when copying to SD card on Android 10 (API 29)):
// 1) yield no URI,
// 2) yield a temporary URI that fails when queried,
@@ -832,7 +887,7 @@ class MediaStoreImageProvider : ImageProvider() {
}
}
- tryScanNewPath(context, path = path, mimeType = mimeType, cont, iteration + 1)
+ tryScanNewPathByMediaStore(context, path = path, mimeType = mimeType, cont, iteration + 1)
}
}
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt
index 1215d7ab7..31d0e9986 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt
@@ -70,7 +70,7 @@ object MimeTypes {
fun isRaw(mimeType: String): Boolean {
return when (mimeType) {
- ARW, CR2, DNG, NEF, NRW, ORF, PEF, RAF, RW2, SRW -> true
+ ARW, CR2, CRW, DCR, DNG, ERF, K25, KDC, MRW, NEF, NRW, ORF, PEF, RAF, RAW, RW2, SR2, SRF, SRW, X3F -> true
else -> false
}
}
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt
index 2bad51da4..0996f0a5d 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt
@@ -250,8 +250,8 @@ object PermissionManager {
for (uriPermission in context.contentResolver.persistedUriPermissions) {
val uri = uriPermission.uri
val path = StorageUtils.convertTreeDocumentUriToDirPath(context, uri)
- if (path != null && !File(path).exists()) {
- Log.d(LOG_TAG, "revoke URI permission for obsolete path=$path")
+ if (path == null || !File(path).exists()) {
+ Log.d(LOG_TAG, "revoke URI permission for obsolete uri=$uri path=$path")
releaseUriPermission(context, uri)
}
}
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt
index 457a36e0e..2a9171de3 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt
@@ -693,7 +693,7 @@ object StorageUtils {
class PathSegments(context: Context, fullPath: String) {
var volumePath: String? = null // `volumePath` with trailing "/"
var relativeDir: String? = null // `relativeDir` with trailing "/"
- var fileName: String? = null // null for directories
+ private var fileName: String? = null // null for directories
init {
volumePath = getVolumePath(context, fullPath)
diff --git a/android/app/src/main/res/values-ckb/strings.xml b/android/app/src/main/res/values-ckb/strings.xml
new file mode 100644
index 000000000..13c4df775
--- /dev/null
+++ b/android/app/src/main/res/values-ckb/strings.xml
@@ -0,0 +1,12 @@
+
+
+ ئاڤیس
+ قەراغی وێنە
+ ڕوونما
+ گەڕان
+ ڤیدیۆ
+ گەڕان بۆ فایل
+ گەڕان بۆ وێنە و ڤیدیۆ
+ گەڕان بۆ فایلەکان
+ وەستاندن
+
\ No newline at end of file
diff --git a/android/build.gradle b/android/build.gradle
index d3a84d964..f15f63e03 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -24,13 +24,13 @@ buildscript {
if (useCrashlytics) {
// GMS & Firebase Crashlytics (used by some flavors only)
- classpath 'com.google.gms:google-services:4.3.14'
- classpath 'com.google.firebase:firebase-crashlytics-gradle:2.9.2'
+ classpath 'com.google.gms:google-services:4.3.15'
+ classpath 'com.google.firebase:firebase-crashlytics-gradle:2.9.4'
}
if (useHms) {
// HMS (used by some flavors only)
- classpath 'com.huawei.agconnect:agcp:1.7.2.300'
+ classpath 'com.huawei.agconnect:agcp:1.8.0.300'
}
}
}
diff --git a/fastlane/metadata/android/ckb/full_description.txt b/fastlane/metadata/android/ckb/full_description.txt
new file mode 100644
index 000000000..6c92748f8
--- /dev/null
+++ b/fastlane/metadata/android/ckb/full_description.txt
@@ -0,0 +1,5 @@
+Aves can handle all sorts of images and videos, including your typical JPEGs and MP4s, but also more exotic things like multi-page TIFFs, SVGs, old AVIs and more! It scans your media collection to identify motion photos, panoramas (aka photo spheres), 360° videos, as well as GeoTIFF files.
+
+Navigation and search is an important part of Aves. The goal is for users to easily flow from albums to photos to tags to maps, etc.
+
+Aves integrates with Android (from KitKat to Android 13, including Android TV) with features such as widgets, app shortcuts, screen saver and global search handling. It also works as a media viewer and picker.
\ No newline at end of file
diff --git a/fastlane/metadata/android/ckb/short_description.txt b/fastlane/metadata/android/ckb/short_description.txt
new file mode 100644
index 000000000..8c9445bd5
--- /dev/null
+++ b/fastlane/metadata/android/ckb/short_description.txt
@@ -0,0 +1 @@
+Gallery and metadata explorer
\ No newline at end of file
diff --git a/fastlane/metadata/android/en-US/changelogs/93.txt b/fastlane/metadata/android/en-US/changelogs/93.txt
new file mode 100644
index 000000000..667cfa285
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/93.txt
@@ -0,0 +1,5 @@
+In v1.8.2:
+- write metadata when converting
+- convert many items at once
+- list places in their own page
+Full changelog available on GitHub
\ No newline at end of file
diff --git a/fastlane/metadata/android/en-US/changelogs/9301.txt b/fastlane/metadata/android/en-US/changelogs/9301.txt
new file mode 100644
index 000000000..667cfa285
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/9301.txt
@@ -0,0 +1,5 @@
+In v1.8.2:
+- write metadata when converting
+- convert many items at once
+- list places in their own page
+Full changelog available on GitHub
\ No newline at end of file
diff --git a/lib/l10n/app_ckb.arb b/lib/l10n/app_ckb.arb
new file mode 100644
index 000000000..82c467290
--- /dev/null
+++ b/lib/l10n/app_ckb.arb
@@ -0,0 +1,166 @@
+{
+ "@@locale" : "ckb",
+ "welcomeOptional": "ئارەزومەندانە",
+ "@welcomeOptional": {},
+ "welcomeTermsToggle": "ڕازیم بە مەرج و یاساکانی بەکارهێنان",
+ "@welcomeTermsToggle": {},
+ "columnCount": "{count, plural, =1{١ ڕیز} other{ڕیز {count}}}",
+ "@columnCount": {
+ "placeholders": {
+ "count": {}
+ }
+ },
+ "timeSeconds": "{seconds, plural, =1{١ چرکە} other{چرکە {seconds}}}",
+ "@timeSeconds": {
+ "placeholders": {
+ "seconds": {}
+ }
+ },
+ "applyButtonLabel": "جێبەجێکردن",
+ "@applyButtonLabel": {},
+ "deleteButtonLabel": "سڕینەوە",
+ "@deleteButtonLabel": {},
+ "nextButtonLabel": "دواتر",
+ "@nextButtonLabel": {},
+ "hideButtonLabel": "شاردنەوە",
+ "@hideButtonLabel": {},
+ "continueButtonLabel": "بەردەوامبون",
+ "@continueButtonLabel": {},
+ "cancelTooltip": "پاشگەزبونەوە",
+ "@cancelTooltip": {},
+ "changeTooltip": "گۆڕین",
+ "@changeTooltip": {},
+ "clearTooltip": "لابردن",
+ "@clearTooltip": {},
+ "previousTooltip": "پێشوتر",
+ "@previousTooltip": {},
+ "showTooltip": "پیشاندان",
+ "@showTooltip": {},
+ "hideTooltip": "شاردنەوە",
+ "@hideTooltip": {},
+ "actionRemove": "لابردن",
+ "@actionRemove": {},
+ "resetTooltip": "سفرکردنەوە",
+ "@resetTooltip": {},
+ "saveTooltip": "پاراستن",
+ "@saveTooltip": {},
+ "pickTooltip": "هەڵبژاردن",
+ "@pickTooltip": {},
+ "doubleBackExitMessage": "دووبارە پەنجەبنێ بە گەڕانەوە ”back“ دا بۆ دەرچوون",
+ "@doubleBackExitMessage": {},
+ "sourceStateLoading": "چاوەڕوانبە",
+ "@sourceStateLoading": {},
+ "sourceStateCataloguing": "تۆماری زانیاری",
+ "@sourceStateCataloguing": {},
+ "sourceStateLocatingCountries": "دۆزینەوەی ووڵات",
+ "@sourceStateLocatingCountries": {},
+ "sourceStateLocatingPlaces": "دۆزینەوەی شوێن",
+ "@sourceStateLocatingPlaces": {},
+ "chipActionDelete": "سڕینەوە",
+ "@chipActionDelete": {},
+ "chipActionGoToAlbumPage": "بینین لە ئەلبومدا",
+ "@chipActionGoToAlbumPage": {},
+ "chipActionGoToTagPage": "پیشاندانی نیشانەکراوەکان",
+ "@chipActionGoToTagPage": {},
+ "chipActionFilterOut": "کاریگەرییەکان",
+ "@chipActionFilterOut": {},
+ "chipActionFilterIn": "کاریگەرییەکان",
+ "@chipActionFilterIn": {},
+ "chipActionHide": "شاردنەوە",
+ "@chipActionHide": {},
+ "chipActionPin": "جێگیرکردن لە سەرەوە",
+ "@chipActionPin": {},
+ "chipActionUnpin": "لابردنی جێگیری",
+ "@chipActionUnpin": {},
+ "chipActionRename": "ناوگۆڕین",
+ "@chipActionRename": {},
+ "chipActionSetCover": "وێنەی بەرگ",
+ "@chipActionSetCover": {},
+ "chipActionCreateVault": "دروستکردنی تایبەت",
+ "@chipActionCreateVault": {},
+ "chipActionConfigureVault": "ڕێکخستنی قفڵکراو",
+ "@chipActionConfigureVault": {},
+ "entryActionCopyToClipboard": "لەبەرگرتنەوە",
+ "@entryActionCopyToClipboard": {},
+ "entryActionDelete": "سڕینەوە",
+ "@entryActionDelete": {},
+ "entryActionConvert": "گۆڕینی جۆر",
+ "@entryActionConvert": {},
+ "entryActionExport": "هەڵگرتن",
+ "@entryActionExport": {},
+ "entryActionInfo": "زانیاری",
+ "@entryActionInfo": {},
+ "entryActionRestore": "گێڕانەوە",
+ "@entryActionRestore": {},
+ "timeDays": "{days, plural, =1{١ ڕۆژ} other{ڕۆژ {days}}}",
+ "@timeDays": {
+ "placeholders": {
+ "days": {}
+ }
+ },
+ "focalLength": "{length} ملیمەتر",
+ "@focalLength": {
+ "placeholders": {
+ "length": {
+ "type": "String",
+ "example": "5.4"
+ }
+ }
+ },
+ "entryActionPrint": "چاپکردن",
+ "@entryActionPrint": {},
+ "entryActionShare": "بڵاوکردنەوە",
+ "@entryActionShare": {},
+ "entryActionViewSource": "بینینی سەرچاوە",
+ "@entryActionViewSource": {},
+ "entryActionEdit": "دەستکاریکردن",
+ "@entryActionEdit": {},
+ "entryActionOpen": "کردنەوە لەڕێی",
+ "@entryActionOpen": {},
+ "entryActionRotateScreen": "سوڕانەوەی خۆکار",
+ "@entryActionRotateScreen": {},
+ "entryActionAddFavourite": "زیادکردن بۆ دڵخواز",
+ "@entryActionAddFavourite": {},
+ "entryActionRemoveFavourite": "لابردن لە دڵخواز",
+ "@entryActionRemoveFavourite": {},
+ "videoActionMute": "بێدەنگکردن",
+ "@videoActionMute": {},
+ "videoActionUnmute": "کاراکردنی دەنگ",
+ "@videoActionUnmute": {},
+ "videoActionPause": "ڕاگرتن",
+ "@videoActionPause": {},
+ "videoActionPlay": "لێدان",
+ "@videoActionPlay": {},
+ "itemCount": "{count, plural, =1{١ دانە} other{دانە {count}}}",
+ "@itemCount": {
+ "placeholders": {
+ "count": {}
+ }
+ },
+ "appName": "ئاڤیس",
+ "@appName": {},
+ "welcomeMessage": "بەخێربێی بۆ ئاڤیس",
+ "@welcomeMessage": {},
+ "nextTooltip": "دواتر",
+ "@nextTooltip": {},
+ "showButtonLabel": "پیشاندان",
+ "@showButtonLabel": {},
+ "doNotAskAgain": "دووبارە مەپرسەوە",
+ "@doNotAskAgain": {},
+ "chipActionGoToCountryPage": "پیشاندان لە ووڵاتەکاندا",
+ "@chipActionGoToCountryPage": {},
+ "chipActionLock": "قفڵکردن",
+ "@chipActionLock": {},
+ "chipActionCreateAlbum": "دروستکردنی ئەلبوم",
+ "@chipActionCreateAlbum": {},
+ "entryActionRename": "ناوگۆڕین",
+ "@entryActionRename": {},
+ "timeMinutes": "{minutes, plural, =1{١ خولەک} other{خولەک {minutes}}}",
+ "@timeMinutes": {
+ "placeholders": {
+ "minutes": {}
+ }
+ },
+ "entryActionSetAs": "دانان وەک",
+ "@entryActionSetAs": {}
+}
diff --git a/lib/l10n/app_el.arb b/lib/l10n/app_el.arb
index 3bf1a4af2..ec6cc71a5 100644
--- a/lib/l10n/app_el.arb
+++ b/lib/l10n/app_el.arb
@@ -1248,5 +1248,9 @@
"tooManyItemsErrorDialogMessage": "Δοκιμάστε ξανά με λιγότερα αρχεία.",
"@tooManyItemsErrorDialogMessage": {},
"settingsVideoGestureVerticalDragBrightnessVolume": "Σύρετε προς τα πάνω ή προς τα κάτω για να ρυθμίσετε τη φωτεινότητα/την ένταση του ήχου",
- "@settingsVideoGestureVerticalDragBrightnessVolume": {}
+ "@settingsVideoGestureVerticalDragBrightnessVolume": {},
+ "lengthUnitPercent": "%",
+ "@lengthUnitPercent": {},
+ "lengthUnitPixel": "px",
+ "@lengthUnitPixel": {}
}
diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb
index 7104192c1..a2bcaa31a 100644
--- a/lib/l10n/app_en.arb
+++ b/lib/l10n/app_en.arb
@@ -74,6 +74,7 @@
"chipActionDelete": "Delete",
"chipActionGoToAlbumPage": "Show in Albums",
"chipActionGoToCountryPage": "Show in Countries",
+ "chipActionGoToPlacePage": "Show in Places",
"chipActionGoToTagPage": "Show in Tags",
"chipActionFilterOut": "Filter out",
"chipActionFilterIn": "Filter in",
@@ -199,6 +200,9 @@
"keepScreenOnViewerOnly": "Viewer page only",
"keepScreenOnAlways": "Always",
+ "lengthUnitPixel": "px",
+ "lengthUnitPercent": "%",
+
"mapStyleGoogleNormal": "Google Maps",
"mapStyleGoogleHybrid": "Google Maps (Hybrid)",
"mapStyleGoogleTerrain": "Google Maps (Terrain)",
@@ -418,6 +422,7 @@
"exportEntryDialogFormat": "Format:",
"exportEntryDialogWidth": "Width",
"exportEntryDialogHeight": "Height",
+ "exportEntryDialogWriteMetadata": "Write metadata",
"renameEntryDialogLabel": "New name",
@@ -621,6 +626,7 @@
"drawerCollectionSphericalVideos": "360° Videos",
"drawerAlbumPage": "Albums",
"drawerCountryPage": "Countries",
+ "drawerPlacePage": "Places",
"drawerTagPage": "Tags",
"sortByDate": "By date",
@@ -665,6 +671,9 @@
"countryPageTitle": "Countries",
"countryEmpty": "No countries",
+ "placePageTitle": "Places",
+ "placeEmpty": "No places",
+
"tagPageTitle": "Tags",
"tagEmpty": "No tags",
diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb
index abe773a66..8031d784f 100644
--- a/lib/l10n/app_es.arb
+++ b/lib/l10n/app_es.arb
@@ -1248,5 +1248,19 @@
"authenticateToUnlockVault": "Autentificarse para desbloquear la caja fuerte",
"@authenticateToUnlockVault": {},
"settingsConfirmationVaultDataLoss": "Mostrar un aviso de pérdida de datos de la caja fuerte",
- "@settingsConfirmationVaultDataLoss": {}
+ "@settingsConfirmationVaultDataLoss": {},
+ "drawerPlacePage": "Lugares",
+ "@drawerPlacePage": {},
+ "placePageTitle": "Lugares",
+ "@placePageTitle": {},
+ "placeEmpty": "Ningún lugar",
+ "@placeEmpty": {},
+ "chipActionGoToPlacePage": "Mostrar en lugares",
+ "@chipActionGoToPlacePage": {},
+ "lengthUnitPixel": "px",
+ "@lengthUnitPixel": {},
+ "lengthUnitPercent": "%",
+ "@lengthUnitPercent": {},
+ "exportEntryDialogWriteMetadata": "Escribir metadatos",
+ "@exportEntryDialogWriteMetadata": {}
}
diff --git a/lib/l10n/app_eu.arb b/lib/l10n/app_eu.arb
index f5c4f7854..61bcc6d3d 100644
--- a/lib/l10n/app_eu.arb
+++ b/lib/l10n/app_eu.arb
@@ -1366,5 +1366,59 @@
"filePickerUseThisFolder": "Erabili karpeta hau",
"@filePickerUseThisFolder": {},
"settingsWidgetOpenPage": "Widgetan sakatzean",
- "@settingsWidgetOpenPage": {}
+ "@settingsWidgetOpenPage": {},
+ "exportEntryDialogWriteMetadata": "Idatzi metadatuak",
+ "@exportEntryDialogWriteMetadata": {},
+ "configureVaultDialogTitle": "Konfiguratu kutxa gotorra",
+ "@configureVaultDialogTitle": {},
+ "newVaultDialogTitle": "Kutxa gotor berria",
+ "@newVaultDialogTitle": {},
+ "vaultDialogLockModeWhenScreenOff": "Blokeatu pantaila itzaltzean",
+ "@vaultDialogLockModeWhenScreenOff": {},
+ "vaultDialogLockTypeLabel": "Blokeatze modua",
+ "@vaultDialogLockTypeLabel": {},
+ "pinDialogEnter": "Sartu PINa",
+ "@pinDialogEnter": {},
+ "chipActionGoToPlacePage": "Erakutsi lekuetan",
+ "@chipActionGoToPlacePage": {},
+ "chipActionLock": "Blokeatu",
+ "@chipActionLock": {},
+ "chipActionCreateVault": "Sortu kutxa gotorra",
+ "@chipActionCreateVault": {},
+ "chipActionConfigureVault": "Konfiguratu kutxa gotorra",
+ "@chipActionConfigureVault": {},
+ "albumTierVaults": "Kutxa gotorrak",
+ "@albumTierVaults": {},
+ "placePageTitle": "Lekuak",
+ "@placePageTitle": {},
+ "placeEmpty": "Lekurik ez",
+ "@placeEmpty": {},
+ "settingsConfirmationVaultDataLoss": "Erakutsi kutxa gotorreko datuen galeraren inguruko abisua",
+ "@settingsConfirmationVaultDataLoss": {},
+ "newVaultWarningDialogMessage": "Kutxa gotorreko elementuak aplikazio honetarako soilik daude eskuragarri eta ez beste edozeinetarako.\n\nAplikazio hau desinstalatzen baduzu, edo aplikazio honen datuak garbitu, elementu guzti hauek galduko dituzu.",
+ "@newVaultWarningDialogMessage": {},
+ "settingsDisablingBinWarningDialogMessage": "Zakarrontziko elementuak betirako ezabatuko dira.",
+ "@settingsDisablingBinWarningDialogMessage": {},
+ "pinDialogConfirm": "Konfirmatu PINa",
+ "@pinDialogConfirm": {},
+ "passwordDialogEnter": "Sartu pasahitza",
+ "@passwordDialogEnter": {},
+ "passwordDialogConfirm": "Berretsi pasahitza",
+ "@passwordDialogConfirm": {},
+ "authenticateToConfigureVault": "Autentifikatu kutxa gotorra konfiguratzeko",
+ "@authenticateToConfigureVault": {},
+ "authenticateToUnlockVault": "Autentifikatu kutxa gotorra desblokeatzeko",
+ "@authenticateToUnlockVault": {},
+ "vaultBinUsageDialogMessage": "Kutxa gotor batzuk zakarrontzia erabiltzen ari dira.",
+ "@vaultBinUsageDialogMessage": {},
+ "lengthUnitPixel": "px",
+ "@lengthUnitPixel": {},
+ "lengthUnitPercent": "%",
+ "@lengthUnitPercent": {},
+ "vaultLockTypePin": "PIN kodea",
+ "@vaultLockTypePin": {},
+ "vaultLockTypePassword": "Pasahitza",
+ "@vaultLockTypePassword": {},
+ "drawerPlacePage": "Lekuak",
+ "@drawerPlacePage": {}
}
diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb
index 5ea062a26..492486a6d 100644
--- a/lib/l10n/app_fr.arb
+++ b/lib/l10n/app_fr.arb
@@ -1248,5 +1248,19 @@
"authenticateToUnlockVault": "Authentification pour déverrouiller le coffre-fort",
"@authenticateToUnlockVault": {},
"settingsDisablingBinWarningDialogMessage": "Les éléments dans la corbeille seront supprimés définitivement.",
- "@settingsDisablingBinWarningDialogMessage": {}
+ "@settingsDisablingBinWarningDialogMessage": {},
+ "drawerPlacePage": "Lieux",
+ "@drawerPlacePage": {},
+ "placePageTitle": "Lieux",
+ "@placePageTitle": {},
+ "chipActionGoToPlacePage": "Afficher dans Lieux",
+ "@chipActionGoToPlacePage": {},
+ "placeEmpty": "Aucun lieu",
+ "@placeEmpty": {},
+ "lengthUnitPixel": "px",
+ "@lengthUnitPixel": {},
+ "lengthUnitPercent": "%",
+ "@lengthUnitPercent": {},
+ "exportEntryDialogWriteMetadata": "Écrire les métadonnées",
+ "@exportEntryDialogWriteMetadata": {}
}
diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb
index c688d386a..06a8173b4 100644
--- a/lib/l10n/app_id.arb
+++ b/lib/l10n/app_id.arb
@@ -1248,5 +1248,19 @@
"newVaultWarningDialogMessage": "Item dalam brankas hanya tersedia untuk aplikasi ini dan bukan yang lain.\n\nJika Anda menghapus aplikasi ini, atau menghapus data aplikasi ini, Anda akan kehilangan semua item tersebut.",
"@newVaultWarningDialogMessage": {},
"settingsConfirmationVaultDataLoss": "Tampilkan peringatan kehilangan data brankas",
- "@settingsConfirmationVaultDataLoss": {}
+ "@settingsConfirmationVaultDataLoss": {},
+ "chipActionGoToPlacePage": "Tampilkan di Tempat",
+ "@chipActionGoToPlacePage": {},
+ "placePageTitle": "Tempat",
+ "@placePageTitle": {},
+ "placeEmpty": "Tidak ada tempat",
+ "@placeEmpty": {},
+ "drawerPlacePage": "Tempat",
+ "@drawerPlacePage": {},
+ "exportEntryDialogWriteMetadata": "Tulis metadata",
+ "@exportEntryDialogWriteMetadata": {},
+ "lengthUnitPixel": "px",
+ "@lengthUnitPixel": {},
+ "lengthUnitPercent": "%",
+ "@lengthUnitPercent": {}
}
diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb
index 9f533475d..d2dc5001a 100644
--- a/lib/l10n/app_it.arb
+++ b/lib/l10n/app_it.arb
@@ -1208,5 +1208,45 @@
"tooManyItemsErrorDialogMessage": "Riprova con meno elementi.",
"@tooManyItemsErrorDialogMessage": {},
"settingsVideoGestureVerticalDragBrightnessVolume": "Trascina su o giù per aggiustare luminosità/volume",
- "@settingsVideoGestureVerticalDragBrightnessVolume": {}
+ "@settingsVideoGestureVerticalDragBrightnessVolume": {},
+ "vaultDialogLockModeWhenScreenOff": "Blocca allo spegnimento dello schermo",
+ "@vaultDialogLockModeWhenScreenOff": {},
+ "chipActionCreateVault": "Crea cassaforte",
+ "@chipActionCreateVault": {},
+ "chipActionConfigureVault": "Configura cassaforte",
+ "@chipActionConfigureVault": {},
+ "chipActionLock": "Proteggi",
+ "@chipActionLock": {},
+ "albumTierVaults": "Casseforti",
+ "@albumTierVaults": {},
+ "vaultLockTypePin": "PIN",
+ "@vaultLockTypePin": {},
+ "vaultLockTypePassword": "Password",
+ "@vaultLockTypePassword": {},
+ "newVaultWarningDialogMessage": "Gli elementi nelle casseforti sono disponibili solo per questa app e non per altre.\n\nSe disinstalli l'app o ne cancelli i dati, perderai tutti questi elementi.",
+ "@newVaultWarningDialogMessage": {},
+ "newVaultDialogTitle": "Nuova Cassaforte",
+ "@newVaultDialogTitle": {},
+ "vaultDialogLockTypeLabel": "Tipo di protezione",
+ "@vaultDialogLockTypeLabel": {},
+ "authenticateToConfigureVault": "Accedi per configurare la cassaforte",
+ "@authenticateToConfigureVault": {},
+ "authenticateToUnlockVault": "Accedi per sbloccare la cassaforte",
+ "@authenticateToUnlockVault": {},
+ "vaultBinUsageDialogMessage": "Alcune casseforti stanno usando il cestino.",
+ "@vaultBinUsageDialogMessage": {},
+ "pinDialogEnter": "Inserisci PIN",
+ "@pinDialogEnter": {},
+ "pinDialogConfirm": "Conferma PIN",
+ "@pinDialogConfirm": {},
+ "passwordDialogEnter": "Inserisci password",
+ "@passwordDialogEnter": {},
+ "passwordDialogConfirm": "Conferma password",
+ "@passwordDialogConfirm": {},
+ "settingsConfirmationVaultDataLoss": "Mostra avviso di perdita dati delle casseforti",
+ "@settingsConfirmationVaultDataLoss": {},
+ "settingsDisablingBinWarningDialogMessage": "Gli elementi nel cestino verranno eliminati permanentemente.",
+ "@settingsDisablingBinWarningDialogMessage": {},
+ "configureVaultDialogTitle": "Configura Cassaforte",
+ "@configureVaultDialogTitle": {}
}
diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb
index d3f3061d3..28f48e43d 100644
--- a/lib/l10n/app_ko.arb
+++ b/lib/l10n/app_ko.arb
@@ -1248,5 +1248,19 @@
"vaultBinUsageDialogMessage": "휴지통을 사용하는 금고가 있습니다.",
"@vaultBinUsageDialogMessage": {},
"settingsConfirmationVaultDataLoss": "금고에 관한 데이터 손실 경고",
- "@settingsConfirmationVaultDataLoss": {}
+ "@settingsConfirmationVaultDataLoss": {},
+ "placePageTitle": "장소",
+ "@placePageTitle": {},
+ "drawerPlacePage": "장소",
+ "@drawerPlacePage": {},
+ "chipActionGoToPlacePage": "장소 페이지에서 보기",
+ "@chipActionGoToPlacePage": {},
+ "placeEmpty": "장소가 없습니다",
+ "@placeEmpty": {},
+ "lengthUnitPixel": "px",
+ "@lengthUnitPixel": {},
+ "lengthUnitPercent": "%",
+ "@lengthUnitPercent": {},
+ "exportEntryDialogWriteMetadata": "메타데이터 저장",
+ "@exportEntryDialogWriteMetadata": {}
}
diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb
index df6bb0c05..8bb84d038 100644
--- a/lib/l10n/app_pl.arb
+++ b/lib/l10n/app_pl.arb
@@ -133,7 +133,7 @@
"@entryActionOpenMap": {},
"sourceStateCataloguing": "Katalogowanie",
"@sourceStateCataloguing": {},
- "sourceStateLocatingPlaces": "Lokowanie miejsc",
+ "sourceStateLocatingPlaces": "Lokalizowanie miejsc",
"@sourceStateLocatingPlaces": {},
"chipActionFilterOut": "Odfiltruj",
"@chipActionFilterOut": {},
@@ -1406,5 +1406,19 @@
"vaultBinUsageDialogMessage": "Niektóre skarbce korzystają z kosza.",
"@vaultBinUsageDialogMessage": {},
"vaultLockTypePin": "PIN",
- "@vaultLockTypePin": {}
+ "@vaultLockTypePin": {},
+ "chipActionGoToPlacePage": "Pokaż w Miejscach",
+ "@chipActionGoToPlacePage": {},
+ "drawerPlacePage": "Miejsca",
+ "@drawerPlacePage": {},
+ "placeEmpty": "Brak miejsc",
+ "@placeEmpty": {},
+ "exportEntryDialogWriteMetadata": "Zapisz metadane",
+ "@exportEntryDialogWriteMetadata": {},
+ "lengthUnitPixel": "px",
+ "@lengthUnitPixel": {},
+ "lengthUnitPercent": "%",
+ "@lengthUnitPercent": {},
+ "placePageTitle": "Miejsca",
+ "@placePageTitle": {}
}
diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb
index c4a8769ad..4309913e8 100644
--- a/lib/l10n/app_pt.arb
+++ b/lib/l10n/app_pt.arb
@@ -1198,5 +1198,23 @@
"settingsDisplayUseTvInterface": "Interface de TV Android",
"@settingsDisplayUseTvInterface": {},
"settingsViewerShowRatingTags": "Mostrar avaliações e tags",
- "@settingsViewerShowRatingTags": {}
+ "@settingsViewerShowRatingTags": {},
+ "chipActionLock": "Bloquear",
+ "@chipActionLock": {},
+ "vaultLockTypePassword": "Senha",
+ "@vaultLockTypePassword": {},
+ "newVaultDialogTitle": "Novo Cofre",
+ "@newVaultDialogTitle": {},
+ "configureVaultDialogTitle": "Configurar Cofre",
+ "@configureVaultDialogTitle": {},
+ "vaultDialogLockModeWhenScreenOff": "Bloquear quando a tela desligar",
+ "@vaultDialogLockModeWhenScreenOff": {},
+ "vaultDialogLockTypeLabel": "Tipo de bloqueio",
+ "@vaultDialogLockTypeLabel": {},
+ "pinDialogEnter": "Digite o PIN",
+ "@pinDialogEnter": {},
+ "pinDialogConfirm": "Confirme o PIN",
+ "@pinDialogConfirm": {},
+ "passwordDialogEnter": "Digite a senha",
+ "@passwordDialogEnter": {}
}
diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb
index 9732c9f56..8b648cb9c 100644
--- a/lib/l10n/app_ru.arb
+++ b/lib/l10n/app_ru.arb
@@ -1196,5 +1196,39 @@
"entryActionShareVideoOnly": "Поделиться только видео",
"@entryActionShareVideoOnly": {},
"settingsViewerShowDescription": "Показать описание",
- "@settingsViewerShowDescription": {}
+ "@settingsViewerShowDescription": {},
+ "chipActionConfigureVault": "Настроить хранилище",
+ "@chipActionConfigureVault": {},
+ "vaultLockTypePin": "Пин-код",
+ "@vaultLockTypePin": {},
+ "lengthUnitPercent": "%",
+ "@lengthUnitPercent": {},
+ "vaultLockTypePassword": "Пароль",
+ "@vaultLockTypePassword": {},
+ "newVaultDialogTitle": "Новое хранилище",
+ "@newVaultDialogTitle": {},
+ "vaultDialogLockModeWhenScreenOff": "Блокировать при выключении экрана",
+ "@vaultDialogLockModeWhenScreenOff": {},
+ "pinDialogEnter": "Введите пин-код",
+ "@pinDialogEnter": {},
+ "pinDialogConfirm": "Подтвердите пин-код",
+ "@pinDialogConfirm": {},
+ "passwordDialogEnter": "Введите пароль",
+ "@passwordDialogEnter": {},
+ "passwordDialogConfirm": "Подтвердите пароль",
+ "@passwordDialogConfirm": {},
+ "vaultDialogLockTypeLabel": "Тип защиты",
+ "@vaultDialogLockTypeLabel": {},
+ "vaultBinUsageDialogMessage": "Некоторые из хранилищ используют корзину.",
+ "@vaultBinUsageDialogMessage": {},
+ "chipActionCreateVault": "Создать хранилище",
+ "@chipActionCreateVault": {},
+ "settingsDisplayUseTvInterface": "Интерфейс Android TV",
+ "@settingsDisplayUseTvInterface": {},
+ "configureVaultDialogTitle": "Настроить хранилище",
+ "@configureVaultDialogTitle": {},
+ "albumTierVaults": "Хранилища",
+ "@albumTierVaults": {},
+ "newVaultWarningDialogMessage": "Элементы внутри хранилищ доступны только для этого приложения, и никакого другого.\n\nЕсли вы удалите приложение или очистите его данные, то вы потеряете все содержимое внутри хранилищ.",
+ "@newVaultWarningDialogMessage": {}
}
diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb
index 75930c2bb..cde3638cb 100644
--- a/lib/l10n/app_uk.arb
+++ b/lib/l10n/app_uk.arb
@@ -901,7 +901,7 @@
"@searchDateSectionTitle": {},
"searchCountriesSectionTitle": "Країни",
"@searchCountriesSectionTitle": {},
- "searchPlacesSectionTitle": "Місця",
+ "searchPlacesSectionTitle": "Локації",
"@searchPlacesSectionTitle": {},
"searchTagsSectionTitle": "Теги",
"@searchTagsSectionTitle": {},
@@ -1115,7 +1115,7 @@
"@settingsCollectionTile": {},
"statsTopCountriesSectionTitle": "Топ Країн",
"@statsTopCountriesSectionTitle": {},
- "statsTopPlacesSectionTitle": "Топ Локацій",
+ "statsTopPlacesSectionTitle": "Топ локацій",
"@statsTopPlacesSectionTitle": {},
"statsTopTagsSectionTitle": "Топ Тегів",
"@statsTopTagsSectionTitle": {},
@@ -1207,7 +1207,7 @@
"@tagEditorSectionPlaceholders": {},
"tagPlaceholderCountry": "Країна",
"@tagPlaceholderCountry": {},
- "tagPlaceholderPlace": "Місце",
+ "tagPlaceholderPlace": "Локація",
"@tagPlaceholderPlace": {},
"panoramaEnableSensorControl": "Увімкнути сенсорне керування",
"@panoramaEnableSensorControl": {},
@@ -1406,5 +1406,19 @@
"settingsDisablingBinWarningDialogMessage": "Елементи в кошику буде видалено назавжди.",
"@settingsDisablingBinWarningDialogMessage": {},
"pinDialogEnter": "Введіть пін-код",
- "@pinDialogEnter": {}
+ "@pinDialogEnter": {},
+ "drawerPlacePage": "Локації",
+ "@drawerPlacePage": {},
+ "placeEmpty": "Немає локацій",
+ "@placeEmpty": {},
+ "chipActionGoToPlacePage": "Показати в Локаціях",
+ "@chipActionGoToPlacePage": {},
+ "placePageTitle": "Локації",
+ "@placePageTitle": {},
+ "exportEntryDialogWriteMetadata": "Напишіть метадані",
+ "@exportEntryDialogWriteMetadata": {},
+ "lengthUnitPixel": "px",
+ "@lengthUnitPixel": {},
+ "lengthUnitPercent": "%",
+ "@lengthUnitPercent": {}
}
diff --git a/lib/model/actions/chip_actions.dart b/lib/model/actions/chip_actions.dart
index bb7dc079a..f71b8c930 100644
--- a/lib/model/actions/chip_actions.dart
+++ b/lib/model/actions/chip_actions.dart
@@ -5,6 +5,7 @@ import 'package:flutter/widgets.dart';
enum ChipAction {
goToAlbumPage,
goToCountryPage,
+ goToPlacePage,
goToTagPage,
reverse,
hide,
@@ -18,6 +19,8 @@ extension ExtraChipAction on ChipAction {
return context.l10n.chipActionGoToAlbumPage;
case ChipAction.goToCountryPage:
return context.l10n.chipActionGoToCountryPage;
+ case ChipAction.goToPlacePage:
+ return context.l10n.chipActionGoToPlacePage;
case ChipAction.goToTagPage:
return context.l10n.chipActionGoToTagPage;
case ChipAction.reverse:
@@ -37,7 +40,9 @@ extension ExtraChipAction on ChipAction {
case ChipAction.goToAlbumPage:
return AIcons.album;
case ChipAction.goToCountryPage:
- return AIcons.location;
+ return AIcons.country;
+ case ChipAction.goToPlacePage:
+ return AIcons.place;
case ChipAction.goToTagPage:
return AIcons.tag;
case ChipAction.reverse:
diff --git a/lib/model/actions/entry_actions.dart b/lib/model/actions/entry_actions.dart
index 880142a25..2153f3ac7 100644
--- a/lib/model/actions/entry_actions.dart
+++ b/lib/model/actions/entry_actions.dart
@@ -329,9 +329,9 @@ extension ExtraEntryAction on EntryAction {
case EntryAction.editTitleDescription:
return AIcons.description;
case EntryAction.editRating:
- return AIcons.editRating;
+ return AIcons.rating;
case EntryAction.editTags:
- return AIcons.editTags;
+ return AIcons.tag;
case EntryAction.removeMetadata:
return AIcons.clear;
case EntryAction.exportMetadata:
diff --git a/lib/model/actions/entry_set_actions.dart b/lib/model/actions/entry_set_actions.dart
index 92af82d5c..86984c2f3 100644
--- a/lib/model/actions/entry_set_actions.dart
+++ b/lib/model/actions/entry_set_actions.dart
@@ -25,6 +25,7 @@ enum EntrySetAction {
copy,
move,
rename,
+ convert,
toggleFavourite,
rotateCCW,
rotateCW,
@@ -45,13 +46,16 @@ class EntrySetActions {
EntrySetAction.selectNone,
];
+ // `null` items are converted to dividers
static const pageBrowsing = [
EntrySetAction.searchCollection,
EntrySetAction.toggleTitleSearch,
EntrySetAction.addShortcut,
+ null,
EntrySetAction.map,
EntrySetAction.slideshow,
EntrySetAction.stats,
+ null,
EntrySetAction.rescan,
EntrySetAction.emptyBin,
];
@@ -67,6 +71,7 @@ class EntrySetActions {
EntrySetAction.rescan,
];
+ // `null` items are converted to dividers
static const pageSelection = [
EntrySetAction.share,
EntrySetAction.delete,
@@ -74,10 +79,13 @@ class EntrySetActions {
EntrySetAction.copy,
EntrySetAction.move,
EntrySetAction.rename,
+ EntrySetAction.convert,
EntrySetAction.toggleFavourite,
+ null,
EntrySetAction.map,
EntrySetAction.slideshow,
EntrySetAction.stats,
+ null,
EntrySetAction.rescan,
// editing actions are in their subsection
];
@@ -89,6 +97,7 @@ class EntrySetActions {
EntrySetAction.copy,
EntrySetAction.move,
EntrySetAction.rename,
+ EntrySetAction.convert,
EntrySetAction.toggleFavourite,
EntrySetAction.map,
EntrySetAction.slideshow,
@@ -163,6 +172,8 @@ extension ExtraEntrySetAction on EntrySetAction {
return context.l10n.collectionActionMove;
case EntrySetAction.rename:
return context.l10n.entryActionRename;
+ case EntrySetAction.convert:
+ return context.l10n.entryActionConvert;
case EntrySetAction.toggleFavourite:
// different data depending on toggle state
return context.l10n.entryActionAddFavourite;
@@ -232,6 +243,8 @@ extension ExtraEntrySetAction on EntrySetAction {
return AIcons.move;
case EntrySetAction.rename:
return AIcons.name;
+ case EntrySetAction.convert:
+ return AIcons.convert;
case EntrySetAction.toggleFavourite:
// different data depending on toggle state
return AIcons.favourite;
@@ -248,9 +261,9 @@ extension ExtraEntrySetAction on EntrySetAction {
case EntrySetAction.editTitleDescription:
return AIcons.description;
case EntrySetAction.editRating:
- return AIcons.editRating;
+ return AIcons.rating;
case EntrySetAction.editTags:
- return AIcons.editTags;
+ return AIcons.tag;
case EntrySetAction.removeMetadata:
return AIcons.clear;
}
diff --git a/lib/model/entry_dirs.dart b/lib/model/entry_dirs.dart
index 73c206a6b..c500504a3 100644
--- a/lib/model/entry_dirs.dart
+++ b/lib/model/entry_dirs.dart
@@ -62,8 +62,12 @@ class EntryDir {
final dir = Directory(resolved);
if (dir.existsSync()) {
final partLower = part.toLowerCase();
- final childrenDirs = dir.listSync().where((v) => v.absolute is Directory).toSet();
- found = childrenDirs.firstWhereOrNull((v) => pContext.basename(v.path).toLowerCase() == partLower);
+ try {
+ final childrenDirs = dir.listSync().where((v) => v.absolute is Directory).toSet();
+ found = childrenDirs.firstWhereOrNull((v) => pContext.basename(v.path).toLowerCase() == partLower);
+ } catch (error) {
+ // ignore, could be IO issue when listing directory
+ }
}
resolved = found?.path ?? '$resolved${pContext.separator}$part';
}
diff --git a/lib/model/entry_metadata_edition.dart b/lib/model/entry_metadata_edition.dart
index cd592f02b..6a0eb8ff3 100644
--- a/lib/model/entry_metadata_edition.dart
+++ b/lib/model/entry_metadata_edition.dart
@@ -25,7 +25,7 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
final appliedModifier = await _applyDateModifierToEntry(userModifier);
if (appliedModifier == null) {
- if (!isMissingAtPath) {
+ if (!isMissingAtPath && userModifier.action != DateEditAction.copyField) {
await reportService.recordError('failed to get date for modifier=$userModifier, entry=$this', null);
}
return {};
diff --git a/lib/model/filters/location.dart b/lib/model/filters/location.dart
index 97289cfd8..aab90081e 100644
--- a/lib/model/filters/location.dart
+++ b/lib/model/filters/location.dart
@@ -51,6 +51,8 @@ class LocationFilter extends CoveredCollectionFilter {
String? get countryCode => _countryCode;
+ String get place => _location;
+
@override
EntryFilter get positiveTest => _test;
@@ -65,17 +67,25 @@ class LocationFilter extends CoveredCollectionFilter {
@override
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) {
- if (_countryCode != null && device.canRenderFlagEmojis) {
- final flag = countryCodeToFlag(_countryCode);
- if (flag != null) {
- return Text(
- flag,
- style: TextStyle(fontSize: size),
- textScaleFactor: 1.0,
- );
- }
+ if (_location.isEmpty) {
+ return Icon(AIcons.locationUnlocated, size: size);
+ }
+ switch (level) {
+ case LocationLevel.place:
+ return Icon(AIcons.place, size: size);
+ case LocationLevel.country:
+ if (_countryCode != null && device.canRenderFlagEmojis) {
+ final flag = countryCodeToFlag(_countryCode);
+ if (flag != null) {
+ return Text(
+ flag,
+ style: TextStyle(fontSize: size),
+ textScaleFactor: 1.0,
+ );
+ }
+ }
+ return Icon(AIcons.country, size: size);
}
- return Icon(_location.isEmpty ? AIcons.locationUnlocated : AIcons.location, size: size);
}
@override
diff --git a/lib/model/filters/placeholder.dart b/lib/model/filters/placeholder.dart
index 933ac331e..e0be8e084 100644
--- a/lib/model/filters/placeholder.dart
+++ b/lib/model/filters/placeholder.dart
@@ -23,8 +23,10 @@ class PlaceholderFilter extends CollectionFilter {
PlaceholderFilter._private(this.placeholder) : super(reversed: false) {
switch (placeholder) {
case _country:
+ _icon = AIcons.country;
+ break;
case _place:
- _icon = AIcons.location;
+ _icon = AIcons.place;
break;
}
}
diff --git a/lib/model/metadata/enums/enums.dart b/lib/model/metadata/enums/enums.dart
index 7d8a54ec6..ad1641fae 100644
--- a/lib/model/metadata/enums/enums.dart
+++ b/lib/model/metadata/enums/enums.dart
@@ -15,6 +15,8 @@ enum DateFieldSource {
exifGpsDate,
}
+enum LengthUnit { px, percent }
+
enum LocationEditAction {
chooseOnMap,
copyItem,
diff --git a/lib/model/metadata/enums/length_unit.dart b/lib/model/metadata/enums/length_unit.dart
new file mode 100644
index 000000000..0da3eb3f0
--- /dev/null
+++ b/lib/model/metadata/enums/length_unit.dart
@@ -0,0 +1,14 @@
+import 'package:aves/model/metadata/enums/enums.dart';
+import 'package:aves/widgets/common/extensions/build_context.dart';
+import 'package:flutter/widgets.dart';
+
+extension ExtraLengthUnit on LengthUnit {
+ String getText(BuildContext context) {
+ switch (this) {
+ case LengthUnit.px:
+ return context.l10n.lengthUnitPixel;
+ case LengthUnit.percent:
+ return context.l10n.lengthUnitPercent;
+ }
+ }
+}
diff --git a/lib/model/settings/defaults.dart b/lib/model/settings/defaults.dart
index 13d04e8fd..554faccd8 100644
--- a/lib/model/settings/defaults.dart
+++ b/lib/model/settings/defaults.dart
@@ -4,6 +4,7 @@ import 'package:aves/model/filters/recent.dart';
import 'package:aves/model/naming_pattern.dart';
import 'package:aves/model/settings/enums/enums.dart';
import 'package:aves/model/source/enums/enums.dart';
+import 'package:aves/ref/mime_types.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart';
import 'package:aves/widgets/filter_grids/countries_page.dart';
import 'package:aves/widgets/filter_grids/tags_page.dart';
@@ -65,6 +66,7 @@ class SettingsDefaults {
static const albumGroupFactor = AlbumChipGroupFactor.importance;
static const albumSortFactor = ChipSortFactor.name;
static const countrySortFactor = ChipSortFactor.name;
+ static const placeSortFactor = ChipSortFactor.name;
static const tagSortFactor = ChipSortFactor.name;
// viewer
@@ -113,6 +115,11 @@ class SettingsDefaults {
static const tagEditorCurrentFilterSectionExpanded = true;
+ // converter
+
+ static const convertMimeType = MimeTypes.jpeg;
+ static const convertWriteMetadata = true;
+
// rendering
static const imageBackground = EntryBackground.white;
diff --git a/lib/model/settings/enums/thumbnail_overlay_location_icon.dart b/lib/model/settings/enums/thumbnail_overlay_location_icon.dart
index a0f76c54e..e61a0fd37 100644
--- a/lib/model/settings/enums/thumbnail_overlay_location_icon.dart
+++ b/lib/model/settings/enums/thumbnail_overlay_location_icon.dart
@@ -18,10 +18,9 @@ extension ExtraThumbnailOverlayLocationIcon on ThumbnailOverlayLocationIcon {
IconData getIcon(BuildContext context) {
switch (this) {
- case ThumbnailOverlayLocationIcon.located:
- return AIcons.location;
case ThumbnailOverlayLocationIcon.unlocated:
return AIcons.locationUnlocated;
+ case ThumbnailOverlayLocationIcon.located:
case ThumbnailOverlayLocationIcon.none:
return AIcons.location;
}
diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart
index ec3584ff1..e89a34383 100644
--- a/lib/model/settings/settings.dart
+++ b/lib/model/settings/settings.dart
@@ -20,6 +20,7 @@ import 'package:aves/widgets/aves_app.dart';
import 'package:aves/widgets/common/search/page.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart';
import 'package:aves/widgets/filter_grids/countries_page.dart';
+import 'package:aves/widgets/filter_grids/places_page.dart';
import 'package:aves/widgets/filter_grids/tags_page.dart';
import 'package:aves_map/aves_map.dart';
import 'package:collection/collection.dart';
@@ -106,9 +107,11 @@ class Settings extends ChangeNotifier {
static const albumGroupFactorKey = 'album_group_factor';
static const albumSortFactorKey = 'album_sort_factor';
static const countrySortFactorKey = 'country_sort_factor';
+ static const placeSortFactorKey = 'place_sort_factor';
static const tagSortFactorKey = 'tag_sort_factor';
static const albumSortReverseKey = 'album_sort_reverse';
static const countrySortReverseKey = 'country_sort_reverse';
+ static const placeSortReverseKey = 'place_sort_reverse';
static const tagSortReverseKey = 'tag_sort_reverse';
static const pinnedFiltersKey = 'pinned_filters';
static const hiddenFiltersKey = 'hidden_filters';
@@ -155,6 +158,11 @@ class Settings extends ChangeNotifier {
static const tagEditorCurrentFilterSectionExpandedKey = 'tag_editor_current_filter_section_expanded';
static const tagEditorExpandedSectionKey = 'tag_editor_expanded_section';
+ // converter
+
+ static const convertMimeTypeKey = 'convert_mime_type';
+ static const convertWriteMetadataKey = 'convert_write_metadata';
+
// map
static const mapStyleKey = 'info_map_style';
static const mapDefaultCenterKey = 'map_default_center';
@@ -247,39 +255,40 @@ class Settings extends ChangeNotifier {
}
}
- applyTvSettings();
+ if (settings.useTvLayout) {
+ applyTvSettings();
+ }
}
void applyTvSettings() {
- if (settings.useTvLayout) {
- themeBrightness = AvesThemeBrightness.dark;
- mustBackTwiceToExit = false;
- // address `TV-BU` / `TV-BY` requirements from https://developer.android.com/docs/quality-guidelines/tv-app-quality
- keepScreenOn = KeepScreenOn.videoPlayback;
- enableBottomNavigationBar = false;
- drawerTypeBookmarks = [
- null,
- MimeFilter.video,
- FavouriteFilter.instance,
- ];
- drawerPageBookmarks = [
- AlbumListPage.routeName,
- CountryListPage.routeName,
- TagListPage.routeName,
- SearchPage.routeName,
- ];
- showOverlayOnOpening = false;
- showOverlayMinimap = false;
- showOverlayThumbnailPreview = false;
- viewerGestureSideTapNext = false;
- viewerUseCutout = true;
- viewerMaxBrightness = false;
- videoControls = VideoControls.none;
- videoGestureDoubleTapTogglePlay = false;
- videoGestureSideDoubleTapSeek = false;
- enableBin = false;
- showPinchGestureAlternatives = true;
- }
+ themeBrightness = AvesThemeBrightness.dark;
+ mustBackTwiceToExit = false;
+ // address `TV-BU` / `TV-BY` requirements from https://developer.android.com/docs/quality-guidelines/tv-app-quality
+ keepScreenOn = KeepScreenOn.videoPlayback;
+ enableBottomNavigationBar = false;
+ drawerTypeBookmarks = [
+ null,
+ MimeFilter.video,
+ FavouriteFilter.instance,
+ ];
+ drawerPageBookmarks = [
+ AlbumListPage.routeName,
+ CountryListPage.routeName,
+ PlaceListPage.routeName,
+ TagListPage.routeName,
+ SearchPage.routeName,
+ ];
+ showOverlayOnOpening = false;
+ showOverlayMinimap = false;
+ showOverlayThumbnailPreview = false;
+ viewerGestureSideTapNext = false;
+ viewerUseCutout = true;
+ viewerMaxBrightness = false;
+ videoControls = VideoControls.none;
+ videoGestureDoubleTapTogglePlay = false;
+ videoGestureSideDoubleTapSeek = false;
+ enableBin = false;
+ showPinchGestureAlternatives = true;
}
Future sanitize() async {
@@ -543,6 +552,10 @@ class Settings extends ChangeNotifier {
set countrySortFactor(ChipSortFactor newValue) => _set(countrySortFactorKey, newValue.toString());
+ ChipSortFactor get placeSortFactor => getEnumOrDefault(placeSortFactorKey, SettingsDefaults.placeSortFactor, ChipSortFactor.values);
+
+ set placeSortFactor(ChipSortFactor newValue) => _set(placeSortFactorKey, newValue.toString());
+
ChipSortFactor get tagSortFactor => getEnumOrDefault(tagSortFactorKey, SettingsDefaults.tagSortFactor, ChipSortFactor.values);
set tagSortFactor(ChipSortFactor newValue) => _set(tagSortFactorKey, newValue.toString());
@@ -555,6 +568,10 @@ class Settings extends ChangeNotifier {
set countrySortReverse(bool newValue) => _set(countrySortReverseKey, newValue);
+ bool get placeSortReverse => getBool(placeSortReverseKey) ?? false;
+
+ set placeSortReverse(bool newValue) => _set(placeSortReverseKey, newValue);
+
bool get tagSortReverse => getBool(tagSortReverseKey) ?? false;
set tagSortReverse(bool newValue) => _set(tagSortReverseKey, newValue);
@@ -712,6 +729,16 @@ class Settings extends ChangeNotifier {
set tagEditorExpandedSection(String? newValue) => _set(tagEditorExpandedSectionKey, newValue);
+ // converter
+
+ String get convertMimeType => getString(convertMimeTypeKey) ?? SettingsDefaults.convertMimeType;
+
+ set convertMimeType(String newValue) => _set(convertMimeTypeKey, newValue);
+
+ bool get convertWriteMetadata => getBool(convertWriteMetadataKey) ?? SettingsDefaults.convertWriteMetadata;
+
+ set convertWriteMetadata(bool newValue) => _set(convertWriteMetadataKey, newValue);
+
// map
EntryMapStyle? get mapStyle {
@@ -857,7 +884,7 @@ class Settings extends ChangeNotifier {
bool? getBool(String key) {
try {
return settingsStore.getBool(key);
- } catch (e) {
+ } catch (error) {
// ignore, could be obsolete value of different type
return null;
}
@@ -866,7 +893,7 @@ class Settings extends ChangeNotifier {
int? getInt(String key) {
try {
return settingsStore.getInt(key);
- } catch (e) {
+ } catch (error) {
// ignore, could be obsolete value of different type
return null;
}
@@ -875,7 +902,7 @@ class Settings extends ChangeNotifier {
double? getDouble(String key) {
try {
return settingsStore.getDouble(key);
- } catch (e) {
+ } catch (error) {
// ignore, could be obsolete value of different type
return null;
}
@@ -884,7 +911,7 @@ class Settings extends ChangeNotifier {
String? getString(String key) {
try {
return settingsStore.getString(key);
- } catch (e) {
+ } catch (error) {
// ignore, could be obsolete value of different type
return null;
}
@@ -893,7 +920,7 @@ class Settings extends ChangeNotifier {
List? getStringList(String key) {
try {
return settingsStore.getStringList(key);
- } catch (e) {
+ } catch (error) {
// ignore, could be obsolete value of different type
return null;
}
@@ -907,7 +934,7 @@ class Settings extends ChangeNotifier {
return v;
}
}
- } catch (e) {
+ } catch (error) {
// ignore, could be obsolete value of different type
}
return defaultValue;
@@ -1038,6 +1065,7 @@ class Settings extends ChangeNotifier {
case showThumbnailVideoDurationKey:
case albumSortReverseKey:
case countrySortReverseKey:
+ case placeSortReverseKey:
case tagSortReverseKey:
case showOverlayOnOpeningKey:
case showOverlayMinimapKey:
@@ -1056,6 +1084,7 @@ class Settings extends ChangeNotifier {
case videoGestureVerticalDragBrightnessVolumeKey:
case subtitleShowOutlineKey:
case tagEditorCurrentFilterSectionExpandedKey:
+ case convertWriteMetadataKey:
case saveSearchHistoryKey:
case showPinchGestureAlternativesKey:
case filePickerShowHiddenFilesKey:
@@ -1084,6 +1113,7 @@ class Settings extends ChangeNotifier {
case albumGroupFactorKey:
case albumSortFactorKey:
case countrySortFactorKey:
+ case placeSortFactorKey:
case tagSortFactorKey:
case imageBackgroundKey:
case videoAutoPlayModeKey:
@@ -1092,6 +1122,7 @@ class Settings extends ChangeNotifier {
case subtitleTextAlignmentKey:
case subtitleTextPositionKey:
case tagEditorExpandedSectionKey:
+ case convertMimeTypeKey:
case mapStyleKey:
case mapDefaultCenterKey:
case coordinateFormatKey:
diff --git a/lib/model/source/collection_lens.dart b/lib/model/source/collection_lens.dart
index f4e48bdd8..5c9730444 100644
--- a/lib/model/source/collection_lens.dart
+++ b/lib/model/source/collection_lens.dart
@@ -14,7 +14,7 @@ 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';
-import 'package:aves/model/source/location.dart';
+import 'package:aves/model/source/location/location.dart';
import 'package:aves/model/source/section_keys.dart';
import 'package:aves/model/source/tag.dart';
import 'package:aves/utils/change_notifier.dart';
diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart
index fcb163e1c..e31296c46 100644
--- a/lib/model/source/collection_source.dart
+++ b/lib/model/source/collection_source.dart
@@ -15,7 +15,9 @@ import 'package:aves/model/source/album.dart';
import 'package:aves/model/source/analysis_controller.dart';
import 'package:aves/model/source/enums/enums.dart';
import 'package:aves/model/source/events.dart';
-import 'package:aves/model/source/location.dart';
+import 'package:aves/model/source/location/country.dart';
+import 'package:aves/model/source/location/location.dart';
+import 'package:aves/model/source/location/place.dart';
import 'package:aves/model/source/tag.dart';
import 'package:aves/model/source/trash.dart';
import 'package:aves/model/vaults/vaults.dart';
@@ -54,7 +56,7 @@ mixin SourceBase {
void invalidateEntries();
}
-abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin, TrashMixin {
+abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, PlaceMixin, LocationMixin, TagMixin, TrashMixin {
CollectionSource() {
settings.updateStream.where((event) => event.key == Settings.localeKey).listen((_) => invalidateAlbumDisplayNames());
settings.updateStream.where((event) => event.key == Settings.hiddenFiltersKey).listen((event) {
@@ -136,6 +138,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
invalidateEntries();
invalidateAlbumFilterSummary(entries: entries, notify: notify);
invalidateCountryFilterSummary(entries: entries, notify: notify);
+ invalidatePlaceFilterSummary(entries: entries, notify: notify);
invalidateTagFilterSummary(entries: entries, notify: notify);
}
@@ -501,21 +504,42 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
int count(CollectionFilter filter) {
if (filter is AlbumFilter) return albumEntryCount(filter);
- if (filter is LocationFilter) return countryEntryCount(filter);
+ if (filter is LocationFilter) {
+ switch (filter.level) {
+ case LocationLevel.country:
+ return countryEntryCount(filter);
+ case LocationLevel.place:
+ return placeEntryCount(filter);
+ }
+ }
if (filter is TagFilter) return tagEntryCount(filter);
return 0;
}
int size(CollectionFilter filter) {
if (filter is AlbumFilter) return albumSize(filter);
- if (filter is LocationFilter) return countrySize(filter);
+ if (filter is LocationFilter) {
+ switch (filter.level) {
+ case LocationLevel.country:
+ return countrySize(filter);
+ case LocationLevel.place:
+ return placeSize(filter);
+ }
+ }
if (filter is TagFilter) return tagSize(filter);
return 0;
}
AvesEntry? recentEntry(CollectionFilter filter) {
if (filter is AlbumFilter) return albumRecentEntry(filter);
- if (filter is LocationFilter) return countryRecentEntry(filter);
+ if (filter is LocationFilter) {
+ switch (filter.level) {
+ case LocationLevel.country:
+ return countryRecentEntry(filter);
+ case LocationLevel.place:
+ return placeRecentEntry(filter);
+ }
+ }
if (filter is TagFilter) return tagRecentEntry(filter);
return null;
}
diff --git a/lib/model/source/location/country.dart b/lib/model/source/location/country.dart
new file mode 100644
index 000000000..a6de50f6a
--- /dev/null
+++ b/lib/model/source/location/country.dart
@@ -0,0 +1,66 @@
+import 'package:aves/model/entry.dart';
+import 'package:aves/model/filters/location.dart';
+import 'package:aves/model/source/collection_source.dart';
+import 'package:aves/utils/collection_utils.dart';
+import 'package:collection/collection.dart';
+
+mixin CountryMixin on SourceBase {
+ // filter summary
+
+ // by country code
+ final Map _filterEntryCountMap = {}, _filterSizeMap = {};
+ final Map _filterRecentEntryMap = {};
+
+ void invalidateCountryFilterSummary({
+ Set? entries,
+ Set? countryCodes,
+ bool notify = true,
+ }) {
+ if (_filterEntryCountMap.isEmpty && _filterSizeMap.isEmpty && _filterRecentEntryMap.isEmpty) return;
+
+ if (entries == null && countryCodes == null) {
+ _filterEntryCountMap.clear();
+ _filterSizeMap.clear();
+ _filterRecentEntryMap.clear();
+ } else {
+ countryCodes ??= {};
+ if (entries != null) {
+ countryCodes.addAll(entries.where((entry) => entry.hasAddress).map((entry) => entry.addressDetails?.countryCode).whereNotNull());
+ }
+ countryCodes.forEach((countryCode) {
+ _filterEntryCountMap.remove(countryCode);
+ _filterSizeMap.remove(countryCode);
+ _filterRecentEntryMap.remove(countryCode);
+ });
+ }
+ if (notify) {
+ eventBus.fire(CountrySummaryInvalidatedEvent(countryCodes));
+ }
+ }
+
+ int countryEntryCount(LocationFilter filter) {
+ final countryCode = filter.countryCode;
+ if (countryCode == null) return 0;
+ return _filterEntryCountMap.putIfAbsent(countryCode, () => visibleEntries.where(filter.test).length);
+ }
+
+ int countrySize(LocationFilter filter) {
+ final countryCode = filter.countryCode;
+ if (countryCode == null) return 0;
+ return _filterSizeMap.putIfAbsent(countryCode, () => visibleEntries.where(filter.test).map((v) => v.sizeBytes).sum);
+ }
+
+ AvesEntry? countryRecentEntry(LocationFilter filter) {
+ final countryCode = filter.countryCode;
+ if (countryCode == null) return null;
+ return _filterRecentEntryMap.putIfAbsent(countryCode, () => sortedEntriesByDate.firstWhereOrNull(filter.test));
+ }
+}
+
+class CountriesChangedEvent {}
+
+class CountrySummaryInvalidatedEvent {
+ final Set? countryCodes;
+
+ const CountrySummaryInvalidatedEvent(this.countryCodes);
+}
diff --git a/lib/model/source/location.dart b/lib/model/source/location/location.dart
similarity index 76%
rename from lib/model/source/location.dart
rename to lib/model/source/location/location.dart
index f063aab80..80703a5b6 100644
--- a/lib/model/source/location.dart
+++ b/lib/model/source/location/location.dart
@@ -6,15 +6,15 @@ import 'package:aves/model/filters/location.dart';
import 'package:aves/model/metadata/address.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/analysis_controller.dart';
-import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums/enums.dart';
+import 'package:aves/model/source/location/country.dart';
+import 'package:aves/model/source/location/place.dart';
import 'package:aves/services/common/services.dart';
-import 'package:aves/utils/collection_utils.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:tuple/tuple.dart';
-mixin LocationMixin on SourceBase {
+mixin LocationMixin on CountryMixin, PlaceMixin {
static const commitCountThreshold = 200;
static const _stopCheckCountThreshold = 50;
@@ -150,7 +150,7 @@ mixin LocationMixin on SourceBase {
}
void updateLocations() {
- final locations = visibleEntries.where((entry) => entry.hasAddress).map((entry) => entry.addressDetails).whereNotNull().toList();
+ final locations = visibleEntries.map((entry) => entry.addressDetails).whereNotNull().toList();
final updatedPlaces = locations.map((address) => address.place).whereNotNull().where((v) => v.isNotEmpty).toSet().toList()..sort(compareAsciiUpperCase);
if (!listEquals(updatedPlaces, sortedPlaces)) {
sortedPlaces = List.unmodifiable(updatedPlaces);
@@ -177,67 +177,6 @@ mixin LocationMixin on SourceBase {
eventBus.fire(CountriesChangedEvent());
}
}
-
- // filter summary
-
- // by country code
- final Map _filterEntryCountMap = {}, _filterSizeMap = {};
- final Map _filterRecentEntryMap = {};
-
- void invalidateCountryFilterSummary({
- Set? entries,
- Set? countryCodes,
- bool notify = true,
- }) {
- if (_filterEntryCountMap.isEmpty && _filterSizeMap.isEmpty && _filterRecentEntryMap.isEmpty) return;
-
- if (entries == null && countryCodes == null) {
- _filterEntryCountMap.clear();
- _filterSizeMap.clear();
- _filterRecentEntryMap.clear();
- } else {
- countryCodes ??= {};
- if (entries != null) {
- countryCodes.addAll(entries.where((entry) => entry.hasAddress).map((entry) => entry.addressDetails?.countryCode).whereNotNull());
- }
- countryCodes.forEach((countryCode) {
- _filterEntryCountMap.remove(countryCode);
- _filterSizeMap.remove(countryCode);
- _filterRecentEntryMap.remove(countryCode);
- });
- }
- if (notify) {
- eventBus.fire(CountrySummaryInvalidatedEvent(countryCodes));
- }
- }
-
- int countryEntryCount(LocationFilter filter) {
- final countryCode = filter.countryCode;
- if (countryCode == null) return 0;
- return _filterEntryCountMap.putIfAbsent(countryCode, () => visibleEntries.where(filter.test).length);
- }
-
- int countrySize(LocationFilter filter) {
- final countryCode = filter.countryCode;
- if (countryCode == null) return 0;
- return _filterSizeMap.putIfAbsent(countryCode, () => visibleEntries.where(filter.test).map((v) => v.sizeBytes).sum);
- }
-
- AvesEntry? countryRecentEntry(LocationFilter filter) {
- final countryCode = filter.countryCode;
- if (countryCode == null) return null;
- return _filterRecentEntryMap.putIfAbsent(countryCode, () => sortedEntriesByDate.firstWhereOrNull(filter.test));
- }
}
class AddressMetadataChangedEvent {}
-
-class PlacesChangedEvent {}
-
-class CountriesChangedEvent {}
-
-class CountrySummaryInvalidatedEvent {
- final Set? countryCodes;
-
- const CountrySummaryInvalidatedEvent(this.countryCodes);
-}
diff --git a/lib/model/source/location/place.dart b/lib/model/source/location/place.dart
new file mode 100644
index 000000000..342202990
--- /dev/null
+++ b/lib/model/source/location/place.dart
@@ -0,0 +1,60 @@
+import 'package:aves/model/entry.dart';
+import 'package:aves/model/filters/location.dart';
+import 'package:aves/model/source/collection_source.dart';
+import 'package:aves/utils/collection_utils.dart';
+import 'package:collection/collection.dart';
+
+mixin PlaceMixin on SourceBase {
+ // filter summary
+
+ // by place
+ final Map _filterEntryCountMap = {}, _filterSizeMap = {};
+ final Map _filterRecentEntryMap = {};
+
+ void invalidatePlaceFilterSummary({
+ Set? entries,
+ Set? places,
+ bool notify = true,
+ }) {
+ if (_filterEntryCountMap.isEmpty && _filterSizeMap.isEmpty && _filterRecentEntryMap.isEmpty) return;
+
+ if (entries == null && places == null) {
+ _filterEntryCountMap.clear();
+ _filterSizeMap.clear();
+ _filterRecentEntryMap.clear();
+ } else {
+ places ??= {};
+ if (entries != null) {
+ places.addAll(entries.map((entry) => entry.addressDetails?.place).whereNotNull());
+ }
+ places.forEach((place) {
+ _filterEntryCountMap.remove(place);
+ _filterSizeMap.remove(place);
+ _filterRecentEntryMap.remove(place);
+ });
+ }
+ if (notify) {
+ eventBus.fire(PlaceSummaryInvalidatedEvent(places));
+ }
+ }
+
+ int placeEntryCount(LocationFilter filter) {
+ return _filterEntryCountMap.putIfAbsent(filter.place, () => visibleEntries.where(filter.test).length);
+ }
+
+ int placeSize(LocationFilter filter) {
+ return _filterSizeMap.putIfAbsent(filter.place, () => visibleEntries.where(filter.test).map((v) => v.sizeBytes).sum);
+ }
+
+ AvesEntry? placeRecentEntry(LocationFilter filter) {
+ return _filterRecentEntryMap.putIfAbsent(filter.place, () => sortedEntriesByDate.firstWhereOrNull(filter.test));
+ }
+}
+
+class PlacesChangedEvent {}
+
+class PlaceSummaryInvalidatedEvent {
+ final Set? places;
+
+ const PlaceSummaryInvalidatedEvent(this.places);
+}
diff --git a/lib/model/source/media_store_source.dart b/lib/model/source/media_store_source.dart
index 67bfaa69d..7744ca664 100644
--- a/lib/model/source/media_store_source.dart
+++ b/lib/model/source/media_store_source.dart
@@ -105,7 +105,7 @@ class MediaStoreSource extends CollectionSource {
// with items that may be hidden right away because of their metadata
addEntries(knownEntries, notify: false);
- await _addVaultEntries(directory);
+ await _loadVaultEntries(directory);
debugPrint('$runtimeType refresh ${stopwatch.elapsed} load metadata');
if (directory != null) {
@@ -266,6 +266,13 @@ class MediaStoreSource extends CollectionSource {
}
}
+ await _refreshVaultEntries(
+ changedUris: changedUris.where(vaults.isVaultEntryUri).toSet(),
+ newEntries: newEntries,
+ entriesToRefresh: entriesToRefresh,
+ existingDirectories: existingDirectories,
+ );
+
invalidateAlbumFilterSummary(directories: existingDirectories);
if (newEntries.isNotEmpty) {
@@ -278,21 +285,21 @@ class MediaStoreSource extends CollectionSource {
await refreshEntries(entriesToRefresh, EntryDataType.values.toSet());
}
- await _refreshVaultEntries(changedUris.where(vaults.isVaultEntryUri).toSet());
-
return tempUris;
}
// vault
- Future _addVaultEntries(String? directory) async {
+ Future _loadVaultEntries(String? directory) async {
addEntries(await metadataDb.loadEntries(origin: EntryOrigins.vault, directory: directory));
}
- Future _refreshVaultEntries(Set changedUris) async {
- final entriesToRefresh = {};
- final existingDirectories = {};
-
+ Future _refreshVaultEntries({
+ required Set changedUris,
+ required Set newEntries,
+ required Set entriesToRefresh,
+ required Set existingDirectories,
+ }) async {
for (final uri in changedUris) {
final existingEntry = allEntries.firstWhereOrNull((entry) => entry.uri == uri);
if (existingEntry != null) {
@@ -301,13 +308,15 @@ class MediaStoreSource extends CollectionSource {
if (existingDirectory != null) {
existingDirectories.add(existingDirectory);
}
+ } else {
+ final sourceEntry = await mediaFetchService.getEntry(uri, null);
+ if (sourceEntry != null) {
+ newEntries.add(sourceEntry.copyWith(
+ id: metadataDb.nextId,
+ origin: EntryOrigins.vault,
+ ));
+ }
}
}
-
- invalidateAlbumFilterSummary(directories: existingDirectories);
-
- if (entriesToRefresh.isNotEmpty) {
- await refreshEntries(entriesToRefresh, EntryDataType.values.toSet());
- }
}
}
diff --git a/lib/model/vaults/vaults.dart b/lib/model/vaults/vaults.dart
index 113ad7515..015a08ac2 100644
--- a/lib/model/vaults/vaults.dart
+++ b/lib/model/vaults/vaults.dart
@@ -154,7 +154,10 @@ class Vaults extends ChangeNotifier {
localizedReason: context.l10n.authenticateToUnlockVault,
);
} on PlatformException catch (e, stack) {
- await reportService.recordError(e, stack);
+ if (e.code != 'auth_in_progress') {
+ // `auth_in_progress`: `Authentication in progress`
+ await reportService.recordError(e, stack);
+ }
}
break;
case VaultLockType.pin:
diff --git a/lib/services/common/optional_event_channel.dart b/lib/services/common/optional_event_channel.dart
index 7ce14fe5a..4f8aff6c0 100644
--- a/lib/services/common/optional_event_channel.dart
+++ b/lib/services/common/optional_event_channel.dart
@@ -27,9 +27,9 @@ class OptionalEventChannel extends EventChannel {
});
try {
await methodChannel.invokeMethod('listen', arguments);
- } catch (exception, stack) {
+ } catch (error, stack) {
FlutterError.reportError(FlutterErrorDetails(
- exception: exception,
+ exception: error,
stack: stack,
library: 'services library',
context: ErrorDescription('while activating platform stream on channel $name'),
@@ -39,9 +39,14 @@ class OptionalEventChannel extends EventChannel {
binaryMessenger.setMessageHandler(name, null);
try {
await methodChannel.invokeMethod('cancel', arguments);
- } catch (exception, stack) {
+ } catch (error, stack) {
+ if (error is PlatformException && error.message == 'No active stream to cancel') {
+ // ignore
+ return;
+ }
+
FlutterError.reportError(FlutterErrorDetails(
- exception: exception,
+ exception: error,
stack: stack,
library: 'services library',
context: ErrorDescription('while de-activating platform stream on channel $name'),
diff --git a/lib/services/media/media_edit_service.dart b/lib/services/media/media_edit_service.dart
index a775c827d..a0d6be076 100644
--- a/lib/services/media/media_edit_service.dart
+++ b/lib/services/media/media_edit_service.dart
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:aves/model/entry.dart';
+import 'package:aves/model/metadata/enums/enums.dart';
import 'package:aves/services/common/image_op_events.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/services/media/enums.dart';
@@ -28,7 +29,7 @@ abstract class MediaEditService {
Stream export(
Iterable entries, {
- required EntryExportOptions options,
+ required EntryConvertOptions options,
required String destinationAlbum,
required NameConflictStrategy nameConflictStrategy,
});
@@ -113,18 +114,20 @@ class PlatformMediaEditService implements MediaEditService {
@override
Stream export(
Iterable entries, {
- required EntryExportOptions options,
+ required EntryConvertOptions options,
required String destinationAlbum,
required NameConflictStrategy nameConflictStrategy,
}) {
try {
return _opStream
.receiveBroadcastStream({
- 'op': 'export',
+ 'op': 'convert',
'entries': entries.map((entry) => entry.toPlatformEntryMap()).toList(),
'mimeType': options.mimeType,
+ 'lengthUnit': options.lengthUnit.name,
'width': options.width,
'height': options.height,
+ 'writeMetadata': options.writeMetadata,
'destinationPath': destinationAlbum,
'nameConflictStrategy': nameConflictStrategy.toPlatform(),
})
@@ -183,15 +186,19 @@ class PlatformMediaEditService implements MediaEditService {
}
@immutable
-class EntryExportOptions extends Equatable {
+class EntryConvertOptions extends Equatable {
final String mimeType;
+ final bool writeMetadata;
+ final LengthUnit lengthUnit;
final int width, height;
@override
- List