Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2023-02-28 17:24:03 +01:00
commit fd92d1b27f
98 changed files with 2657 additions and 842 deletions

@ -1 +1 @@
Subproject commit 9944297138845a94256f1cf37beb88ff9a8e811a
Subproject commit c07f7888888435fd9df505aa2efc38d3cf65681b

View file

@ -4,6 +4,25 @@ All notable changes to this project will be documented in this file.
## <a id="unreleased"></a>[Unreleased]
## <a id="v1.8.2"></a>[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
## <a id="v1.8.1"></a>[v1.8.1] - 2023-02-21
### Added

View file

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

View file

@ -224,7 +224,7 @@ This change eventually prevents building the app with Flutter v3.3.3.
<service
android:name=".MediaPlaybackService"
android:exported="true"
android:exported="false"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />

View file

@ -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 {
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)
result["success"] = false
}
}
}
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 {
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("export-failure", "failed to export entries", throwable)
})
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 {
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 {
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()

View file

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

View file

@ -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"))
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,14 +53,16 @@ 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
Log.d(LOG_TAG, "delete file at uri=$uri")
if (file.delete()) return
path ?: throw Exception("failed to delete file because path is null")
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(
activity: Activity,

View file

@ -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<AvesEntry>,
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<String, String?>()
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<ImageProvider>()
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

View file

@ -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,14 +562,57 @@ 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) {
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 targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType)}"
val values = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, targetFileName)
put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
@ -598,8 +630,18 @@ class MediaStoreImageProvider : ImageProvider() {
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<FieldMap>, iteration: Int = 0) {
private fun tryScanNewPathByMediaStore(
context: Context,
path: String,
mimeType: String,
cont: Continuation<FieldMap>,
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)
}
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">ئاڤیس</string>
<string name="app_widget_label">قەراغی وێنە</string>
<string name="wallpaper">ڕوونما</string>
<string name="search_shortcut_short_label">گەڕان</string>
<string name="videos_shortcut_short_label">ڤیدیۆ</string>
<string name="analysis_channel_name">گەڕان بۆ فایل</string>
<string name="analysis_service_description">گەڕان بۆ وێنە و ڤیدیۆ</string>
<string name="analysis_notification_default_title">گەڕان بۆ فایلەکان</string>
<string name="analysis_notification_action_stop">وەستاندن</string>
</resources>

View file

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

View file

@ -0,0 +1,5 @@
<i>Aves</i> can handle all sorts of images and videos, including your typical JPEGs and MP4s, but also more exotic things like <b>multi-page TIFFs, SVGs, old AVIs and more</b>! It scans your media collection to identify <b>motion photos</b>, <b>panoramas</b> (aka photo spheres), <b>360° videos</b>, as well as <b>GeoTIFF</b> files.
<b>Navigation and search</b> is an important part of <i>Aves</i>. The goal is for users to easily flow from albums to photos to tags to maps, etc.
<i>Aves</i> integrates with Android (from KitKat to Android 13, including Android TV) with features such as <b>widgets</b>, <b>app shortcuts</b>, <b>screen saver</b> and <b>global search</b> handling. It also works as a <b>media viewer and picker</b>.

View file

@ -0,0 +1 @@
Gallery and metadata explorer

View file

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

View file

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

166
lib/l10n/app_ckb.arb Normal file
View file

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

View file

@ -1248,5 +1248,9 @@
"tooManyItemsErrorDialogMessage": "Δοκιμάστε ξανά με λιγότερα αρχεία.",
"@tooManyItemsErrorDialogMessage": {},
"settingsVideoGestureVerticalDragBrightnessVolume": "Σύρετε προς τα πάνω ή προς τα κάτω για να ρυθμίσετε τη φωτεινότητα/την ένταση του ήχου",
"@settingsVideoGestureVerticalDragBrightnessVolume": {}
"@settingsVideoGestureVerticalDragBrightnessVolume": {},
"lengthUnitPercent": "%",
"@lengthUnitPercent": {},
"lengthUnitPixel": "px",
"@lengthUnitPixel": {}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1248,5 +1248,19 @@
"vaultBinUsageDialogMessage": "휴지통을 사용하는 금고가 있습니다.",
"@vaultBinUsageDialogMessage": {},
"settingsConfirmationVaultDataLoss": "금고에 관한 데이터 손실 경고",
"@settingsConfirmationVaultDataLoss": {}
"@settingsConfirmationVaultDataLoss": {},
"placePageTitle": "장소",
"@placePageTitle": {},
"drawerPlacePage": "장소",
"@drawerPlacePage": {},
"chipActionGoToPlacePage": "장소 페이지에서 보기",
"@chipActionGoToPlacePage": {},
"placeEmpty": "장소가 없습니다",
"@placeEmpty": {},
"lengthUnitPixel": "px",
"@lengthUnitPixel": {},
"lengthUnitPercent": "%",
"@lengthUnitPercent": {},
"exportEntryDialogWriteMetadata": "메타데이터 저장",
"@exportEntryDialogWriteMetadata": {}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -62,8 +62,12 @@ class EntryDir {
final dir = Directory(resolved);
if (dir.existsSync()) {
final partLower = part.toLowerCase();
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';
}

View file

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

View file

@ -51,6 +51,8 @@ class LocationFilter extends CoveredCollectionFilter {
String? get countryCode => _countryCode;
String get place => _location;
@override
EntryFilter get positiveTest => _test;
@ -65,6 +67,13 @@ class LocationFilter extends CoveredCollectionFilter {
@override
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) {
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) {
@ -75,7 +84,8 @@ class LocationFilter extends CoveredCollectionFilter {
);
}
}
return Icon(_location.isEmpty ? AIcons.locationUnlocated : AIcons.location, size: size);
return Icon(AIcons.country, size: size);
}
}
@override

View file

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

View file

@ -15,6 +15,8 @@ enum DateFieldSource {
exifGpsDate,
}
enum LengthUnit { px, percent }
enum LocationEditAction {
chooseOnMap,
copyItem,

View file

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

View file

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

View file

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

View file

@ -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,11 +255,12 @@ class Settings extends ChangeNotifier {
}
}
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
@ -265,6 +274,7 @@ class Settings extends ChangeNotifier {
drawerPageBookmarks = [
AlbumListPage.routeName,
CountryListPage.routeName,
PlaceListPage.routeName,
TagListPage.routeName,
SearchPage.routeName,
];
@ -280,7 +290,6 @@ class Settings extends ChangeNotifier {
enableBin = false;
showPinchGestureAlternatives = true;
}
}
Future<void> sanitize() async {
if (timeToTakeAction == AccessibilityTimeout.system && !await AccessibilityService.hasRecommendedTimeouts()) {
@ -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<String>? 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:

View file

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

View file

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

View file

@ -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<String, int> _filterEntryCountMap = {}, _filterSizeMap = {};
final Map<String, AvesEntry?> _filterRecentEntryMap = {};
void invalidateCountryFilterSummary({
Set<AvesEntry>? entries,
Set<String>? 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<String>? countryCodes;
const CountrySummaryInvalidatedEvent(this.countryCodes);
}

View file

@ -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<String, int> _filterEntryCountMap = {}, _filterSizeMap = {};
final Map<String, AvesEntry?> _filterRecentEntryMap = {};
void invalidateCountryFilterSummary({
Set<AvesEntry>? entries,
Set<String>? 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<String>? countryCodes;
const CountrySummaryInvalidatedEvent(this.countryCodes);
}

View file

@ -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<String, int> _filterEntryCountMap = {}, _filterSizeMap = {};
final Map<String, AvesEntry?> _filterRecentEntryMap = {};
void invalidatePlaceFilterSummary({
Set<AvesEntry>? entries,
Set<String>? 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<String>? places;
const PlaceSummaryInvalidatedEvent(this.places);
}

View file

@ -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<void> _addVaultEntries(String? directory) async {
Future<void> _loadVaultEntries(String? directory) async {
addEntries(await metadataDb.loadEntries(origin: EntryOrigins.vault, directory: directory));
}
Future<void> _refreshVaultEntries(Set<String> changedUris) async {
final entriesToRefresh = <AvesEntry>{};
final existingDirectories = <String>{};
Future<void> _refreshVaultEntries({
required Set<String> changedUris,
required Set<AvesEntry> newEntries,
required Set<AvesEntry> entriesToRefresh,
required Set<String> 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());
}
}
}

View file

@ -154,8 +154,11 @@ class Vaults extends ChangeNotifier {
localizedReason: context.l10n.authenticateToUnlockVault,
);
} on PlatformException catch (e, stack) {
if (e.code != 'auth_in_progress') {
// `auth_in_progress`: `Authentication in progress`
await reportService.recordError(e, stack);
}
}
break;
case VaultLockType.pin:
final pin = await showDialog<String>(

View file

@ -27,9 +27,9 @@ class OptionalEventChannel extends EventChannel {
});
try {
await methodChannel.invokeMethod<void>('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<void>('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'),

View file

@ -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<ExportOpEvent> export(
Iterable<AvesEntry> entries, {
required EntryExportOptions options,
required EntryConvertOptions options,
required String destinationAlbum,
required NameConflictStrategy nameConflictStrategy,
});
@ -113,18 +114,20 @@ class PlatformMediaEditService implements MediaEditService {
@override
Stream<ExportOpEvent> export(
Iterable<AvesEntry> entries, {
required EntryExportOptions options,
required EntryConvertOptions options,
required String destinationAlbum,
required NameConflictStrategy nameConflictStrategy,
}) {
try {
return _opStream
.receiveBroadcastStream(<String, dynamic>{
'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<Object?> get props => [mimeType, width, height];
List<Object?> get props => [mimeType, writeMetadata, lengthUnit, width, height];
const EntryExportOptions({
const EntryConvertOptions({
required this.mimeType,
required this.writeMetadata,
required this.lengthUnit,
required this.width,
required this.height,
});

View file

@ -36,6 +36,8 @@ class AIcons {
static const IconData language = Icons.translate_outlined;
static const IconData location = Icons.place_outlined;
static const IconData locationUnlocated = Icons.location_off_outlined;
static const IconData country = Icons.flag_outlined;
static const IconData place = Icons.place_outlined;
static const IconData mainStorage = Icons.smartphone_outlined;
static const IconData mimeType = Icons.code_outlined;
static const IconData opacity = Icons.opacity;
@ -81,8 +83,6 @@ class AIcons {
static const IconData debug = Icons.whatshot_outlined;
static const IconData delete = Icons.delete_outlined;
static const IconData edit = Icons.edit_outlined;
static const IconData editRating = MdiIcons.starPlusOutline;
static const IconData editTags = MdiIcons.tagPlusOutline;
static const IconData emptyBin = Icons.delete_sweep_outlined;
static const IconData export = Icons.open_with_outlined;
static const IconData fileExport = MdiIcons.fileExportOutline;

View file

@ -36,7 +36,7 @@ DateTime? dateTimeFromMillis(int? millis, {bool isUtc = false}) {
if (millis == null || millis == 0) return null;
try {
return DateTime.fromMillisecondsSinceEpoch(millis, isUtc: isUtc);
} catch (e) {
} catch (error) {
// `DateTime`s can represent time values that are at a distance of at most 100,000,000
// days from epoch (1970-01-01 UTC): -271821-04-20 to 275760-09-13.
debugPrint('failed to build DateTime from timestamp in millis=$millis');

View file

@ -1,5 +1,6 @@
import 'dart:convert';
import 'dart:io';
import 'dart:ui';
import 'package:aves/app_flavor.dart';
import 'package:aves/flutter_version.dart';
@ -141,6 +142,7 @@ class _BugReportState extends State<BugReport> with FeedbackMixin {
}
Future<String> _getInfo(BuildContext context) async {
final accessibility = window.accessibilityFeatures;
final packageInfo = await PackageInfo.fromPlatform();
final androidInfo = await DeviceInfoPlugin().androidInfo;
final flavor = context.read<AppFlavor>().toString().split('.')[1];
@ -159,6 +161,7 @@ class _BugReportState extends State<BugReport> with FeedbackMixin {
'System locales: ${WidgetsBinding.instance.window.locales.join(', ')}',
'Aves locale: ${settings.locale ?? 'system'} -> ${settings.appliedLocale}',
'Installer: ${packageInfo.installerStore}',
'Accessibility: accessibleNavigation=${accessibility.accessibleNavigation}, disableAnimations=${accessibility.disableAnimations}',
].join('\n');
}

View file

@ -47,6 +47,8 @@ class AboutTranslators extends StatelessWidget {
Contributor('rehork', 'cooky@e.email'),
Contributor('Eric', 'hamburger2048@users.noreply.hosted.weblate.org'),
Contributor('Aitor Salaberria', 'trslbrr@gmail.com'),
Contributor('Felipe Nogueira', 'contato.fnog@gmail.com'),
Contributor('kaajjo', 'claymanoff@gmail.com'),
// Contributor('SAMIRAH AIL', 'samiratalzahrani@gmail.com'), // Arabic
// Contributor('Salih Ail', 'rrrfff444@gmail.com'), // Arabic
// Contributor('امیر جهانگرد', 'ijahangard.a@gmail.com'), // Persian
@ -55,6 +57,7 @@ class AboutTranslators extends StatelessWidget {
// Contributor('Nattapong K', 'mixer5056@gmail.com'), // Thai
// Contributor('Idj', 'joneltmp+goahn@gmail.com'), // Hebrew
// Contributor('Martin Frandel', 'martinko.fr@gmail.com'), // Slovak
// Contributor('GoRaN', 'gorangharib.909@gmail.com'), // Kurdish (Central)
};
@override

View file

@ -55,7 +55,8 @@ class AvesApp extends StatefulWidget {
final AppFlavor flavor;
// temporary exclude locales not ready yet for prime time
static final _unsupportedLocales = {'ar', 'fa', 'gl', 'he', 'nn', 'sk', 'th'}.map(Locale.new).toSet();
// `ckb`: add `flutter_ckb_localization` and necessary app localization delegates when ready
static final _unsupportedLocales = {'ar', 'ckb', 'fa', 'gl', 'he', 'nn', 'sk', 'th'}.map(Locale.new).toSet();
static final List<Locale> supportedLocales = AppLocalizations.supportedLocales.where((v) => !_unsupportedLocales.contains(v)).toList();
static final ValueNotifier<EdgeInsets> cutoutInsetsNotifier = ValueNotifier(EdgeInsets.zero);
static final GlobalKey<NavigatorState> navigatorKey = GlobalKey(debugLabel: 'app-navigator');

View file

@ -32,6 +32,7 @@ import 'package:aves/widgets/common/tile_extent_controller.dart';
import 'package:aves/widgets/dialogs/tile_view_dialog.dart';
import 'package:aves/widgets/filter_grids/common/action_delegates/chip.dart';
import 'package:aves/widgets/search/search_delegate.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart';
@ -342,7 +343,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
return [
...EntrySetActions.general,
...isSelecting ? EntrySetActions.pageSelection : EntrySetActions.pageBrowsing,
].where(isVisible).map((action) {
].whereNotNull().where(isVisible).map((action) {
final enabled = canApply(action);
return CaptionedButton(
iconButtonBuilder: (context, focusNode) => _buildButtonIcon(
@ -388,9 +389,20 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
final browsingMenuActions = EntrySetActions.pageBrowsing.where((v) => !browsingQuickActions.contains(v));
final selectionMenuActions = EntrySetActions.pageSelection.where((v) => !selectionQuickActions.contains(v));
final contextualMenuItems = [
...(isSelecting ? selectionMenuActions : browsingMenuActions).where(isVisible).map(
(action) => _toMenuItem(action, enabled: canApply(action), selection: selection),
final contextualMenuActions = (isSelecting ? selectionMenuActions : browsingMenuActions).where((v) => v == null || isVisible(v)).fold(<EntrySetAction?>[], (prev, v) {
if (v == null && (prev.isEmpty || prev.last == null)) return prev;
return [...prev, v];
});
if (contextualMenuActions.isNotEmpty && contextualMenuActions.last == null) {
contextualMenuActions.removeLast();
}
final contextualMenuItems = <PopupMenuEntry<EntrySetAction>>[
...contextualMenuActions.map(
(action) {
if (action == null) return const PopupMenuDivider();
return _toMenuItem(action, enabled: canApply(action), selection: selection);
},
),
if (isSelecting && !settings.isReadOnly && appMode == AppMode.main && !isTrash)
PopupMenuItem<EntrySetAction>(
@ -630,6 +642,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
case EntrySetAction.copy:
case EntrySetAction.move:
case EntrySetAction.rename:
case EntrySetAction.convert:
case EntrySetAction.toggleFavourite:
case EntrySetAction.rotateCCW:
case EntrySetAction.rotateCW:

View file

@ -94,6 +94,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
case EntrySetAction.copy:
case EntrySetAction.move:
case EntrySetAction.rename:
case EntrySetAction.convert:
case EntrySetAction.rotateCCW:
case EntrySetAction.rotateCW:
case EntrySetAction.flip:
@ -145,6 +146,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
case EntrySetAction.copy:
case EntrySetAction.move:
case EntrySetAction.rename:
case EntrySetAction.convert:
case EntrySetAction.toggleFavourite:
case EntrySetAction.rotateCCW:
case EntrySetAction.rotateCW:
@ -211,6 +213,9 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
case EntrySetAction.rename:
_rename(context);
break;
case EntrySetAction.convert:
_convert(context);
break;
case EntrySetAction.toggleFavourite:
_toggleFavourite(context);
break;
@ -379,6 +384,13 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
_browse(context);
}
void _convert(BuildContext context) {
final entries = _getTargetItems(context);
convert(context, entries);
_browse(context);
}
Future<void> _toggleFavourite(BuildContext context) async {
final entries = _getTargetItems(context);
if (entries.every((entry) => entry.isFavourite)) {

View file

@ -16,6 +16,7 @@ import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/common/image_op_events.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/services/media/enums.dart';
import 'package:aves/services/media/media_edit_service.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/aves_app.dart';
@ -27,6 +28,7 @@ import 'package:aves/widgets/common/action_mixins/size_aware.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_confirmation_dialog.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/dialogs/convert_entry_dialog.dart';
import 'package:aves/widgets/dialogs/pick_dialogs/album_pick_page.dart';
import 'package:aves/widgets/viewer/notifications.dart';
import 'package:collection/collection.dart';
@ -34,6 +36,100 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
Future<void> convert(BuildContext context, Set<AvesEntry> targetEntries) async {
final options = await showDialog<EntryConvertOptions>(
context: context,
builder: (context) => ConvertEntryDialog(entries: targetEntries),
routeSettings: const RouteSettings(name: ConvertEntryDialog.routeName),
);
if (options == null) return;
final destinationAlbum = await pickAlbum(context: context, moveType: MoveType.export);
if (destinationAlbum == null) return;
if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return;
if (!await checkFreeSpaceForMove(context, targetEntries, destinationAlbum, MoveType.export)) return;
final selection = <AvesEntry>{};
await Future.forEach(targetEntries, (targetEntry) async {
if (targetEntry.isMultiPage) {
final multiPageInfo = await targetEntry.getMultiPageInfo();
if (multiPageInfo != null) {
if (targetEntry.isMotionPhoto) {
await multiPageInfo.extractMotionPhotoVideo();
}
if (multiPageInfo.pageCount > 1) {
selection.addAll(multiPageInfo.exportEntries);
}
}
} else {
selection.add(targetEntry);
}
});
final selectionCount = selection.length;
final source = context.read<CollectionSource>();
source.pauseMonitoring();
await showOpReport<ExportOpEvent>(
context: context,
opStream: mediaEditService.export(
selection,
options: options,
destinationAlbum: destinationAlbum,
nameConflictStrategy: NameConflictStrategy.rename,
),
itemCount: selectionCount,
onDone: (processed) async {
final successOps = processed.where((e) => e.success).toSet();
final exportedOps = successOps.where((e) => !e.skipped).toSet();
final newUris = exportedOps.map((v) => v.newFields['uri'] as String?).whereNotNull().toSet();
final isMainMode = context.read<ValueNotifier<AppMode>>().value == AppMode.main;
source.resumeMonitoring();
unawaited(source.refreshUris(newUris));
final l10n = context.l10n;
final showAction = isMainMode && newUris.isNotEmpty
? SnackBarAction(
label: l10n.showButtonLabel,
onPressed: () {
// local context may be deactivated when action is triggered after navigation
final context = AvesApp.navigatorKey.currentContext;
if (context != null) {
Navigator.maybeOf(context)?.pushAndRemoveUntil(
MaterialPageRoute(
settings: const RouteSettings(name: CollectionPage.routeName),
builder: (context) => CollectionPage(
source: source,
filters: {AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum))},
highlightTest: (entry) => newUris.contains(entry.uri),
),
),
(route) => false,
);
}
},
)
: null;
final successCount = successOps.length;
if (successCount < selectionCount) {
final count = selectionCount - successCount;
showFeedback(
context,
l10n.collectionExportFailureFeedback(count),
showAction,
);
} else {
showFeedback(
context,
l10n.genericSuccessFeedback,
showAction,
);
}
},
);
}
Future<void> doQuickMove(
BuildContext context, {
required MoveType moveType,

View file

@ -26,7 +26,7 @@ mixin FeedbackMixin {
ScaffoldMessengerState? scaffoldMessenger;
try {
scaffoldMessenger = ScaffoldMessenger.of(context);
} catch (e) {
} catch (error) {
// minor issue: the page triggering this feedback likely
// allows the user to navigate away and they did so
debugPrint('failed to find ScaffoldMessenger in context');

View file

@ -0,0 +1,14 @@
import 'package:flutter/widgets.dart';
class AvesTransitions {
static Widget formTransitionBuilder(Widget child, Animation<double> animation) {
return FadeTransition(
opacity: animation,
child: SizeTransition(
sizeFactor: animation,
axisAlignment: -1,
child: child,
),
);
}
}

View file

@ -96,6 +96,7 @@ class AvesFilterChip extends StatefulWidget {
final actions = [
if (filter is AlbumFilter) ChipAction.goToAlbumPage,
if ((filter is LocationFilter && filter.level == LocationLevel.country)) ChipAction.goToCountryPage,
if ((filter is LocationFilter && filter.level == LocationLevel.place)) ChipAction.goToPlacePage,
if (filter is TagFilter) ChipAction.goToTagPage,
ChipAction.reverse,
ChipAction.hide,

View file

@ -3,6 +3,7 @@ import 'package:aves/widgets/collection/collection_page.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.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/widgets/viewer/info/common.dart';
import 'package:flutter/material.dart';
@ -51,6 +52,7 @@ class DebugSettingsSection extends StatelessWidget {
'tileExtent - Collection': '${settings.getTileExtent(CollectionPage.routeName)}',
'tileExtent - Albums': '${settings.getTileExtent(AlbumListPage.routeName)}',
'tileExtent - Countries': '${settings.getTileExtent(CountryListPage.routeName)}',
'tileExtent - Places': '${settings.getTileExtent(PlaceListPage.routeName)}',
'tileExtent - Tags': '${settings.getTileExtent(TagListPage.routeName)}',
'infoMapZoom': '${settings.infoMapZoom}',
'collectionSelectionQuickActions': '${settings.collectionSelectionQuickActions}',

View file

@ -0,0 +1,271 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/metadata/enums/enums.dart';
import 'package:aves/model/metadata/enums/length_unit.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/media/media_edit_service.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/themes.dart';
import 'package:aves/utils/mime_utils.dart';
import 'package:aves/widgets/common/basic/text_dropdown_button.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/fx/transitions.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'aves_dialog.dart';
class ConvertEntryDialog extends StatefulWidget {
static const routeName = '/dialog/convert_entry';
final Set<AvesEntry> entries;
const ConvertEntryDialog({
super.key,
required this.entries,
});
@override
State<ConvertEntryDialog> createState() => _ConvertEntryDialogState();
}
class _ConvertEntryDialogState extends State<ConvertEntryDialog> {
final TextEditingController _widthController = TextEditingController(), _heightController = TextEditingController();
final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false);
late ValueNotifier<String> _mimeTypeNotifier;
late bool _writeMetadata, _sameSized;
late List<LengthUnit> _lengthUnitOptions;
late LengthUnit _lengthUnit;
Set<AvesEntry> get entries => widget.entries;
static const imageExportFormats = [
MimeTypes.bmp,
MimeTypes.jpeg,
MimeTypes.png,
MimeTypes.webp,
];
@override
void initState() {
super.initState();
_mimeTypeNotifier = ValueNotifier(settings.convertMimeType);
_writeMetadata = settings.convertWriteMetadata;
_sameSized = entries.map((entry) => entry.displaySize).toSet().length == 1;
_lengthUnitOptions = [
if (_sameSized) LengthUnit.px,
LengthUnit.percent,
];
_lengthUnit = _lengthUnitOptions.first;
_initDimensions();
_validate();
}
void _initDimensions() {
switch (_lengthUnit) {
case LengthUnit.px:
final displaySize = entries.first.displaySize;
_widthController.text = '${displaySize.width.round()}';
_heightController.text = '${displaySize.height.round()}';
break;
case LengthUnit.percent:
_widthController.text = '100';
_heightController.text = '100';
break;
}
}
@override
void dispose() {
_widthController.dispose();
_heightController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
const contentHorizontalPadding = EdgeInsets.symmetric(horizontal: AvesDialog.defaultHorizontalContentPadding);
// used by the drop down to match input decoration
final textFieldDecorationBorder = Border(
bottom: BorderSide(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.38), //Color(0xFFBDBDBD),
width: 1.0,
),
);
return AvesDialog(
scrollableContent: [
const SizedBox(height: 16),
Padding(
padding: contentHorizontalPadding,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.exportEntryDialogFormat),
const SizedBox(width: AvesDialog.controlCaptionPadding),
TextDropdownButton<String>(
values: imageExportFormats,
valueText: MimeUtils.displayType,
value: _mimeTypeNotifier.value,
onChanged: (selected) {
if (selected != null) {
setState(() => _mimeTypeNotifier.value = selected);
}
},
),
],
),
),
Padding(
padding: contentHorizontalPadding,
child: Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Expanded(
child: TextField(
controller: _widthController,
decoration: InputDecoration(labelText: l10n.exportEntryDialogWidth),
keyboardType: TextInputType.number,
onChanged: (value) {
final width = int.tryParse(value);
if (width != null) {
switch (_lengthUnit) {
case LengthUnit.px:
_heightController.text = '${(width / entries.first.displayAspectRatio).round()}';
break;
case LengthUnit.percent:
_heightController.text = '$width';
break;
}
} else {
_heightController.text = '';
}
_validate();
},
),
),
const SizedBox(width: 8),
const Text(AvesEntry.resolutionSeparator),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _heightController,
decoration: InputDecoration(labelText: l10n.exportEntryDialogHeight),
keyboardType: TextInputType.number,
onChanged: (value) {
final height = int.tryParse(value);
if (height != null) {
switch (_lengthUnit) {
case LengthUnit.px:
_widthController.text = '${(height * entries.first.displayAspectRatio).round()}';
break;
case LengthUnit.percent:
_widthController.text = '$height';
break;
}
} else {
_widthController.text = '';
}
_validate();
},
),
),
const SizedBox(width: 16),
TextDropdownButton<LengthUnit>(
values: _lengthUnitOptions,
valueText: (v) => v.getText(context),
value: _lengthUnit,
onChanged: _lengthUnitOptions.length > 1
? (v) {
if (v != null && _lengthUnit != v) {
_lengthUnit = v;
_initDimensions();
_validate();
setState(() {});
}
}
: null,
underline: Container(
height: 1.0,
decoration: BoxDecoration(
border: textFieldDecorationBorder,
),
),
itemHeight: 60,
dropdownColor: Themes.thirdLayerColor(context),
),
],
),
),
ValueListenableBuilder<String>(
valueListenable: _mimeTypeNotifier,
builder: (context, mimeType, child) {
Widget child;
if (MimeTypes.canEditExif(mimeType) || MimeTypes.canEditIptc(mimeType) || MimeTypes.canEditXmp(mimeType)) {
child = SwitchListTile(
value: _writeMetadata,
onChanged: (v) => setState(() => _writeMetadata = v),
title: Text(context.l10n.exportEntryDialogWriteMetadata),
contentPadding: const EdgeInsetsDirectional.only(
start: AvesDialog.defaultHorizontalContentPadding,
end: AvesDialog.defaultHorizontalContentPadding - 8,
),
);
} else {
child = const SizedBox(height: 16);
}
return AnimatedSwitcher(
duration: context.read<DurationsData>().formTransition,
switchInCurve: Curves.easeInOutCubic,
switchOutCurve: Curves.easeInOutCubic,
transitionBuilder: AvesTransitions.formTransitionBuilder,
child: child,
);
},
),
],
actions: [
const CancelButton(),
ValueListenableBuilder<bool>(
valueListenable: _isValidNotifier,
builder: (context, isValid, child) {
return TextButton(
onPressed: isValid
? () {
final width = int.tryParse(_widthController.text);
final height = int.tryParse(_heightController.text);
final options = (width != null && height != null)
? EntryConvertOptions(
mimeType: _mimeTypeNotifier.value,
writeMetadata: _writeMetadata,
lengthUnit: _lengthUnit,
width: width,
height: height,
)
: null;
if (options != null) {
settings.convertMimeType = options.mimeType;
settings.convertWriteMetadata = options.writeMetadata;
}
Navigator.maybeOf(context)?.pop(options);
}
: null,
child: Text(l10n.applyButtonLabel),
);
},
),
],
);
}
Future<void> _validate() async {
final width = int.tryParse(_widthController.text);
final height = int.tryParse(_heightController.text);
_isValidNotifier.value = (width ?? 0) > 0 && (height ?? 0) > 0;
}
}

View file

@ -13,6 +13,7 @@ import 'package:aves/utils/time_utils.dart';
import 'package:aves/widgets/common/basic/text_dropdown_button.dart';
import 'package:aves/widgets/common/basic/wheel.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/fx/transitions.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/dialogs/item_picker.dart';
@ -111,7 +112,7 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
duration: context.read<DurationsData>().formTransition,
switchInCurve: Curves.easeInOutCubic,
switchOutCurve: Curves.easeInOutCubic,
transitionBuilder: _formTransitionBuilder,
transitionBuilder: AvesTransitions.formTransitionBuilder,
child: Column(
key: ValueKey(_action),
mainAxisSize: MainAxisSize.min,
@ -143,15 +144,6 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
);
}
Widget _formTransitionBuilder(Widget child, Animation<double> animation) => FadeTransition(
opacity: animation,
child: SizeTransition(
sizeFactor: animation,
axisAlignment: -1,
child: child,
),
);
Widget _buildSetCustomContent(BuildContext context) {
final l10n = context.l10n;
final locale = l10n.localeName;

View file

@ -11,6 +11,7 @@ import 'package:aves/theme/themes.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/basic/text_dropdown_button.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/fx/transitions.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/dialogs/item_picker.dart';
@ -114,7 +115,7 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
duration: context.read<DurationsData>().formTransition,
switchInCurve: Curves.easeInOutCubic,
switchOutCurve: Curves.easeInOutCubic,
transitionBuilder: _formTransitionBuilder,
transitionBuilder: AvesTransitions.formTransitionBuilder,
child: Column(
key: ValueKey(_action),
mainAxisSize: MainAxisSize.min,
@ -145,15 +146,6 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
);
}
Widget _formTransitionBuilder(Widget child, Animation<double> animation) => FadeTransition(
opacity: animation,
child: SizeTransition(
sizeFactor: animation,
axisAlignment: -1,
child: child,
),
);
Widget _buildChooseOnMapContent(BuildContext context) {
final l10n = context.l10n;
@ -297,7 +289,7 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
double? tryParse(String text) {
try {
return double.tryParse(text) ?? (coordinateFormatter.parse(text).toDouble());
} catch (e) {
} catch (error) {
// ignore
return null;
}

View file

@ -1,151 +0,0 @@
import 'package:aves/model/entry.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/media/media_edit_service.dart';
import 'package:aves/utils/mime_utils.dart';
import 'package:aves/widgets/common/basic/text_dropdown_button.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/material.dart';
import 'aves_dialog.dart';
class ExportEntryDialog extends StatefulWidget {
static const routeName = '/dialog/export_entry';
final AvesEntry entry;
const ExportEntryDialog({
super.key,
required this.entry,
});
@override
State<ExportEntryDialog> createState() => _ExportEntryDialogState();
}
class _ExportEntryDialogState extends State<ExportEntryDialog> {
final TextEditingController _widthController = TextEditingController(), _heightController = TextEditingController();
final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false);
String _mimeType = MimeTypes.jpeg;
AvesEntry get entry => widget.entry;
static const imageExportFormats = [
MimeTypes.bmp,
MimeTypes.jpeg,
MimeTypes.png,
MimeTypes.webp,
];
@override
void initState() {
super.initState();
_widthController.text = '${entry.isRotated ? entry.height : entry.width}';
_heightController.text = '${entry.isRotated ? entry.width : entry.height}';
_validate();
}
@override
void dispose() {
_widthController.dispose();
_heightController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
const contentHorizontalPadding = EdgeInsets.symmetric(horizontal: AvesDialog.defaultHorizontalContentPadding);
return AvesDialog(
scrollableContent: [
const SizedBox(height: 16),
Padding(
padding: contentHorizontalPadding,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.exportEntryDialogFormat),
const SizedBox(width: AvesDialog.controlCaptionPadding),
TextDropdownButton<String>(
values: imageExportFormats,
valueText: MimeUtils.displayType,
value: _mimeType,
onChanged: (selected) {
if (selected != null) {
setState(() => _mimeType = selected);
}
},
),
],
),
),
Padding(
padding: contentHorizontalPadding,
child: Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Expanded(
child: TextField(
controller: _widthController,
decoration: InputDecoration(labelText: l10n.exportEntryDialogWidth),
keyboardType: TextInputType.number,
onChanged: (value) {
final width = int.tryParse(value);
_heightController.text = width != null ? '${(width / entry.displayAspectRatio).round()}' : '';
_validate();
},
),
),
const Text(AvesEntry.resolutionSeparator),
Expanded(
child: TextField(
controller: _heightController,
decoration: InputDecoration(labelText: l10n.exportEntryDialogHeight),
keyboardType: TextInputType.number,
onChanged: (value) {
final height = int.tryParse(value);
_widthController.text = height != null ? '${(height * entry.displayAspectRatio).round()}' : '';
_validate();
},
),
),
],
),
),
const SizedBox(height: 16),
],
actions: [
const CancelButton(),
ValueListenableBuilder<bool>(
valueListenable: _isValidNotifier,
builder: (context, isValid, child) {
return TextButton(
onPressed: isValid
? () {
final width = int.tryParse(_widthController.text);
final height = int.tryParse(_heightController.text);
final options = (width != null && height != null)
? EntryExportOptions(
mimeType: _mimeType,
width: width,
height: height,
)
: null;
Navigator.maybeOf(context)?.pop(options);
}
: null,
child: Text(l10n.applyButtonLabel),
);
},
),
],
);
}
Future<void> _validate() async {
final width = int.tryParse(_widthController.text);
final height = int.tryParse(_heightController.text);
_isValidNotifier.value = (width ?? 0) > 0 && (height ?? 0) > 0;
}
}

View file

@ -4,6 +4,7 @@ import 'package:aves/theme/icons.dart';
import 'package:aves/theme/themes.dart';
import 'package:aves/widgets/common/basic/text_dropdown_button.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/fx/transitions.dart';
import 'package:aves/widgets/common/identity/aves_caption.dart';
import 'package:aves/widgets/common/identity/highlight_title.dart';
import 'package:aves/widgets/common/tile_extent_controller.dart';
@ -98,14 +99,7 @@ class _TileViewDialogState<S, G, L> extends State<TileViewDialog<S, G, L>> with
duration: context.read<DurationsData>().formTransition,
switchInCurve: Curves.easeInOutCubic,
switchOutCurve: Curves.easeInOutCubic,
transitionBuilder: (child, animation) => FadeTransition(
opacity: animation,
child: SizeTransition(
sizeFactor: animation,
axisAlignment: -1,
child: child,
),
),
transitionBuilder: AvesTransitions.formTransitionBuilder,
child: _buildSection(
show: canGroup,
icon: AIcons.group,

View file

@ -11,6 +11,7 @@ import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_dialog.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:flutter/material.dart';
import 'package:provider/provider.dart';
@ -23,6 +24,7 @@ class ChipActionDelegate with FeedbackMixin, VaultAwareMixin {
switch (action) {
case ChipAction.goToAlbumPage:
case ChipAction.goToCountryPage:
case ChipAction.goToPlacePage:
case ChipAction.goToTagPage:
case ChipAction.reverse:
return true;
@ -42,6 +44,9 @@ class ChipActionDelegate with FeedbackMixin, VaultAwareMixin {
case ChipAction.goToCountryPage:
_goTo(context, filter, CountryListPage.routeName, (context) => const CountryListPage());
break;
case ChipAction.goToPlacePage:
_goTo(context, filter, PlaceListPage.routeName, (context) => const PlaceListPage());
break;
case ChipAction.goToTagPage:
_goTo(context, filter, TagListPage.routeName, (context) => const TagListPage());
break;

View file

@ -0,0 +1,33 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/location.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/enums/enums.dart';
import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart';
import 'package:aves/widgets/filter_grids/places_page.dart';
class PlaceChipSetActionDelegate extends ChipSetActionDelegate<LocationFilter> {
final Iterable<FilterGridItem<LocationFilter>> _items;
PlaceChipSetActionDelegate(Iterable<FilterGridItem<LocationFilter>> items) : _items = items;
@override
Iterable<FilterGridItem<LocationFilter>> get allItems => _items;
@override
ChipSortFactor get sortFactor => settings.placeSortFactor;
@override
set sortFactor(ChipSortFactor factor) => settings.placeSortFactor = factor;
@override
bool get sortReverse => settings.placeSortReverse;
@override
set sortReverse(bool value) => settings.placeSortReverse = value;
@override
TileLayout get tileLayout => settings.getTileLayout(PlaceListPage.routeName);
@override
set tileLayout(TileLayout tileLayout) => settings.setTileLayout(PlaceListPage.routeName, tileLayout);
}

View file

@ -327,9 +327,13 @@ class _FilterGridAppBarState<T extends CollectionFilter, CSAD extends ChipSetAct
final browsingMenuActions = ChipSetActions.browsing.where((v) => !browsingQuickActions.contains(v));
final selectionMenuActions = ChipSetActions.selection.where((v) => !selectionQuickActions.contains(v));
final contextualMenuActions = (isSelecting ? selectionMenuActions : browsingMenuActions).where((v) => v == null || isVisible(v)).toList();
if (contextualMenuActions.isNotEmpty && contextualMenuActions.first == null) contextualMenuActions.removeAt(0);
if (contextualMenuActions.isNotEmpty && contextualMenuActions.last == null) contextualMenuActions.removeLast();
final contextualMenuActions = (isSelecting ? selectionMenuActions : browsingMenuActions).where((v) => v == null || isVisible(v)).fold(<ChipSetAction?>[], (prev, v) {
if (v == null && (prev.isEmpty || prev.last == null)) return prev;
return [...prev, v];
});
if (contextualMenuActions.isNotEmpty && contextualMenuActions.last == null) {
contextualMenuActions.removeLast();
}
return [
...generalMenuItems,

View file

@ -7,7 +7,7 @@ import 'package:aves/model/filters/location.dart';
import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/source/album.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/location.dart';
import 'package:aves/model/source/location/country.dart';
import 'package:aves/model/source/tag.dart';
import 'package:aves/model/vaults/vaults.dart';
import 'package:aves/theme/durations.dart';

View file

@ -3,7 +3,7 @@ import 'package:aves/model/filters/location.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums/enums.dart';
import 'package:aves/model/source/location.dart';
import 'package:aves/model/source/location/country.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/empty.dart';
@ -43,7 +43,7 @@ class CountryListPage extends StatelessWidget {
filterSections: _groupToSections(gridItems),
applyQuery: applyQuery,
emptyBuilder: () => EmptyContent(
icon: AIcons.location,
icon: AIcons.country,
text: context.l10n.countryEmpty,
),
);

View file

@ -0,0 +1,80 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/location.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums/enums.dart';
import 'package:aves/model/source/location/place.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/empty.dart';
import 'package:aves/widgets/filter_grids/common/action_delegates/place_set.dart';
import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart';
import 'package:aves/widgets/filter_grids/common/section_keys.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
class PlaceListPage extends StatelessWidget {
static const routeName = '/places';
const PlaceListPage({super.key});
@override
Widget build(BuildContext context) {
final source = context.read<CollectionSource>();
return Selector<Settings, Tuple3<ChipSortFactor, bool, Set<CollectionFilter>>>(
selector: (context, s) => Tuple3(s.placeSortFactor, s.placeSortReverse, s.pinnedFilters),
shouldRebuild: (t1, t2) {
// `Selector` by default uses `DeepCollectionEquality`, which does not go deep in collections within `TupleN`
const eq = DeepCollectionEquality();
return !(eq.equals(t1.item1, t2.item1) && eq.equals(t1.item2, t2.item2) && eq.equals(t1.item3, t2.item3));
},
builder: (context, s, child) {
return StreamBuilder(
stream: source.eventBus.on<PlacesChangedEvent>(),
builder: (context, snapshot) {
final gridItems = _getGridItems(source);
return FilterNavigationPage<LocationFilter, PlaceChipSetActionDelegate>(
source: source,
title: context.l10n.placePageTitle,
sortFactor: settings.placeSortFactor,
actionDelegate: PlaceChipSetActionDelegate(gridItems),
filterSections: _groupToSections(gridItems),
applyQuery: applyQuery,
emptyBuilder: () => EmptyContent(
icon: AIcons.place,
text: context.l10n.placeEmpty,
),
);
},
);
},
);
}
List<FilterGridItem<LocationFilter>> applyQuery(BuildContext context, List<FilterGridItem<LocationFilter>> filters, String query) {
return filters.where((item) => item.filter.getLabel(context).toUpperCase().contains(query)).toList();
}
List<FilterGridItem<LocationFilter>> _getGridItems(CollectionSource source) {
final filters = source.sortedPlaces.map((location) => LocationFilter(LocationLevel.place, location)).toSet();
return FilterNavigationPage.sort(settings.placeSortFactor, settings.placeSortReverse, source, filters);
}
static Map<ChipSectionKey, List<FilterGridItem<LocationFilter>>> _groupToSections(Iterable<FilterGridItem<LocationFilter>> sortedMapEntries) {
final pinned = settings.pinnedFilters.whereType<LocationFilter>();
final byPin = groupBy<FilterGridItem<LocationFilter>, bool>(sortedMapEntries, (e) => pinned.contains(e.filter));
final pinnedMapEntries = (byPin[true] ?? []);
final unpinnedMapEntries = (byPin[false] ?? []);
return {
if (pinnedMapEntries.isNotEmpty || unpinnedMapEntries.isNotEmpty)
const ChipSectionKey(): [
...pinnedMapEntries,
...unpinnedMapEntries,
],
};
}
}

View file

@ -6,7 +6,8 @@ import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/album.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/location.dart';
import 'package:aves/model/source/location/country.dart';
import 'package:aves/model/source/location/place.dart';
import 'package:aves/model/source/tag.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
@ -19,6 +20,7 @@ import 'package:aves/widgets/common/identity/aves_logo.dart';
import 'package:aves/widgets/debug/app_debug_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/widgets/navigation/drawer/collection_nav_tile.dart';
import 'package:aves/widgets/navigation/drawer/page_nav_tile.dart';
@ -242,6 +244,12 @@ class _AppDrawerState extends State<AppDrawer> {
builder: (context, _) => Text('${source.sortedCountries.length}'),
);
break;
case PlaceListPage.routeName:
trailing = StreamBuilder(
stream: source.eventBus.on<PlacesChangedEvent>(),
builder: (context, _) => Text('${source.sortedPlaces.length}'),
);
break;
case TagListPage.routeName:
trailing = StreamBuilder(
stream: source.eventBus.on<TagsChangedEvent>(),

View file

@ -7,6 +7,7 @@ import 'package:aves/widgets/common/search/route.dart';
import 'package:aves/widgets/debug/app_debug_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/widgets/navigation/drawer/tile.dart';
import 'package:aves/widgets/search/search_delegate.dart';
@ -88,6 +89,8 @@ class PageNavTile extends StatelessWidget {
return (_) => const AlbumListPage();
case CountryListPage.routeName:
return (_) => const CountryListPage();
case PlaceListPage.routeName:
return (_) => const PlaceListPage();
case TagListPage.routeName:
return (_) => const TagListPage();
case SettingsPage.routeName:

View file

@ -9,6 +9,7 @@ import 'package:aves/widgets/common/search/page.dart';
import 'package:aves/widgets/debug/app_debug_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/widgets/settings/settings_page.dart';
import 'package:flutter/material.dart';
@ -35,6 +36,8 @@ class NavigationDisplay {
return l10n.drawerAlbumPage;
case CountryListPage.routeName:
return l10n.drawerCountryPage;
case PlaceListPage.routeName:
return l10n.drawerPlacePage;
case TagListPage.routeName:
return l10n.drawerTagPage;
case SettingsPage.routeName:
@ -55,7 +58,9 @@ class NavigationDisplay {
case AlbumListPage.routeName:
return AIcons.album;
case CountryListPage.routeName:
return AIcons.location;
return AIcons.country;
case PlaceListPage.routeName:
return AIcons.place;
case TagListPage.routeName:
return AIcons.tag;
case SettingsPage.routeName:

View file

@ -15,7 +15,8 @@ import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/album.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/location.dart';
import 'package:aves/model/source/location/country.dart';
import 'package:aves/model/source/location/place.dart';
import 'package:aves/model/source/tag.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:aves/widgets/collection/collection_page.dart';

View file

@ -11,6 +11,7 @@ import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/settings/common/tile_leading.dart';
import 'package:aves/widgets/settings/common/tiles.dart';
import 'package:aves/widgets/settings/privacy/privacy.dart';
import 'package:aves/widgets/settings/settings_definition.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@ -134,6 +135,8 @@ class SettingsTileDisplayForceTvLayout extends SettingsTile {
if (confirmed == null || !confirmed) return;
}
if (v && !(await SettingsTilePrivacyEnableBin.setBinUsage(context, false))) return;
settings.forceTvLayout = v;
},
title: title(context),

View file

@ -6,6 +6,7 @@ import 'package:aves/widgets/common/extensions/build_context.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/widgets/navigation/drawer/app_drawer.dart';
import 'package:aves/widgets/navigation/drawer/tile.dart';
@ -40,6 +41,7 @@ class _NavigationDrawerEditorPageState extends State<NavigationDrawerEditorPage>
static const Set<String> _pageOptions = {
AlbumListPage.routeName,
CountryListPage.routeName,
PlaceListPage.routeName,
TagListPage.routeName,
SearchPage.routeName,
};

View file

@ -97,9 +97,14 @@ class SettingsTilePrivacyEnableBin extends SettingsTile {
@override
Widget build(BuildContext context) => SettingsSwitchListTile(
selector: (context, s) => s.enableBin,
onChanged: (v) async {
onChanged: (v) => setBinUsage(context, v),
title: title(context),
subtitle: context.l10n.settingsEnableBinSubtitle,
);
static Future<bool> setBinUsage(BuildContext context, bool enabled) async {
final l10n = context.l10n;
if (!v) {
if (!enabled) {
if (vaults.all.any((v) => v.useBin)) {
await showDialog<bool>(
context: context,
@ -108,7 +113,7 @@ class SettingsTilePrivacyEnableBin extends SettingsTile {
actions: const [OkButton()],
),
);
return;
return false;
}
final source = context.read<CollectionSource>();
@ -118,7 +123,7 @@ class SettingsTilePrivacyEnableBin extends SettingsTile {
context: context,
message: l10n.settingsDisablingBinWarningDialogMessage,
confirmationButtonLabel: l10n.applyButtonLabel,
)) return;
)) return false;
// delete forever trashed items
await EntrySetActionDelegate().doDelete(
@ -128,18 +133,15 @@ class SettingsTilePrivacyEnableBin extends SettingsTile {
);
// in case of failure or cancellation
if (source.trashedEntries.isNotEmpty) return;
}
if (source.trashedEntries.isNotEmpty) return false;
}
settings.enableBin = v;
if (!v) {
settings.searchHistory = [];
}
},
title: title(context),
subtitle: context.l10n.settingsEnableBinSubtitle,
);
settings.enableBin = enabled;
return true;
}
}
class SettingsTilePrivacyHiddenItems extends SettingsTile {

View file

@ -5,6 +5,7 @@ import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/date.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/fx/transitions.dart';
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
import 'package:aves/widgets/stats/date/axis.dart';
import 'package:charts_flutter/flutter.dart' as charts;
@ -343,14 +344,7 @@ class _HistogramState extends State<Histogram> with AutomaticKeepAliveClientMixi
duration: context.read<DurationsData>().formTransition,
switchInCurve: Curves.easeInOutCubic,
switchOutCurve: Curves.easeInOutCubic,
transitionBuilder: (child, animation) => FadeTransition(
opacity: animation,
child: SizeTransition(
sizeFactor: animation,
axisAlignment: -1,
child: child,
),
),
transitionBuilder: AvesTransitions.formTransitionBuilder,
child: child,
);
},

View file

@ -8,20 +8,14 @@ import 'package:aves/model/actions/share_actions.dart';
import 'package:aves/model/device.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/entry_metadata_edition.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/enums/enums.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/vaults/vaults.dart';
import 'package:aves/services/common/image_op_events.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/services/media/enums.dart';
import 'package:aves/services/media/media_edit_service.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/aves_app.dart';
import 'package:aves/widgets/collection/collection_page.dart';
import 'package:aves/widgets/common/action_mixins/entry_storage.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart';
import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
@ -32,8 +26,6 @@ import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart';
import 'package:aves/widgets/dialogs/aves_confirmation_dialog.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/dialogs/entry_editors/rename_entry_dialog.dart';
import 'package:aves/widgets/dialogs/export_entry_dialog.dart';
import 'package:aves/widgets/dialogs/pick_dialogs/album_pick_page.dart';
import 'package:aves/widgets/viewer/action/entry_info_action_delegate.dart';
import 'package:aves/widgets/viewer/action/printer.dart';
import 'package:aves/widgets/viewer/action/single_entry_editor.dart';
@ -42,7 +34,6 @@ import 'package:aves/widgets/viewer/multipage/conductor.dart';
import 'package:aves/widgets/viewer/notifications.dart';
import 'package:aves/widgets/viewer/source_viewer_page.dart';
import 'package:aves/widgets/viewer/video/conductor.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
@ -197,7 +188,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
_move(context, targetEntry, moveType: MoveType.fromBin);
break;
case EntryAction.convert:
_convert(context, targetEntry);
convert(context, {targetEntry});
break;
case EntryAction.print:
EntryPrinter(targetEntry).print(context);
@ -412,98 +403,6 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
}
}
Future<void> _convert(BuildContext context, AvesEntry targetEntry) async {
final options = await showDialog<EntryExportOptions>(
context: context,
builder: (context) => ExportEntryDialog(entry: targetEntry),
routeSettings: const RouteSettings(name: ExportEntryDialog.routeName),
);
if (options == null) return;
final destinationAlbum = await pickAlbum(context: context, moveType: MoveType.export);
if (destinationAlbum == null) return;
if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return;
if (!await checkFreeSpaceForMove(context, {targetEntry}, destinationAlbum, MoveType.export)) return;
final selection = <AvesEntry>{};
if (targetEntry.isMultiPage) {
final multiPageInfo = await targetEntry.getMultiPageInfo();
if (multiPageInfo != null) {
if (targetEntry.isMotionPhoto) {
await multiPageInfo.extractMotionPhotoVideo();
}
if (multiPageInfo.pageCount > 1) {
selection.addAll(multiPageInfo.exportEntries);
}
}
} else {
selection.add(targetEntry);
}
final selectionCount = selection.length;
final source = context.read<CollectionSource>();
source.pauseMonitoring();
await showOpReport<ExportOpEvent>(
context: context,
opStream: mediaEditService.export(
selection,
options: options,
destinationAlbum: destinationAlbum,
nameConflictStrategy: NameConflictStrategy.rename,
),
itemCount: selectionCount,
onDone: (processed) async {
final successOps = processed.where((e) => e.success).toSet();
final exportedOps = successOps.where((e) => !e.skipped).toSet();
final newUris = exportedOps.map((v) => v.newFields['uri'] as String?).whereNotNull().toSet();
final isMainMode = context.read<ValueNotifier<AppMode>>().value == AppMode.main;
source.resumeMonitoring();
unawaited(source.refreshUris(newUris));
final l10n = context.l10n;
final showAction = isMainMode && newUris.isNotEmpty
? SnackBarAction(
label: l10n.showButtonLabel,
onPressed: () {
// local context may be deactivated when action is triggered after navigation
final context = AvesApp.navigatorKey.currentContext;
if (context != null) {
Navigator.maybeOf(context)?.pushAndRemoveUntil(
MaterialPageRoute(
settings: const RouteSettings(name: CollectionPage.routeName),
builder: (context) => CollectionPage(
source: source,
filters: {AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum))},
highlightTest: (entry) => newUris.contains(entry.uri),
),
),
(route) => false,
);
}
},
)
: null;
final successCount = successOps.length;
if (successCount < selectionCount) {
final count = selectionCount - successCount;
showFeedback(
context,
l10n.collectionExportFailureFeedback(count),
showAction,
);
} else {
showFeedback(
context,
l10n.genericSuccessFeedback,
showAction,
);
}
},
);
}
Future<void> _move(BuildContext context, AvesEntry targetEntry, {required MoveType moveType}) => doMove(
context,
moveType: moveType,

View file

@ -55,8 +55,8 @@ mixin SingleEntryEditorMixin on FeedbackMixin, PermissionAwareMixin {
} else {
showFeedback(context, l10n.genericFailureFeedback);
}
} catch (e, stack) {
await reportService.recordError(e, stack);
} catch (error, stack) {
await reportService.recordError(error, stack);
}
source?.resumeMonitoring();
}

View file

@ -55,7 +55,7 @@ class ViewerDebugPage extends StatelessWidget {
if (time != null && time > 0) {
try {
value += ' (${DateTime.fromMillisecondsSinceEpoch(time * factor)})';
} catch (e) {
} catch (error) {
value += ' (invalid DateTime})';
}
}

View file

@ -62,7 +62,7 @@ class _MetadataTabState extends State<MetadataTab> {
}
try {
value += ' (${DateTime.fromMillisecondsSinceEpoch(v)})';
} catch (e) {
} catch (error) {
value += ' (invalid DateTime})';
}
}

View file

@ -39,11 +39,11 @@ class _WelcomePageState extends State<WelcomePage> {
WidgetsBinding.instance.addPostFrameCallback((_) => _initWelcomeSettings());
}
// explicitly set consent values to current defaults
// so they are not subject to future default changes
void _initWelcomeSettings() {
// this should be done outside of `initState`/`build`
settings.setContextualDefaults(context.read<AppFlavor>());
// explicitly set consent values to current defaults
// so they are not subject to future default changes
settings.isInstalledAppAccessAllowed = SettingsDefaults.isInstalledAppAccessAllowed;
settings.isErrorReportingAllowed = SettingsDefaults.isErrorReportingAllowed;
}

View file

@ -197,14 +197,17 @@ class _MagnifierCoreState extends State<MagnifierCore> with TickerProviderStateM
if (_isFlingGesture(estimate, _flingPointerKind, Axis.horizontal)) {
final left = _mayFlingLTRB.item1;
final right = _mayFlingLTRB.item3;
if (left ^ right) {
if (left) {
onFling(AxisDirection.left);
} else if (right) {
onFling(AxisDirection.right);
}
}
} else if (_isFlingGesture(estimate, _flingPointerKind, Axis.vertical)) {
final up = _mayFlingLTRB.item2;
final down = _mayFlingLTRB.item4;
if (up ^ down) {
if (up) {
onFling(AxisDirection.up);
} else if (down) {
@ -212,6 +215,7 @@ class _MagnifierCoreState extends State<MagnifierCore> with TickerProviderStateM
}
}
}
}
final _position = controller.position;
final _scale = controller.scale!;

View file

@ -66,10 +66,10 @@ packages:
dependency: "direct main"
description:
name: plugin_platform_interface
sha256: dbf0f707c78beedc9200146ad3cb0ab4d5da13c246336987be6940f026500d3a
sha256: "6a2128648c854906c53fa8e33986fc0247a1116122f9534dd20e3ab9e16a32bc"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
version: "2.1.4"
sky_engine:
dependency: transitive
description: flutter

View file

@ -5,10 +5,10 @@ packages:
dependency: transitive
description:
name: _flutterfire_internals
sha256: "6215ac7d00ed98300b72f45ed2b38c2ca841f9f4e6965fab33cbd591e45e4473"
sha256: "64fcb0dbca4386356386c085142fa6e79c00a3326ceaa778a2d25f5d9ba61441"
url: "https://pub.dev"
source: hosted
version: "1.0.13"
version: "1.0.16"
async:
dependency: transitive
description:
@ -68,10 +68,10 @@ packages:
dependency: "direct main"
description:
name: firebase_core
sha256: be13e431c0c950f0fc66bdb67b41b8059121d7e7d8bbbc21fb59164892d561f8
sha256: fe30ac230f12f8836bb97e6e09197340d3c584526825b1746ea362a82e1e43f7
url: "https://pub.dev"
source: hosted
version: "2.5.0"
version: "2.7.0"
firebase_core_platform_interface:
dependency: transitive
description:
@ -84,26 +84,26 @@ packages:
dependency: transitive
description:
name: firebase_core_web
sha256: "4b3a41410f3313bb95fd560aa5eb761b6ad65c185de772c72231e8b4aeed6d18"
sha256: "291fbcace608aca6c860652e1358ef89752be8cc3ef227f8bbcd1e62775b833a"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
version: "2.2.1"
firebase_crashlytics:
dependency: "direct main"
description:
name: firebase_crashlytics
sha256: "6f1dc5321aa7d8bb84e8eec2ed517b0a7e7bc99a2708549c55c78359a323ecd0"
sha256: "816bbb920316c8fe257b460b8856b01e274e867a729961bf7a3be6322cdf13e1"
url: "https://pub.dev"
source: hosted
version: "3.0.12"
version: "3.0.15"
firebase_crashlytics_platform_interface:
dependency: transitive
description:
name: firebase_crashlytics_platform_interface
sha256: "3728ad632a5209499cdfed6a1f7154982a524a076566f5a1d02af623bf46f947"
sha256: "120e47b9bac3654848d1bdc60b8027f3574b53ee0b81b1a2e5e76ddaa58f6645"
url: "https://pub.dev"
source: hosted
version: "3.3.12"
version: "3.3.15"
flutter:
dependency: "direct main"
description: flutter
@ -179,10 +179,10 @@ packages:
dependency: transitive
description:
name: plugin_platform_interface
sha256: dbf0f707c78beedc9200146ad3cb0ab4d5da13c246336987be6940f026500d3a
sha256: "6a2128648c854906c53fa8e33986fc0247a1116122f9534dd20e3ab9e16a32bc"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
version: "2.1.4"
sky_engine:
dependency: transitive
description: flutter

View file

@ -135,10 +135,10 @@ packages:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: "60fc7b78455b94e6de2333d2f95196d32cf5c22f4b0b0520a628804cb463503b"
sha256: "4bef634684b2c7f3468c77c766c831229af829a0cd2d4ee6c1b99558bd14e5d2"
url: "https://pub.dev"
source: hosted
version: "2.0.7"
version: "2.0.8"
flutter_web_plugins:
dependency: transitive
description: flutter
@ -156,34 +156,34 @@ packages:
dependency: "direct main"
description:
name: google_maps_flutter
sha256: "0c6b72b4b1e0f6204973e2b40868a75fe6380725d498f215cd7e35ed920d1c57"
sha256: e4f117747e210e68fe34196c93040e0712ba8aa91f8813fe8cb3f52ad667e70b
url: "https://pub.dev"
source: hosted
version: "2.2.3"
version: "2.2.4"
google_maps_flutter_android:
dependency: "direct main"
description:
name: google_maps_flutter_android
sha256: f238a04c378df6fbe7d84a3ea19362e9db19ea1717d06deffa9edaa17973e916
sha256: "69bac3fd18db5638763b06e04c2f2ff2e65e0a76fe93e0e211d8a77afaca033f"
url: "https://pub.dev"
source: hosted
version: "2.4.4"
version: "2.4.6"
google_maps_flutter_ios:
dependency: transitive
description:
name: google_maps_flutter_ios
sha256: "33bbca8d4148ed373251ea2ec2344fdc63009926b6d6be71a0854fd42409b1ba"
sha256: "7ed57e6f1351aebf1442dea0028f2c880989ead866eaf25e9121237c622d4af0"
url: "https://pub.dev"
source: hosted
version: "2.1.13"
version: "2.1.14"
google_maps_flutter_platform_interface:
dependency: "direct main"
description:
name: google_maps_flutter_platform_interface
sha256: "0967430c25240836b794d42336bd4c61f0e78e9fd33d1365fa9316bb36b6b410"
sha256: a07811d2b82055815ede75e1fe4b7b76f71a0b4820b26f71bdaddd157d6a3e20
url: "https://pub.dev"
source: hosted
version: "2.2.5"
version: "2.2.6"
http:
dependency: transitive
description:
@ -284,10 +284,10 @@ packages:
dependency: transitive
description:
name: plugin_platform_interface
sha256: dbf0f707c78beedc9200146ad3cb0ab4d5da13c246336987be6940f026500d3a
sha256: "6a2128648c854906c53fa8e33986fc0247a1116122f9534dd20e3ab9e16a32bc"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
version: "2.1.4"
polylabel:
dependency: transitive
description:

View file

@ -222,10 +222,10 @@ packages:
dependency: transitive
description:
name: plugin_platform_interface
sha256: dbf0f707c78beedc9200146ad3cb0ab4d5da13c246336987be6940f026500d3a
sha256: "6a2128648c854906c53fa8e33986fc0247a1116122f9534dd20e3ab9e16a32bc"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
version: "2.1.4"
polylabel:
dependency: transitive
description:

View file

@ -5,26 +5,26 @@ packages:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: "0c80aeab9bc807ab10022cd3b2f4cf2ecdf231949dc1ddd9442406a003f19201"
sha256: e440ac42679dfc04bbbefb58ed225c994bc7e07fccc8a68ec7d3631a127e5da9
url: "https://pub.dev"
source: hosted
version: "52.0.0"
version: "54.0.0"
_flutterfire_internals:
dependency: transitive
description:
name: _flutterfire_internals
sha256: "6215ac7d00ed98300b72f45ed2b38c2ca841f9f4e6965fab33cbd591e45e4473"
sha256: "64fcb0dbca4386356386c085142fa6e79c00a3326ceaa778a2d25f5d9ba61441"
url: "https://pub.dev"
source: hosted
version: "1.0.13"
version: "1.0.16"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: cd8ee83568a77f3ae6b913a36093a1c9b1264e7cb7f834d9ddd2311dade9c1f4
sha256: "2c2e3721ee9fb36de92faa060f3480c81b23e904352b087e5c64224b1a044427"
url: "https://pub.dev"
source: hosted
version: "5.4.0"
version: "5.6.0"
archive:
dependency: transitive
description:
@ -37,10 +37,10 @@ packages:
dependency: transitive
description:
name: args
sha256: "139d809800a412ebb26a3892da228b2d0ba36f0ef5d9a82166e5e52ec8d61611"
sha256: "4cab82a83ffef80b262ddedf47a0a8e56ee6fbf7fe21e6e768b02792034dd440"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
version: "2.4.0"
async:
dependency: transitive
description:
@ -321,10 +321,10 @@ packages:
dependency: transitive
description:
name: firebase_core
sha256: be13e431c0c950f0fc66bdb67b41b8059121d7e7d8bbbc21fb59164892d561f8
sha256: fe30ac230f12f8836bb97e6e09197340d3c584526825b1746ea362a82e1e43f7
url: "https://pub.dev"
source: hosted
version: "2.5.0"
version: "2.7.0"
firebase_core_platform_interface:
dependency: transitive
description:
@ -337,26 +337,26 @@ packages:
dependency: transitive
description:
name: firebase_core_web
sha256: "4b3a41410f3313bb95fd560aa5eb761b6ad65c185de772c72231e8b4aeed6d18"
sha256: "291fbcace608aca6c860652e1358ef89752be8cc3ef227f8bbcd1e62775b833a"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
version: "2.2.1"
firebase_crashlytics:
dependency: transitive
description:
name: firebase_crashlytics
sha256: "6f1dc5321aa7d8bb84e8eec2ed517b0a7e7bc99a2708549c55c78359a323ecd0"
sha256: "816bbb920316c8fe257b460b8856b01e274e867a729961bf7a3be6322cdf13e1"
url: "https://pub.dev"
source: hosted
version: "3.0.12"
version: "3.0.15"
firebase_crashlytics_platform_interface:
dependency: transitive
description:
name: firebase_crashlytics_platform_interface
sha256: "3728ad632a5209499cdfed6a1f7154982a524a076566f5a1d02af623bf46f947"
sha256: "120e47b9bac3654848d1bdc60b8027f3574b53ee0b81b1a2e5e76ddaa58f6645"
url: "https://pub.dev"
source: hosted
version: "3.3.12"
version: "3.3.15"
flex_color_picker:
dependency: "direct main"
description:
@ -440,18 +440,18 @@ packages:
dependency: "direct main"
description:
name: flutter_markdown
sha256: "818cf6c28377ba2c91ed283c96fd712e9c175dd2d2488eb7fc93b6afb9ad2e08"
sha256: "7b25c10de1fea883f3c4f9b8389506b54053cd00807beab69fd65c8653a2711f"
url: "https://pub.dev"
source: hosted
version: "0.6.13+1"
version: "0.6.14"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: "60fc7b78455b94e6de2333d2f95196d32cf5c22f4b0b0520a628804cb463503b"
sha256: "4bef634684b2c7f3468c77c766c831229af829a0cd2d4ee6c1b99558bd14e5d2"
url: "https://pub.dev"
source: hosted
version: "2.0.7"
version: "2.0.8"
flutter_staggered_animations:
dependency: "direct main"
description:
@ -511,34 +511,34 @@ packages:
dependency: transitive
description:
name: google_maps_flutter
sha256: "0c6b72b4b1e0f6204973e2b40868a75fe6380725d498f215cd7e35ed920d1c57"
sha256: e4f117747e210e68fe34196c93040e0712ba8aa91f8813fe8cb3f52ad667e70b
url: "https://pub.dev"
source: hosted
version: "2.2.3"
version: "2.2.4"
google_maps_flutter_android:
dependency: transitive
description:
name: google_maps_flutter_android
sha256: f238a04c378df6fbe7d84a3ea19362e9db19ea1717d06deffa9edaa17973e916
sha256: "69bac3fd18db5638763b06e04c2f2ff2e65e0a76fe93e0e211d8a77afaca033f"
url: "https://pub.dev"
source: hosted
version: "2.4.4"
version: "2.4.6"
google_maps_flutter_ios:
dependency: transitive
description:
name: google_maps_flutter_ios
sha256: "33bbca8d4148ed373251ea2ec2344fdc63009926b6d6be71a0854fd42409b1ba"
sha256: "7ed57e6f1351aebf1442dea0028f2c880989ead866eaf25e9121237c622d4af0"
url: "https://pub.dev"
source: hosted
version: "2.1.13"
version: "2.1.14"
google_maps_flutter_platform_interface:
dependency: transitive
description:
name: google_maps_flutter_platform_interface
sha256: "0967430c25240836b794d42336bd4c61f0e78e9fd33d1365fa9316bb36b6b410"
sha256: a07811d2b82055815ede75e1fe4b7b76f71a0b4820b26f71bdaddd157d6a3e20
url: "https://pub.dev"
source: hosted
version: "2.2.5"
version: "2.2.6"
highlight:
dependency: transitive
description:
@ -575,10 +575,10 @@ packages:
dependency: transitive
description:
name: image
sha256: "3686865febd85c57a632d87a0fb1f0a8a9ef602fdb68d909c3e9aa29ec70fd24"
sha256: "483a389d6ccb292b570c31b3a193779b1b0178e7eb571986d9a49904b6861227"
url: "https://pub.dev"
source: hosted
version: "4.0.13"
version: "4.0.15"
intl:
dependency: "direct main"
description:
@ -631,42 +631,42 @@ packages:
dependency: "direct main"
description:
name: local_auth
sha256: "8cea55dca20d1e0efa5480df2d47ae30851e7a24cb8e7d225be7e67ae8485aa4"
sha256: "1625a217c599db02044df992509eb52584cfdaa8219958d516b74f644ef2c626"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
version: "2.1.5"
local_auth_android:
dependency: transitive
description:
name: local_auth_android
sha256: cfcbc4936e288d61ef85a04feef6b95f49ba496d4fd98364e6abafb462b06a1f
sha256: "9106024549f411fea2543630582ae7937fcb97ce197755a6d985b23b980410c7"
url: "https://pub.dev"
source: hosted
version: "1.0.18"
version: "1.0.19"
local_auth_ios:
dependency: transitive
description:
name: local_auth_ios
sha256: aa32478d7513066564139af57e11e2cad1bbd535c1efd224a88a8764c5665e3b
sha256: "14e7822264fbb6842240e09ff32a60e2ca2b1c3207de8adf2f56c149cccd49fd"
url: "https://pub.dev"
source: hosted
version: "1.0.12"
version: "1.0.13"
local_auth_platform_interface:
dependency: transitive
description:
name: local_auth_platform_interface
sha256: fbb6973f2fd088e2677f39a5ab550aa1cfbc00997859d5e865569872499d6d61
sha256: "9e160d59ef0743e35f1b50f4fb84dc64f55676b1b8071e319ef35e7f3bc13367"
url: "https://pub.dev"
source: hosted
version: "1.0.6"
version: "1.0.7"
local_auth_windows:
dependency: transitive
description:
name: local_auth_windows
sha256: "888482e4f9ca3560e00bc227ce2badeb4857aad450c42a31c6cfc9dc21e0ccbc"
sha256: "69c4a6b1201e7b5467e7180c7dd84cf96c308982680cc1778984552bea84b0bc"
url: "https://pub.dev"
source: hosted
version: "1.0.5"
version: "1.0.6"
logging:
dependency: transitive
description:
@ -679,10 +679,10 @@ packages:
dependency: transitive
description:
name: markdown
sha256: c2b81e184067b41d0264d514f7cdaa2c02d38511e39d6521a1ccc238f6d7b3f2
sha256: b3c60dee8c2af50ad0e6e90cceba98e47718a6ee0a7a6772c77846a0cc21f78b
url: "https://pub.dev"
source: hosted
version: "6.0.1"
version: "7.0.1"
matcher:
dependency: transitive
description:
@ -833,26 +833,26 @@ packages:
dependency: transitive
description:
name: path_provider_linux
sha256: ab0987bf95bc591da42dffb38c77398fc43309f0b9b894dcc5d6f40c4b26c379
sha256: "525ad5e07622d19447ad740b1ed5070031f7a5437f44355ae915ff56e986429a"
url: "https://pub.dev"
source: hosted
version: "2.1.7"
version: "2.1.9"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: f0abc8ebd7253741f05488b4813d936b4d07c6bae3e86148a09e342ee4b08e76
sha256: "57585299a729335f1298b43245842678cb9f43a6310351b18fb577d6e33165ec"
url: "https://pub.dev"
source: hosted
version: "2.0.5"
version: "2.0.6"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bcabbe399d4042b8ee687e17548d5d3f527255253b4a639f5f8d2094a9c2b45c
sha256: "642ddf65fde5404f83267e8459ddb4556316d3ee6d511ed193357e25caa3632d"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
version: "2.1.4"
pdf:
dependency: "direct main"
description:
@ -937,10 +937,10 @@ packages:
dependency: transitive
description:
name: plugin_platform_interface
sha256: dbf0f707c78beedc9200146ad3cb0ab4d5da13c246336987be6940f026500d3a
sha256: "6a2128648c854906c53fa8e33986fc0247a1116122f9534dd20e3ab9e16a32bc"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
version: "2.1.4"
polylabel:
dependency: transitive
description:
@ -1073,58 +1073,58 @@ packages:
dependency: "direct main"
description:
name: shared_preferences
sha256: "5949029e70abe87f75cfe59d17bf5c397619c4b74a099b10116baeb34786fad9"
sha256: ee6257848f822b8481691f20c3e6d2bfee2e9eccb2a3d249907fcfb198c55b41
url: "https://pub.dev"
source: hosted
version: "2.0.17"
version: "2.0.18"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "955e9736a12ba776bdd261cf030232b30eadfcd9c79b32a3250dd4a494e8c8f7"
sha256: a51a4f9375097f94df1c6e0a49c0374440d31ab026b59d58a7e7660675879db4
url: "https://pub.dev"
source: hosted
version: "2.0.15"
version: "2.0.16"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "2b55c18636a4edc529fa5cd44c03d3f3100c00513f518c5127c951978efcccd0"
sha256: "6b84fdf06b32bb336f972d373cd38b63734f3461ba56ac2ba01b56d052796259"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
version: "2.1.4"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: f8ea038aa6da37090093974ebdcf4397010605fd2ff65c37a66f9d28394cb874
sha256: d7fb71e6e20cd3dfffcc823a28da3539b392e53ed5fc5c2b90b55fdaa8a7e8fa
url: "https://pub.dev"
source: hosted
version: "2.1.3"
version: "2.1.4"
shared_preferences_platform_interface:
dependency: "direct dev"
description:
name: shared_preferences_platform_interface
sha256: da9431745ede5ece47bc26d5d73a9d3c6936ef6945c101a5aca46f62e52c1cf3
sha256: "824bfd02713e37603b2bdade0842e47d56e7db32b1dcdd1cae533fb88e2913fc"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
version: "2.1.1"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: a4b5bc37fe1b368bbc81f953197d55e12f49d0296e7e412dfe2d2d77d6929958
sha256: "6737b757e49ba93de2a233df229d0b6a87728cea1684da828cbc718b65dcf9d7"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
version: "2.0.5"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "5eaf05ae77658d3521d0e993ede1af962d4b326cd2153d312df716dc250f00c9"
sha256: bd014168e8484837c39ef21065b78f305810ceabc1d4f90be6e3b392ce81b46d
url: "https://pub.dev"
source: hosted
version: "2.1.3"
version: "2.1.4"
shelf:
dependency: transitive
description:
@ -1166,10 +1166,10 @@ packages:
dependency: "direct main"
description:
name: smooth_page_indicator
sha256: "49e9b6a265790454c39bd4a447a02f398c02b44b2602e7c5e3a381dc2e3b4102"
sha256: "8c301bc686892306cd41672c1880167f140c16be305d5ede8201fefd9fcda829"
url: "https://pub.dev"
source: hosted
version: "1.0.0+2"
version: "1.0.1"
source_map_stack_trace:
dependency: transitive
description:
@ -1182,10 +1182,10 @@ packages:
dependency: transitive
description:
name: source_maps
sha256: "490098075234dcedb83c5d949b4c93dad5e6b7702748de000be2b57b8e6b2427"
sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703"
url: "https://pub.dev"
source: hosted
version: "0.10.11"
version: "0.10.12"
source_span:
dependency: transitive
description:
@ -1303,10 +1303,10 @@ packages:
dependency: "direct main"
description:
name: transparent_image
sha256: e566a616922a781489f4d91cc939b1b3203b6e4a093317805f2f82f0bb0f8dec
sha256: e8991d955a2094e197ca24c645efec2faf4285772a4746126ca12875e54ca02f
url: "https://pub.dev"
source: hosted
version: "2.0.0"
version: "2.0.1"
tuple:
dependency: "direct main"
description:
@ -1335,66 +1335,66 @@ packages:
dependency: "direct main"
description:
name: url_launcher
sha256: e8f2efc804810c0f2f5b485f49e7942179f56eabcfe81dce3387fec4bb55876b
sha256: "75f2846facd11168d007529d6cd8fcb2b750186bea046af9711f10b907e1587e"
url: "https://pub.dev"
source: hosted
version: "6.1.9"
version: "6.1.10"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "3e2f6dfd2c7d9cd123296cab8ef66cfc2c1a13f5845f42c7a0f365690a8a7dd1"
sha256: "1f4d9ebe86f333c15d318f81dcdc08b01d45da44af74552608455ebdc08d9732"
url: "https://pub.dev"
source: hosted
version: "6.0.23"
version: "6.0.24"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "0a5af0aefdd8cf820dd739886efb1637f1f24489900204f50984634c07a54815"
sha256: c9cd648d2f7ab56968e049d4e9116f96a85517f1dd806b96a86ea1018a3a82e5
url: "https://pub.dev"
source: hosted
version: "6.1.0"
version: "6.1.1"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: "318c42cba924e18180c029be69caf0a1a710191b9ec49bb42b5998fdcccee3cc"
sha256: e29039160ab3730e42f3d811dc2a6d5f2864b90a70fb765ea60144b03307f682
url: "https://pub.dev"
source: hosted
version: "3.0.2"
version: "3.0.3"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "41988b55570df53b3dd2a7fc90c76756a963de6a8c5f8e113330cb35992e2094"
sha256: "2dddb3291a57b074dade66b5e07e64401dd2487caefd4e9e2f467138d8c7eb06"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
version: "3.0.3"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "4eae912628763eb48fc214522e58e942fd16ce195407dbf45638239523c759a6"
sha256: "6c9ca697a5ae218ce56cece69d46128169a58aa8653c1b01d26fcd4aad8c4370"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
version: "2.1.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "44d79408ce9f07052095ef1f9a693c258d6373dc3944249374e30eff7219ccb0"
sha256: "574cfbe2390666003c3a1d129bdc4574aaa6728f0c00a4829a81c316de69dd9b"
url: "https://pub.dev"
source: hosted
version: "2.0.14"
version: "2.0.15"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: b6217370f8eb1fd85c8890c539f5a639a01ab209a36db82c921ebeacefc7a615
sha256: "97c9067950a0d09cbd93e2e3f0383d1403989362b97102fbf446473a48079a4b"
url: "https://pub.dev"
source: hosted
version: "3.0.3"
version: "3.0.4"
vector_math:
dependency: transitive
description:
@ -1471,10 +1471,10 @@ packages:
dependency: transitive
description:
name: xdg_directories
sha256: bd512f03919aac5f1313eb8249f223bacf4927031bf60b02601f81f687689e86
sha256: ee1505df1426458f7f60aac270645098d318a8b4766d85fde75f76f2e21807d1
url: "https://pub.dev"
source: hosted
version: "0.2.0+3"
version: "1.0.0"
xml:
dependency: "direct main"
description:
@ -1493,4 +1493,4 @@ packages:
version: "3.1.1"
sdks:
dart: ">=2.19.0 <3.0.0"
flutter: ">=3.7.3"
flutter: ">=3.7.5"

View file

@ -7,13 +7,13 @@ repository: https://github.com/deckerst/aves
# - play changelog: /whatsnew/whatsnew-en-US
# - izzy changelog: /fastlane/metadata/android/en-US/changelogs/XX01.txt
# - libre changelog: /fastlane/metadata/android/en-US/changelogs/XX.txt
version: 1.8.1+92
version: 1.8.2+93
publish_to: none
environment:
# this project bundles Flutter SDK via `flutter_wrapper`
# cf https://github.com/passsy/flutter_wrapper
flutter: 3.7.3
flutter: 3.7.5
sdk: ">=2.19.0 <3.0.0"
# following https://github.blog/2021-09-01-improving-git-protocol-security-github/
@ -89,7 +89,11 @@ dependencies:
provider:
screen_brightness:
screen_state:
shared_preferences:
# as of `shared_preferences` v2.0.18, upgrading packages downgrades `shared_preferences` to v0.5.4+6
# because its dependency `shared_preferences_windows` v2.1.4 gets removed
# because its dependency `path_provider_windows` v2.1.4 gets removed
# so that the transitive `win32` gets upgraded to v4.x.x
shared_preferences: ">=2.0.0"
smooth_page_indicator:
sqflite:
streams_channel:

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
In v1.8.1:
- Android TV support (cont'd)
- hide your secrets in vaults
- enjoy the app in Basque
In v1.8.2:
- write metadata when converting
- convert many items at once
- list places in their own page
Full changelog available on GitHub