Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2022-01-30 10:47:40 +09:00
commit 8e3cfe863d
231 changed files with 5216 additions and 1315 deletions

4
.gitignore vendored
View file

@ -44,3 +44,7 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
# screenshot generation
/test_driver/assets/screenshots/
/screenshots/

View file

@ -4,6 +4,29 @@ All notable changes to this project will be documented in this file.
## <a id="unreleased"></a>[Unreleased]
## <a id="v1.5.11"></a>[v1.5.11] - 2022-01-30
### Added
- Collection / Info: edit location of JPG/PNG/WEBP/DNG images via Exif
- Viewer: resize option when exporting
- Settings: export/import covers & favourites along with settings
- Collection: allow rescan when browsing
- support Android 12L (API 32)
- Portuguese translation (thanks Jonatas De Almeida Barros)
### Removed
- new version check
### Fixed
- loading when system locale uses non-western arabic numerals
- handling timestamps provided in 10^-8 s (18 digits)
- Viewer: SVG export
- Viewer: sending to editing app on some environments
- Map: projected center anchoring
## <a id="v1.5.10"></a>[v1.5.10] - 2022-01-07
### Added

View file

@ -29,11 +29,37 @@ It scans your media collection to identify **motion photos**, **panoramas** (aka
**Navigation and search** is an important part of Aves. The goal is for users to easily flow from albums to photos to tags to maps, etc.
Aves integrates with Android (from **API 19 to 31**, i.e. from KitKat to S) with features such as **app shortcuts** and **global search** handling. It also works as a **media viewer and picker**.
Aves integrates with Android (from **API 19 to 32**, i.e. from KitKat to Android 12L) with features such as **app shortcuts** and **global search** handling. It also works as a **media viewer and picker**.
## Screenshots
<img src="https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/S10/1-S10-collection.png" alt='Collection screenshot' height="400" /><img src="https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/S10/2-S10-viewer.png" alt='Image screenshot' height="400" /><img src="https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/S10/5-S10-stats.png" alt='Stats screenshot' height="400" /><img src="https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/S10/3-S10-info__basic_.png" alt='Info (basic) screenshot' height="400" /><img src="https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/S10/4-S10-info__metadata_.png" alt='Info (metadata) screenshot' height="400" /><img src="https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/S10/6-S10-countries.png" alt='Countries screenshot' height="400" />
<div align="center">
[<img src="https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/readme/en/1.png"
alt='Collection screenshot'
width="130" />](https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/play/en/1.png)
[<img
src="https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/readme/en/2.png"
alt='Image screenshot'
width="130" />](https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/play/en/2.png)
[<img
src="https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/readme/en/5.png"
alt='Stats screenshot'
width="130" />](https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/play/en/5.png)
[<img
src="https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/readme/en/3.png"
alt='Info (basic) screenshot'
width="130" />](https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/play/en/3.png)
[<img
src="https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/readme/en/4.png"
alt='Info (metadata) screenshot'
width="130" />](https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/play/en/4.png)
[<img
src="https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/readme/en/6.png"
alt='Countries screenshot'
width="130" />](https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/play/en/6.png)
<div align="left">
## Changelog
@ -59,7 +85,7 @@ At this stage this project does *not* accept PRs, except for translations.
### Translations
If you want to translate this app in your language and share the result, feel free to open a PR or send the translation by [email](mailto:gallery.aves@gmail.com). You can find some localization notes in [pubspec.yaml](https://github.com/deckerst/aves/blob/develop/pubspec.yaml). English, Korean and French are already handled by me. Russian, German and Spanish are handled by generous volunteers.
If you want to translate this app in your language and share the result, [there is a guide](https://github.com/deckerst/aves/wiki/Contributing-to-Translations). English, Korean and French are already handled by me. Russian, German and Spanish are handled by generous volunteers.
### Donations

View file

@ -41,15 +41,12 @@ if (keystorePropertiesFile.exists()) {
}
android {
compileSdkVersion 31
compileSdkVersion 32
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
lintOptions {
disable 'InvalidPackage'
}
defaultConfig {
applicationId appId
@ -60,7 +57,7 @@ android {
// which implementation `DocumentBuilderImpl` is provided by the OS and is not customizable on Android,
// but the implementation on API <19 is not robust enough and fails to build XMP documents
minSdkVersion 19
targetSdkVersion 31
targetSdkVersion 32
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
manifestPlaceholders = [googleApiKey: keystoreProperties['googleApiKey']]
@ -129,6 +126,9 @@ android {
}
}
}
lint {
disable 'InvalidPackage'
}
}
flutter {

View file

@ -143,6 +143,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
.submit(size, size)
try {
@Suppress("BlockingMethodInNonBlockingContext")
data = target.get()?.getBytes(canHaveAlpha = true, recycle = false)
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to decode app icon for packageName=$packageName", e)
@ -312,7 +313,16 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
context.startActivity(Intent.createChooser(intent, title))
return true
} catch (e: SecurityException) {
Log.w(LOG_TAG, "failed to start activity chooser for intent=$intent", e)
if (intent.flags and Intent.FLAG_GRANT_WRITE_URI_PERMISSION != 0) {
// in some environments, providing the write flag yields a `SecurityException`:
// "UID XXXX does not have permission to content://XXXX"
// so we retry without it
Log.i(LOG_TAG, "retry intent=$intent without FLAG_GRANT_WRITE_URI_PERMISSION")
intent.flags = intent.flags and Intent.FLAG_GRANT_WRITE_URI_PERMISSION.inv()
return safeStartActivityChooser(title, intent)
} else {
Log.w(LOG_TAG, "failed to start activity chooser for intent=$intent", e)
}
}
return false
}

View file

@ -9,6 +9,7 @@ import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.io.IOException
import java.util.*
// as of 2021/03/10, geocoding packages exist but:
@ -50,6 +51,10 @@ class GeocodingHandler(private val context: Context) : MethodCallHandler {
val addresses = try {
geocoder!!.getFromLocation(latitude, longitude, maxResults) ?: ArrayList()
} catch (e: IOException) {
// `grpc failed`, etc.
result.error("getAddress-network", "failed to get address because of network issues", e.message)
return
} catch (e: Exception) {
result.error("getAddress-exception", "failed to get address", e.message)
return

View file

@ -13,7 +13,7 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.signature.ObjectKey
import deckers.thibault.aves.decoder.MultiTrackImage
import deckers.thibault.aves.decoder.SvgThumbnail
import deckers.thibault.aves.decoder.SvgImage
import deckers.thibault.aves.decoder.TiffImage
import deckers.thibault.aves.decoder.VideoThumbnail
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
@ -128,7 +128,7 @@ class ThumbnailFetcher internal constructor(
.submit(width, height)
} else {
val model: Any = when {
svgFetch -> SvgThumbnail(context, uri)
svgFetch -> SvgImage(context, uri)
tiffFetch -> TiffImage(context, uri, pageId)
multiTrackFetch -> MultiTrackImage(context, uri, pageId)
else -> StorageUtils.getGlideSafeUri(uri, mimeType)

View file

@ -134,8 +134,10 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
var destinationDir = arguments["destinationPath"] as String?
val mimeType = arguments["mimeType"] as String?
val width = arguments["width"] as Int?
val height = arguments["height"] as Int?
val nameConflictStrategy = NameConflictStrategy.get(arguments["nameConflictStrategy"] as String?)
if (destinationDir == null || mimeType == null || nameConflictStrategy == null) {
if (destinationDir == null || mimeType == null || width == null || height == null || nameConflictStrategy == null) {
error("export-args", "failed because of missing arguments", null)
return
}
@ -150,7 +152,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
val entries = entryMapList.map(::AvesEntry)
provider.exportMultiple(activity, mimeType, destinationDir, entries, nameConflictStrategy, object : ImageOpCallback {
provider.exportMultiple(activity, mimeType, destinationDir, entries, width, height, nameConflictStrategy, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = success(fields)
override fun onFailure(throwable: Throwable) = error("export-failure", "failed to export entries", throwable)
})

View file

@ -25,27 +25,27 @@ import kotlin.math.ceil
@GlideModule
class SvgGlideModule : LibraryGlideModule() {
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
registry.append(SvgThumbnail::class.java, Bitmap::class.java, SvgLoader.Factory())
registry.append(SvgImage::class.java, Bitmap::class.java, SvgLoader.Factory())
}
}
class SvgThumbnail(val context: Context, val uri: Uri)
class SvgImage(val context: Context, val uri: Uri)
internal class SvgLoader : ModelLoader<SvgThumbnail, Bitmap> {
override fun buildLoadData(model: SvgThumbnail, width: Int, height: Int, options: Options): ModelLoader.LoadData<Bitmap> {
internal class SvgLoader : ModelLoader<SvgImage, Bitmap> {
override fun buildLoadData(model: SvgImage, width: Int, height: Int, options: Options): ModelLoader.LoadData<Bitmap> {
return ModelLoader.LoadData(ObjectKey(model.uri), SvgFetcher(model, width, height))
}
override fun handles(model: SvgThumbnail): Boolean = true
override fun handles(model: SvgImage): Boolean = true
internal class Factory : ModelLoaderFactory<SvgThumbnail, Bitmap> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<SvgThumbnail, Bitmap> = SvgLoader()
internal class Factory : ModelLoaderFactory<SvgImage, Bitmap> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<SvgImage, Bitmap> = SvgLoader()
override fun teardown() {}
}
}
internal class SvgFetcher(val model: SvgThumbnail, val width: Int, val height: Int) : DataFetcher<Bitmap> {
internal class SvgFetcher(val model: SvgImage, val width: Int, val height: Int) : DataFetcher<Bitmap> {
override fun loadData(priority: Priority, callback: DataFetcher.DataCallback<in Bitmap>) {
val context = model.context
val uri = model.uri

View file

@ -220,7 +220,7 @@ object ExifInterfaceHelper {
// initialize metadata-extractor directories that we will fill
// by tags converted from the ExifInterface attributes
// so that we can rely on metadata-extractor descriptions
val dirs = DirType.values().map { Pair(it, it.createDirectory()) }.toMap()
val dirs = DirType.values().associate { Pair(it, it.createDirectory()) }
// exclude Exif directory when it only includes image size
val isUselessExif = fun(it: Map<String, String>): Boolean {

View file

@ -102,8 +102,8 @@ object MediaMetadataRetrieverHelper {
val symbol = "bit/s"
if (size < divider) return "$size $symbol"
if (size < divider * divider) return "${String.format("%.2f", size.toDouble() / divider)} K$symbol"
return "${String.format("%.2f", size.toDouble() / divider / divider)} M$symbol"
if (size < divider * divider) return "${String.format(Locale.getDefault(), "%.2f", size.toDouble() / divider)} K$symbol"
return "${String.format(Locale.getDefault(), "%.2f", size.toDouble() / divider / divider)} M$symbol"
}
fun MediaMetadataRetriever.getSafeDescription(tag: Int, save: (value: String) -> Unit) {

View file

@ -6,7 +6,6 @@ import com.drew.lang.Rational
import com.drew.lang.SequentialByteArrayReader
import com.drew.metadata.Directory
import com.drew.metadata.exif.ExifDirectoryBase
import com.drew.metadata.exif.ExifIFD0Directory
import com.drew.metadata.exif.ExifReader
import com.drew.metadata.iptc.IptcReader
import com.drew.metadata.png.PngDirectory

View file

@ -13,9 +13,11 @@ import androidx.exifinterface.media.ExifInterface
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.FutureTarget
import com.bumptech.glide.request.RequestOptions
import com.commonsware.cwac.document.DocumentFileCompat
import deckers.thibault.aves.decoder.MultiTrackImage
import deckers.thibault.aves.decoder.SvgImage
import deckers.thibault.aves.decoder.TiffImage
import deckers.thibault.aves.metadata.*
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
@ -35,6 +37,7 @@ import deckers.thibault.aves.utils.MimeTypes.isVideo
import java.io.ByteArrayInputStream
import java.io.File
import java.io.IOException
import java.io.OutputStream
import java.util.*
import kotlin.collections.HashMap
@ -82,6 +85,8 @@ abstract class ImageProvider {
imageExportMimeType: String,
targetDir: String,
entries: List<AvesEntry>,
width: Int,
height: Int,
nameConflictStrategy: NameConflictStrategy,
callback: ImageOpCallback,
) {
@ -95,12 +100,6 @@ abstract class ImageProvider {
return
}
// TODO TLAD [storage] allow inserting by Media Store
if (targetDirDocFile == null) {
callback.onFailure(Exception("failed to get tree doc for directory at path=$targetDir"))
return
}
for (entry in entries) {
val sourceUri = entry.uri
val sourcePath = entry.path
@ -115,11 +114,13 @@ abstract class ImageProvider {
val sourceMimeType = entry.mimeType
val exportMimeType = if (isVideo(sourceMimeType)) sourceMimeType else imageExportMimeType
try {
val newFields = exportSingleByTreeDocAndScan(
val newFields = exportSingle(
activity = activity,
sourceEntry = entry,
targetDir = targetDir,
targetDirDocFile = targetDirDocFile,
width = width,
height = height,
nameConflictStrategy = nameConflictStrategy,
exportMimeType = exportMimeType,
)
@ -133,11 +134,13 @@ abstract class ImageProvider {
}
@Suppress("BlockingMethodInNonBlockingContext")
private suspend fun exportSingleByTreeDocAndScan(
private suspend fun exportSingle(
activity: Activity,
sourceEntry: AvesEntry,
targetDir: String,
targetDirDocFile: DocumentFileCompat,
targetDirDocFile: DocumentFileCompat?,
width: Int,
height: Int,
nameConflictStrategy: NameConflictStrategy,
exportMimeType: String,
): FieldMap {
@ -163,44 +166,46 @@ abstract class ImageProvider {
conflictStrategy = nameConflictStrategy,
) ?: return skippedFieldMap
// the file created from a `TreeDocumentFile` is also a `TreeDocumentFile`
// but in order to open an output stream to it, we need to use a `SingleDocumentFile`
// through a document URI, not a tree URI
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
val targetTreeFile = targetDirDocFile.createFile(exportMimeType, targetNameWithoutExtension)
val targetDocFile = DocumentFileCompat.fromSingleUri(activity, targetTreeFile.uri)
if (isVideo(sourceMimeType)) {
val sourceDocFile = DocumentFileCompat.fromSingleUri(activity, sourceUri)
sourceDocFile.copyTo(targetDocFile)
} else {
val model: Any = if (MimeTypes.isHeic(sourceMimeType) && pageId != null) {
MultiTrackImage(activity, sourceUri, pageId)
} else if (sourceMimeType == MimeTypes.TIFF) {
TiffImage(activity, sourceUri, pageId)
val targetMimeType: String
val write: (OutputStream) -> Unit
var target: FutureTarget<Bitmap>? = null
try {
if (isVideo(sourceMimeType)) {
targetMimeType = sourceMimeType
write = { output ->
val sourceDocFile = DocumentFileCompat.fromSingleUri(activity, sourceUri)
sourceDocFile.copyTo(output)
}
} else {
StorageUtils.getGlideSafeUri(sourceUri, sourceMimeType)
}
val model: Any = if (MimeTypes.isHeic(sourceMimeType) && pageId != null) {
MultiTrackImage(activity, sourceUri, pageId)
} else if (sourceMimeType == MimeTypes.TIFF) {
TiffImage(activity, sourceUri, pageId)
} else if (sourceMimeType == MimeTypes.SVG) {
SvgImage(activity, sourceUri)
} else {
StorageUtils.getGlideSafeUri(sourceUri, sourceMimeType)
}
// request a fresh image with the highest quality format
val glideOptions = RequestOptions()
.format(DecodeFormat.PREFER_ARGB_8888)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.skipMemoryCache(true)
// request a fresh image with the highest quality format
val glideOptions = RequestOptions()
.format(DecodeFormat.PREFER_ARGB_8888)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.skipMemoryCache(true)
val target = Glide.with(activity)
.asBitmap()
.apply(glideOptions)
.load(model)
.submit()
try {
target = Glide.with(activity)
.asBitmap()
.apply(glideOptions)
.load(model)
.submit(width, height)
var bitmap = target.get()
if (MimeTypes.needRotationAfterGlide(sourceMimeType)) {
bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped)
}
bitmap ?: throw Exception("failed to get image for mimeType=$sourceMimeType uri=$sourceUri page=$pageId")
targetDocFile.openOutputStream().use { output ->
targetMimeType = exportMimeType
write = { output ->
if (exportMimeType == MimeTypes.BMP) {
BmpWriter.writeRGB24(bitmap, output)
} else {
@ -223,21 +228,23 @@ abstract class ImageProvider {
bitmap.compress(format, quality, output)
}
}
} catch (e: Exception) {
// remove empty file
if (targetDocFile.exists()) {
targetDocFile.delete()
}
throw e
} finally {
Glide.with(activity).clear(target)
}
val mediaStoreImageProvider = MediaStoreImageProvider()
val targetPath = mediaStoreImageProvider.createSingle(
activity = activity,
mimeType = targetMimeType,
targetDir = targetDir,
targetDirDocFile = targetDirDocFile,
targetNameWithoutExtension = targetNameWithoutExtension,
write = write,
)
return mediaStoreImageProvider.scanNewPath(activity, targetPath, exportMimeType)
} finally {
// clearing Glide target should happen after effectively writing the bitmap
Glide.with(activity).clear(target)
}
val fileName = targetDocFile.name
val targetFullPath = targetDir + fileName
return MediaStoreImageProvider().scanNewPath(activity, targetFullPath, exportMimeType)
}
@Suppress("BlockingMethodInNonBlockingContext")
@ -808,6 +815,55 @@ abstract class ImageProvider {
modifier: FieldMap,
callback: ImageOpCallback,
) {
if (modifier.containsKey("exif")) {
val fields = modifier["exif"] as Map<*, *>?
if (fields != null && fields.isNotEmpty()) {
if (!editExif(context, path, uri, mimeType, callback) { exif ->
var setLocation = false
fields.forEach { kv ->
val tag = kv.key as String?
if (tag != null) {
val value = kv.value
if (value == null) {
// remove attribute
exif.setAttribute(tag, value)
} else {
when (tag) {
ExifInterface.TAG_GPS_LATITUDE,
ExifInterface.TAG_GPS_LATITUDE_REF,
ExifInterface.TAG_GPS_LONGITUDE,
ExifInterface.TAG_GPS_LONGITUDE_REF -> {
setLocation = true
}
else -> {
if (value is String) {
exif.setAttribute(tag, value)
} else {
Log.w(LOG_TAG, "failed to set Exif attribute $tag because value=$value is not a string")
}
}
}
}
}
}
if (setLocation) {
val latAbs = (fields[ExifInterface.TAG_GPS_LATITUDE] as Number?)?.toDouble()
val latRef = fields[ExifInterface.TAG_GPS_LATITUDE_REF] as String?
val lngAbs = (fields[ExifInterface.TAG_GPS_LONGITUDE] as Number?)?.toDouble()
val lngRef = fields[ExifInterface.TAG_GPS_LONGITUDE_REF] as String?
if (latAbs != null && latRef != null && lngAbs != null && lngRef != null) {
val latitude = if (latRef == ExifInterface.LATITUDE_SOUTH) -latAbs else latAbs
val longitude = if (lngRef == ExifInterface.LONGITUDE_WEST) -lngAbs else lngAbs
exif.setLatLong(latitude, longitude)
} else {
Log.w(LOG_TAG, "failed to set Exif location with latAbs=$latAbs, latRef=$latRef, lngAbs=$lngAbs, lngRef=$lngRef")
}
}
exif.saveAttributes()
}) return
}
}
if (modifier.containsKey("iptc")) {
val iptc = (modifier["iptc"] as List<*>?)?.filterIsInstance<FieldMap>()
if (!editIptc(

View file

@ -28,6 +28,7 @@ import deckers.thibault.aves.utils.StorageUtils
import deckers.thibault.aves.utils.StorageUtils.PathSegments
import deckers.thibault.aves.utils.UriUtils.tryParseId
import java.io.File
import java.io.OutputStream
import java.util.*
import java.util.concurrent.CompletableFuture
import kotlin.collections.ArrayList
@ -414,34 +415,39 @@ class MediaStoreImageProvider : ImageProvider() {
conflictStrategy = nameConflictStrategy,
) ?: return skippedFieldMap
return moveSingleByTreeDoc(
val sourceDocFile = DocumentFileCompat.fromSingleUri(activity, sourceUri)
val targetPath = createSingle(
activity = activity,
mimeType = mimeType,
sourceUri = sourceUri,
sourcePath = sourcePath,
targetDir = targetDir,
targetDirDocFile = targetDirDocFile,
targetNameWithoutExtension = targetNameWithoutExtension,
copy = copy
)
) { output: OutputStream -> sourceDocFile.copyTo(output) }
if (!copy) {
// delete original entry
try {
delete(activity, sourceUri, sourcePath, mimeType)
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to delete entry with path=$sourcePath", e)
}
}
return scanNewPath(activity, targetPath, mimeType)
}
private suspend fun moveSingleByTreeDoc(
// `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,
sourceUri: Uri,
sourcePath: String,
targetDir: String,
targetDirDocFile: DocumentFileCompat?,
targetNameWithoutExtension: String,
copy: Boolean
): FieldMap {
// `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`
val source = DocumentFileCompat.fromSingleUri(activity, sourceUri)
val targetPath = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && isDownloadDir(activity, targetDir)) {
write: (OutputStream) -> Unit,
): String {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && isDownloadDir(activity, targetDir)) {
val targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType)}"
val values = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, targetFileName)
@ -451,10 +457,7 @@ class MediaStoreImageProvider : ImageProvider() {
val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
uri?.let {
@Suppress("BlockingMethodInNonBlockingContext")
resolver.openOutputStream(uri)?.use { output ->
source.copyTo(output)
}
resolver.openOutputStream(uri)?.use(write)
values.clear()
values.put(MediaStore.MediaColumns.IS_PENDING, 0)
resolver.update(uri, values, null, null)
@ -468,12 +471,18 @@ class MediaStoreImageProvider : ImageProvider() {
// but in order to open an output stream to it, we need to use a `SingleDocumentFile`
// through a document URI, not a tree URI
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
@Suppress("BlockingMethodInNonBlockingContext")
val targetTreeFile = targetDirDocFile.createFile(mimeType, targetNameWithoutExtension)
val targetDocFile = DocumentFileCompat.fromSingleUri(activity, targetTreeFile.uri)
@Suppress("BlockingMethodInNonBlockingContext")
source.copyTo(targetDocFile)
try {
targetDocFile.openOutputStream().use(write)
} catch (e: Exception) {
// remove empty file
if (targetDocFile.exists()) {
targetDocFile.delete()
}
throw e
}
// the source file name and the created document file name can be different when:
// - a file with the same name already exists, some implementations give a suffix like ` (1)`, some *do not*
@ -481,17 +490,6 @@ class MediaStoreImageProvider : ImageProvider() {
val fileName = targetDocFile.name
targetDir + fileName
}
if (!copy) {
// delete original entry
try {
delete(activity, sourceUri, sourcePath, mimeType)
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to delete entry with path=$sourcePath", e)
}
}
return scanNewPath(activity, targetPath, mimeType)
}
private fun isDownloadDir(context: Context, dirPath: String): Boolean {

View file

@ -80,77 +80,87 @@ object StorageUtils {
return pathSteps.iterator()
}
private fun appSpecificVolumePath(file: File?): String? {
file ?: return null
val appSpecificPath = file.absolutePath
val relativePathStartIndex = appSpecificPath.indexOf("Android/data")
if (relativePathStartIndex < 0) return null
return appSpecificPath.substring(0, relativePathStartIndex)
}
private fun findPrimaryVolumePath(context: Context): String? {
// we want:
// /storage/emulated/0/
// `Environment.getExternalStorageDirectory()` (deprecated) yields:
// /storage/emulated/0
// `context.getExternalFilesDir(null)` yields:
// /storage/emulated/0/Android/data/{package_name}/files
return context.getExternalFilesDir(null)?.let {
val appSpecificPath = it.absolutePath
return appSpecificPath.substring(0, appSpecificPath.indexOf("Android/data"))
try {
// we want:
// /storage/emulated/0/
// `Environment.getExternalStorageDirectory()` (deprecated) yields:
// /storage/emulated/0
// `context.getExternalFilesDir(null)` yields:
// /storage/emulated/0/Android/data/{package_name}/files
return appSpecificVolumePath(context.getExternalFilesDir(null))
} catch (e: Exception) {
Log.e(LOG_TAG, "failed to find primary volume path", e)
}
return null
}
private fun findVolumePaths(context: Context): Array<String> {
// Final set of paths
val paths = HashSet<String>()
// Primary emulated SD-CARD
val rawEmulatedStorageTarget = System.getenv("EMULATED_STORAGE_TARGET") ?: ""
if (TextUtils.isEmpty(rawEmulatedStorageTarget)) {
// fix of empty raw emulated storage on marshmallow
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
lateinit var files: List<File>
var validFiles: Boolean
do {
// `getExternalFilesDirs` sometimes include `null` when called right after getting read access
// (e.g. on API 30 emulator) so we retry until the file system is ready
val externalFilesDirs = context.getExternalFilesDirs(null)
validFiles = !externalFilesDirs.contains(null)
if (validFiles) {
files = externalFilesDirs.filterNotNull()
} else {
try {
Thread.sleep(100)
} catch (e: InterruptedException) {
Log.e(LOG_TAG, "insomnia", e)
try {
// Primary emulated SD-CARD
val rawEmulatedStorageTarget = System.getenv("EMULATED_STORAGE_TARGET") ?: ""
if (TextUtils.isEmpty(rawEmulatedStorageTarget)) {
// fix of empty raw emulated storage on marshmallow
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
lateinit var files: List<File>
var validFiles: Boolean
do {
// `getExternalFilesDirs` sometimes include `null` when called right after getting read access
// (e.g. on API 30 emulator) so we retry until the file system is ready
val externalFilesDirs = context.getExternalFilesDirs(null)
validFiles = !externalFilesDirs.contains(null)
if (validFiles) {
files = externalFilesDirs.filterNotNull()
} else {
try {
Thread.sleep(100)
} catch (e: InterruptedException) {
Log.e(LOG_TAG, "insomnia", e)
}
}
}
} while (!validFiles)
for (file in files) {
val appSpecificAbsolutePath = file.absolutePath
val emulatedRootPath = appSpecificAbsolutePath.substring(0, appSpecificAbsolutePath.indexOf("Android/data"))
paths.add(emulatedRootPath)
}
} else {
// Primary physical SD-CARD (not emulated)
val rawExternalStorage = System.getenv("EXTERNAL_STORAGE") ?: ""
// Device has physical external storage; use plain paths.
if (TextUtils.isEmpty(rawExternalStorage)) {
// EXTERNAL_STORAGE undefined; falling back to default.
paths.addAll(physicalPaths)
} while (!validFiles)
paths.addAll(files.mapNotNull(::appSpecificVolumePath))
} else {
paths.add(rawExternalStorage)
// Primary physical SD-CARD (not emulated)
val rawExternalStorage = System.getenv("EXTERNAL_STORAGE") ?: ""
// Device has physical external storage; use plain paths.
if (TextUtils.isEmpty(rawExternalStorage)) {
// EXTERNAL_STORAGE undefined; falling back to default.
paths.addAll(physicalPaths)
} else {
paths.add(rawExternalStorage)
}
}
} else {
// Device has emulated storage; external storage paths should have userId burned into them.
// /storage/emulated/[0,1,2,...]/
val path = getPrimaryVolumePath(context)
val rawUserId = path.split(File.separator).lastOrNull(String::isNotEmpty)?.takeIf { TextUtils.isDigitsOnly(it) } ?: ""
if (rawUserId.isEmpty()) {
paths.add(rawEmulatedStorageTarget)
} else {
paths.add(rawEmulatedStorageTarget + File.separator + rawUserId)
}
}
} else {
// Device has emulated storage; external storage paths should have userId burned into them.
// /storage/emulated/[0,1,2,...]/
val path = getPrimaryVolumePath(context)
val rawUserId = path.split(File.separator).lastOrNull(String::isNotEmpty)?.takeIf { TextUtils.isDigitsOnly(it) } ?: ""
if (rawUserId.isEmpty()) {
paths.add(rawEmulatedStorageTarget)
} else {
paths.add(rawEmulatedStorageTarget + File.separator + rawUserId)
}
}
// All Secondary SD-CARDs (all exclude primary) separated by ":"
System.getenv("SECONDARY_STORAGE")?.let { secondaryStorages ->
paths.addAll(secondaryStorages.split(File.pathSeparator).filter { it.isNotEmpty() })
// All Secondary SD-CARDs (all exclude primary) separated by ":"
System.getenv("SECONDARY_STORAGE")?.let { secondaryStorages ->
paths.addAll(secondaryStorages.split(File.pathSeparator).filter { it.isNotEmpty() })
}
} catch (e: Exception) {
Log.e(LOG_TAG, "failed to find volume paths", e)
}
return paths.map { ensureTrailingSeparator(it) }.toTypedArray()
@ -272,7 +282,9 @@ object StorageUtils {
// content://com.android.externalstorage.documents/tree/primary%3A -> /storage/emulated/0/
// content://com.android.externalstorage.documents/tree/10F9-3F13%3APictures -> /storage/10F9-3F13/Pictures/
fun convertTreeUriToDirPath(context: Context, treeUri: Uri): String? {
val encoded = treeUri.toString().substring(TREE_URI_ROOT.length)
val treeUriString = treeUri.toString()
if (treeUriString.length <= TREE_URI_ROOT.length) return null
val encoded = treeUriString.substring(TREE_URI_ROOT.length)
val matcher = TREE_URI_PATH_PATTERN.matcher(Uri.decode(encoded))
with(matcher) {
if (find()) {

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Aves</string>
<string name="search_shortcut_short_label">Procurar</string>
<string name="videos_shortcut_short_label">Vídeos</string>
<string name="analysis_channel_name">Digitalização de mídia</string>
<string name="analysis_service_description">Digitalizar imagens &amp; vídeos</string>
<string name="analysis_notification_default_title">Digitalizando mídia</string>
<string name="analysis_notification_action_stop">Pare</string>
</resources>

View file

@ -6,7 +6,7 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.0.4'
classpath 'com.android.tools.build:gradle:7.1.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// GMS & Firebase Crashlytics are not actually used by all flavors
classpath 'com.google.gms:google-services:4.3.10'

View file

@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-all.zip

View file

@ -2,4 +2,4 @@
<b>Navigation und Suche</b> ist ein wichtiger Bestandteil von <i>Aves</i>. Das Ziel besteht darin, dass Benutzer problemlos von Alben zu Fotos zu Tags zu Karten usw. wechseln können.
<i>Aves</i> lässt sich mit Android (von <b>API 19 bis 31</b>, d. h. von KitKat bis S) mit Funktionen wie <b>App-Verknüpfungen</b> und <b>globaler Suche</b> integrieren. Es funktioniert auch als <b>Medienbetrachter und -auswahl</b>.
<i>Aves</i> lässt sich mit Android (von <b>API 19 bis 32</b>, d. h. von KitKat bis Android 12L) mit Funktionen wie <b>App-Verknüpfungen</b> und <b>globaler Suche</b> integrieren. Es funktioniert auch als <b>Medienbetrachter und -auswahl</b>.

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

View file

@ -0,0 +1,5 @@
In v1.5.10:
- show, search and edit ratings
- add many items to favourites at once
- enjoy the app in Spanish
Full changelog available on GitHub

View file

@ -0,0 +1,5 @@
In v1.5.11:
- edit locations of images
- export SVGs to convert and resize them
- enjoy the app in Portuguese
Full changelog available on GitHub

View file

@ -2,4 +2,4 @@
<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 <b>API 19 to 31</b>, i.e. from KitKat to S) with features such as <b>app shortcuts</b> and <b>global search</b> handling. It also works as a <b>media viewer and picker</b>.
<i>Aves</i> integrates with Android (from <b>API 19 to 32</b>, i.e. from KitKat to Android 12L) with features such as <b>app shortcuts</b> and <b>global search</b> handling. It also works as a <b>media viewer and picker</b>.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 KiB

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 470 KiB

After

Width:  |  Height:  |  Size: 500 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 327 KiB

After

Width:  |  Height:  |  Size: 367 KiB

View file

@ -2,4 +2,4 @@
La <b>navegación y búsqueda</b> son partes importantes de <i>Aves</i>. Su propósito es que los usuarios puedan fácimente ir de álbumes a fotos, etiquetas, mapas, etc.
<i>Aves</i> se integra con Android (desde <b>API 19 a 31</b>, por ej. desde KitKat hasta S) con características como <b>vínculos de aplicación</b> y manejo de <b>búsqueda global</b>. También funciona como un <b>visor y seleccionador multimedia</b>.
<i>Aves</i> se integra con Android (desde <b>API 19 a 32</b>, por ej. desde KitKat hasta Android 12L) con características como <b>vínculos de aplicación</b> y manejo de <b>búsqueda global</b>. También funciona como un <b>visor y seleccionador multimedia</b>.

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 501 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

View file

@ -0,0 +1,5 @@
<i>Aves</i> pode lidar com todos os tipos de imagens e vídeos, incluindo seus típicos JPEGs e MP4s, mas também coisas mais exóticas como <b>páginas múltiplas TIFFs, SVGs, AVIs antigos e muito mais</b>! Ele verifica sua coleção de mídia para identificar <b>fotos em movimento</b>, <b>panoramas</b> (aka photo spheres), <b>vídeos em 360°</b>, assim como <b>GeoTIFF</b> arquivos.
<b>Navegação e pesquisa</b> é uma parte importante do <i>Aves</i>. O objetivo é que os usuários fluam facilmente de álbuns para fotos, etiquetas, mapas, etc.
<i>Aves</i> integra com Android (de <b>API 19 para 32</b>, i.e. de KitKat para Android 12L) com recursos como <b>atalhos de apps</b> e <b>pesquisa global</b> manipulação. Também funciona como um <b>visualizador e selecionador de mídia</b>.

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

View file

@ -0,0 +1 @@
Galeria e explorador de metadados

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

View file

@ -3,10 +3,11 @@
"welcomeMessage": "Willkommen bei Aves",
"welcomeOptional": "Optional",
"welcomeTermsToggle": "Ich stimme den Bedingungen und Konditionen zu",
"itemCount": " {count, plural, =1{1 Element} other{{count} Elemente}}",
"itemCount": "{count, plural, =1{1 Element} other{{count} Elemente}}",
"timeSeconds": " {seconds, plural, =1{1 Sekunde} other{{seconds} Sekunde}}",
"timeMinutes": " {minutes, plural, =1{1 Minute} other{{minutes} Minuten}}",
"timeSeconds": "{seconds, plural, =1{1 Sekunde} other{{seconds} Sekunde}}",
"timeMinutes": "{minutes, plural, =1{1 Minute} other{{minutes} Minuten}}",
"focalLength": "{length} mm",
"applyButtonLabel": "ANWENDEN",
"deleteButtonLabel": "LÖSCHEN",
@ -25,7 +26,7 @@
"actionRemove": "Entfernen",
"resetButtonTooltip": "Zurücksetzen",
"doubleBackExitMessage": "Tippen Sie zum Verlassen erneut auf „Zurück“.",
"doubleBackExitMessage": "Zum Verlassen erneut auf „Zurück“ tippen.",
"sourceStateLoading": "Laden",
"sourceStateCataloguing": "Katalogisierung",
@ -56,7 +57,7 @@
"entryActionViewSource": "Quelle anzeigen",
"entryActionViewMotionPhotoVideo": "Bewegtes Foto öffnen",
"entryActionEdit": "Bearbeiten mit...",
"entryActionOpen": "Öffnen Sie mit...",
"entryActionOpen": "Öffnen mit...",
"entryActionSetAs": "Einstellen als...",
"entryActionOpenMap": "In der Karten-App anzeigen...",
"entryActionRotateScreen": "Bildschirm rotieren",
@ -73,6 +74,7 @@
"videoActionSettings": "Einstellungen",
"entryInfoActionEditDate": "Datum & Uhrzeit bearbeiten",
"entryInfoActionEditLocation": "Standort bearbeiten",
"entryInfoActionEditRating": "Bewertung bearbeiten",
"entryInfoActionEditTags": "Tags bearbeiten",
"entryInfoActionRemoveMetadata": "Metadaten entfernen",
@ -93,7 +95,7 @@
"coordinateFormatDms": "GMS",
"coordinateFormatDecimal": "Dezimalgrad",
"coordinateDms": " {coordinate} {direction}",
"coordinateDms": "{coordinate} {direction}",
"coordinateDmsNorth": "N",
"coordinateDmsSouth": "s",
"coordinateDmsEast": "O",
@ -111,10 +113,10 @@
"mapStyleGoogleTerrain": "Google Maps (Gelände)",
"mapStyleOsmHot": "Humanitäres OSM",
"mapStyleStamenToner": "Stamen Toner (SchwarzWeiß)",
"mapStyleStamenWatercolor": "Stamen Aquarell",
"mapStyleStamenWatercolor": "Stamen Watercolor (Aquarell)",
"nameConflictStrategyRename": "Umbenennen",
"nameConflictStrategyReplace": "Ersetzen Sie",
"nameConflictStrategyReplace": "Ersetzen",
"nameConflictStrategySkip": "Überspringen",
"keepScreenOnNever": "Niemals",
@ -135,16 +137,16 @@
"rootDirectoryDescription": "Hauptverzeichnis",
"otherDirectoryDescription": "„{name}“ Verzeichnis",
"storageAccessDialogTitle": "Speicherzugriff",
"storageAccessDialogMessage": "Bitte wählen Sie den {directory} von „{volume}“ auf dem nächsten Bildschirm, um dieser App Zugriff darauf zu geben.",
"storageAccessDialogMessage": "Bitte den {directory} von „{volume}“ auf dem nächsten Bildschirm auswählen, um dieser App Zugriff darauf zu geben.",
"restrictedAccessDialogTitle": "Eingeschränkter Zugang",
"restrictedAccessDialogMessage": "Diese Anwendung darf keine Dateien im {directory} von „{volume}“ verändern.\n\nBitte verwenden Sie einen vorinstallierten Dateimanager oder eine Galerie-App, um die Objekte in ein anderes Verzeichnis zu verschieben.",
"restrictedAccessDialogMessage": "Diese Anwendung darf keine Dateien im {directory} von „{volume}“ verändern.\n\nBitte einen vorinstallierten Dateimanager verwenden oder eine Galerie-App, um die Objekte in ein anderes Verzeichnis zu verschieben.",
"notEnoughSpaceDialogTitle": "Nicht genug Platz",
"notEnoughSpaceDialogMessage": "Diese Operation benötigt {neededSize} freien Platz auf „{volume}“, um abgeschlossen zu werden, aber es ist nur noch {freeSize} übrig.",
"missingSystemFilePickerDialogTitle": "Fehlender System-Dateiauswahldialog",
"missingSystemFilePickerDialogMessage": "Der System-Dateiauswahldialog fehlt oder ist deaktiviert. Bitte aktivieren Sie ihn und versuchen Sie es erneut.",
"missingSystemFilePickerDialogMessage": "Der System-Dateiauswahldialog fehlt oder ist deaktiviert. Bitte aktivieren und es erneut versuchen.",
"unsupportedTypeDialogTitle": "Nicht unterstützte Typen",
"unsupportedTypeDialogMessage": " {count, plural, =1{Dieser Vorgang wird für Elemente des folgenden Typs nicht unterstützt: {types}.} other{Dieser Vorgang wird für Elemente der folgenden Typen nicht unterstützt: {types}.}}",
"unsupportedTypeDialogMessage": "{count, plural, =1{Dieser Vorgang wird für Elemente des folgenden Typs nicht unterstützt: {types}.} other{Dieser Vorgang wird für Elemente der folgenden Typen nicht unterstützt: {types}.}}",
"nameConflictDialogSingleSourceMessage": "Einige Dateien im Zielordner haben den gleichen Namen.",
"nameConflictDialogMultipleSourceMessage": "Einige Dateien haben denselben Namen.",
@ -155,9 +157,9 @@
"noMatchingAppDialogTitle": "Keine passende App",
"noMatchingAppDialogMessage": "Es gibt keine Anwendungen, die dies bewältigen können.",
"deleteEntriesConfirmationDialogMessage": " {count, plural, =1{Sind Sie sicher, dass Sie dieses Element löschen möchten?} other{Sind Sie sicher, dass Sie diese {count} Elemente löschen möchten?}}",
"deleteEntriesConfirmationDialogMessage": "{count, plural, =1{Sicher, dass dieses Element gelöscht werden soll?} other{Sicher, dass diese {count} Elemente gelöscht werden sollen?}}",
"videoResumeDialogMessage": "Möchten Sie bei {time} weiter abspielen?",
"videoResumeDialogMessage": "Soll bei {time} weiter abspielt werden?",
"videoStartOverButtonLabel": "NEU BEGINNEN",
"videoResumeButtonLabel": "FORTSETZTEN",
@ -165,7 +167,7 @@
"setCoverDialogLatest": "Letzter Artikel",
"setCoverDialogCustom": "Benutzerdefiniert",
"hideFilterConfirmationDialogMessage": "Passende Fotos und Videos werden aus Ihrer Sammlung ausgeblendet. Sie können sie in den „Datenschutz“-Einstellungen wieder einblenden.\n\nSind Sie sicher, dass Sie sie ausblenden möchten?",
"hideFilterConfirmationDialogMessage": "Passende Fotos und Videos werden aus Ihrer Sammlung ausgeblendet. Dies kann in den „Datenschutz“-Einstellungen wieder eingeblendet werden.\n\nSicher, dass diese ausblendet werden sollen?",
"newAlbumDialogTitle": "Neues Album",
"newAlbumDialogNameLabel": "Album Name",
@ -175,10 +177,12 @@
"renameAlbumDialogLabel": "Neuer Name",
"renameAlbumDialogLabelAlreadyExistsHelper": "Verzeichnis existiert bereits",
"deleteSingleAlbumConfirmationDialogMessage": " {count, plural, =1{Sind Sie sicher, dass Sie dieses Album und ihren Inhalt löschen möchten?} other{Sind Sie sicher, dass Sie dieses Album und deren {count} Elemente löschen möchten?}}",
"deleteMultiAlbumConfirmationDialogMessage": " {count, plural, =1{Sind Sie sicher, dass Sie diese Alben und ihren Inhalt löschen möchten?} other{Sind Sie sicher, dass Sie diese Alben und deren {count} Elemente löschen möchten?}}",
"deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{Sicher, dass dieses Album und der Inhalt gelöscht werden soll?} other{Sicher, dass dieses Album und deren {count} Elemente gelöscht werden sollen?}}",
"deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{Sicher, dass diese Alben und deren Inhalt gelöscht werden sollen?} other{Sicher, dass diese Alben und deren {count} Elemente gelöscht werden sollen?}}",
"exportEntryDialogFormat": "Format:",
"exportEntryDialogWidth": "Breite",
"exportEntryDialogHeight": "Höhe",
"renameEntryDialogLabel": "Neuer Name",
@ -192,12 +196,19 @@
"editEntryDateDialogHours": "Stunden",
"editEntryDateDialogMinutes": "Minuten",
"editEntryLocationDialogTitle": "Standort",
"editEntryLocationDialogChooseOnMapTooltip": "Auf Karte wählen",
"editEntryLocationDialogLatitude": "Breitengrad",
"editEntryLocationDialogLongitude": "Längengrad",
"locationPickerUseThisLocationButton": "Diesen Standort verwenden",
"editEntryRatingDialogTitle": "Bewertung",
"removeEntryMetadataDialogTitle": "Entfernung von Metadaten",
"removeEntryMetadataDialogMore": "Mehr",
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP ist erforderlich, um das Video innerhalb eines bewegten Bildes abzuspielen.\n\nSind Sie sicher, dass Sie es entfernen möchten?",
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP ist erforderlich, um das Video innerhalb eines bewegten Bildes abzuspielen.\n\nSicher, dass es entfernt werden soll?",
"videoSpeedDialogLabel": "Wiedergabegeschwindigkeit",
@ -230,13 +241,6 @@
"aboutLinkLicense": "Lizenz",
"aboutLinkPolicy": "Datenschutzrichtlinie",
"aboutUpdate": "Neue Version verfügbar",
"aboutUpdateLinks1": "Eine neue Version von Aves ist verfügbar unter",
"aboutUpdateLinks2": "und",
"aboutUpdateLinks3": ".",
"aboutUpdateGitHub": "github",
"aboutUpdateGooglePlay": "Google Play",
"aboutBug": "Fehlerbericht",
"aboutBugSaveLogInstruction": "Anwendungsprotokolle in einer Datei speichern",
"aboutBugSaveLogButton": "Speichern",
@ -263,7 +267,7 @@
"collectionPageTitle": "Sammlung",
"collectionPickPageTitle": "Wähle",
"collectionSelectionPageTitle": " {count, plural, =0{Elemente auswählen} =1{1 Element} other{{count} Elemente}}",
"collectionSelectionPageTitle": "{count, plural, =0{Elemente auswählen} =1{1 Element} other{{count} Elemente}}",
"collectionActionShowTitleSearch": "Titelfilter anzeigen",
"collectionActionHideTitleSearch": "Titelfilter ausblenden",
@ -289,14 +293,14 @@
"dateToday": "Heute",
"dateYesterday": "Gestern",
"dateThisMonth": "Diesen Monat",
"collectionDeleteFailureFeedback": " {count, plural, =1{1 Element konnte nicht gelöscht werden} other{{count} Elemente konnten nicht gelöscht werden}}",
"collectionCopyFailureFeedback": " {count, plural, =1{1 Element konnte nicht kopiert werden} other{{count} Element konnten nicht kopiert werden}}",
"collectionMoveFailureFeedback": " {count, plural, =1{1 Element konnte nicht verschoben werden} other{{count} Elemente konnten nicht verschoben werden}}",
"collectionEditFailureFeedback": " {count, plural, =1{1 Element konnte nicht bearbeitet werden} other{{count} 1 Elemente konnten nicht bearbeitet werden}}",
"collectionExportFailureFeedback": " {count, plural, =1{1 Seite konnte nicht exportiert werden} other{{count} Seiten konnten nicht exportiert werden}}",
"collectionCopySuccessFeedback": " {count, plural, =1{1 Element kopier} other{ {count} Elemente kopiert}}",
"collectionMoveSuccessFeedback": " {count, plural, =1{1 Element verschoben} other{{count} Elemente verschoben}}",
"collectionEditSuccessFeedback": " {count, plural, =1{1 Element bearbeitet} other{ {count} Elemente bearbeitet}}",
"collectionDeleteFailureFeedback": "{count, plural, =1{1 Element konnte nicht gelöscht werden} other{{count} Elemente konnten nicht gelöscht werden}}",
"collectionCopyFailureFeedback": "{count, plural, =1{1 Element konnte nicht kopiert werden} other{{count} Element konnten nicht kopiert werden}}",
"collectionMoveFailureFeedback": "{count, plural, =1{1 Element konnte nicht verschoben werden} other{{count} Elemente konnten nicht verschoben werden}}",
"collectionEditFailureFeedback": "{count, plural, =1{1 Element konnte nicht bearbeitet werden} other{{count} 1 Elemente konnten nicht bearbeitet werden}}",
"collectionExportFailureFeedback": "{count, plural, =1{1 Seite konnte nicht exportiert werden} other{{count} Seiten konnten nicht exportiert werden}}",
"collectionCopySuccessFeedback": "{count, plural, =1{1 Element kopier} other{ {count} Elemente kopiert}}",
"collectionMoveSuccessFeedback": "{count, plural, =1{1 Element verschoben} other{{count} Elemente verschoben}}",
"collectionEditSuccessFeedback": "{count, plural, =1{1 Element bearbeitet} other{ {count} Elemente bearbeitet}}",
"collectionEmptyFavourites": "Keine Favoriten",
"collectionEmptyVideos": "Keine Videos",
@ -305,7 +309,7 @@
"collectionSelectSectionTooltip": "Bereich auswählen",
"collectionDeselectSectionTooltip": "Bereich abwählen",
"drawerCollectionAll": "Alle Sammlung",
"drawerCollectionAll": "Alle Bilder",
"drawerCollectionFavourites": "Favoriten",
"drawerCollectionImages": "Bilder",
"drawerCollectionVideos": "Videos",
@ -329,7 +333,7 @@
"albumPickPageTitlePick": "Album auswählen",
"albumCamera": "Kamera",
"albumDownload": "Herunterladen",
"albumDownload": "Heruntergeladen",
"albumScreenshots": "Bildschirmfotos",
"albumScreenRecordings": "Bildschirmaufnahmen",
"albumVideoCaptures": "Video-Aufnahmen",
@ -361,6 +365,10 @@
"settingsActionExport": "Exportieren",
"settingsActionImport": "Importieren",
"appExportCovers": "Titelbilder",
"appExportFavourites": "Favoriten",
"appExportSettings": "Einstellungen",
"settingsSectionNavigation": "Navigation",
"settingsHome": "Startseite",
"settingsKeepScreenOnTile": "Bildschirm eingeschaltet lassen",
@ -369,7 +377,7 @@
"settingsNavigationDrawerTile": "Menü Navigation",
"settingsNavigationDrawerEditorTitle": "Menü Navigation",
"settingsNavigationDrawerBanner": "Berühren und halten Sie die Taste, um Menüpunkte zu verschieben und neu anzuordnen.",
"settingsNavigationDrawerBanner": "Die Taste berühren und halten, um Menüpunkte zu verschieben und neu anzuordnen.",
"settingsNavigationDrawerTabTypes": "Typen",
"settingsNavigationDrawerTabAlbums": "Alben",
"settingsNavigationDrawerTabPages": "Seiten",
@ -387,8 +395,8 @@
"settingsCollectionQuickActionEditorTitle": "Schnelle Aktionen",
"settingsCollectionQuickActionTabBrowsing": "Durchsuchen",
"settingsCollectionQuickActionTabSelecting": "Auswahl",
"settingsCollectionBrowsingQuickActionEditorBanner": "Halten Sie die Taste gedrückt, um die Schaltflächen zu bewegen und auszuwählen, welche Aktionen beim Durchsuchen von Elementen angezeigt werden.",
"settingsCollectionSelectionQuickActionEditorBanner": "Halten Sie die Taste gedrückt, um die Schaltflächen zu bewegen und auszuwählen, welche Aktionen bei der Auswahl von Elementen angezeigt werden.",
"settingsCollectionBrowsingQuickActionEditorBanner": "Die Taste gedrückt halten, um die Schaltflächen zu bewegen und auszuwählen, welche Aktionen beim Durchsuchen von Elementen angezeigt werden.",
"settingsCollectionSelectionQuickActionEditorBanner": "Die Taste gedrückt halten, um die Schaltflächen zu bewegen und auszuwählen, welche Aktionen beim Durchsuchen von Elementen angezeigt werden.",
"settingsSectionViewer": "Anzeige",
"settingsViewerUseCutout": "Ausgeschnittenen Bereich verwenden",
@ -398,7 +406,7 @@
"settingsViewerQuickActionsTile": "Schnelle Aktionen",
"settingsViewerQuickActionEditorTitle": "Schnelle Aktionen",
"settingsViewerQuickActionEditorBanner": "Halten Sie die Taste gedrückt, um die Schaltflächen zu bewegen und auszuwählen, welche Aktionen im Viewer angezeigt werden sollen.",
"settingsViewerQuickActionEditorBanner": "Die Taste gedrückt halten, um die Schaltflächen zu bewegen und auszuwählen, welche Aktionen im Viewer angezeigt werden sollen.",
"settingsViewerQuickActionEditorDisplayedButtons": "Angezeigte Schaltflächen",
"settingsViewerQuickActionEditorAvailableButtons": "Verfügbare Schaltflächen",
"settingsViewerQuickActionEmpty": "Keine Tasten",
@ -444,7 +452,7 @@
"settingsSaveSearchHistory": "Suchverlauf speichern",
"settingsHiddenItemsTile": "Versteckte Elemente",
"settingsHiddenItemsTitle": "Versteckte Gegenstände",
"settingsHiddenItemsTitle": "Versteckte Elemente",
"settingsHiddenFiltersTitle": "Versteckte Filter",
"settingsHiddenFiltersBanner": "Fotos und Videos, die versteckten Filtern entsprechen, werden nicht in Ihrer Sammlung angezeigt.",
@ -456,7 +464,7 @@
"settingsStorageAccessTile": "Speicherzugriff",
"settingsStorageAccessTitle": "Speicherzugriff",
"settingsStorageAccessBanner": "Einige Verzeichnisse erfordern eine explizite Zugriffsberechtigung, um Dateien darin zu ändern. Sie können hier Verzeichnisse überprüfen, auf die Sie zuvor Zugriff gewährt haben.",
"settingsStorageAccessBanner": "Einige Verzeichnisse erfordern eine explizite Zugriffsberechtigung, um Dateien darin zu ändern. Hier können Verzeichnisse überprüft werden, auf die zuvor Zugriff gewährt wurde.",
"settingsStorageAccessEmpty": "Keine Zugangsberechtigungen",
"settingsStorageAccessRevokeTooltip": "Widerrufen",
@ -474,7 +482,7 @@
"settingsUnitSystemTitle": "Einheiten",
"statsPageTitle": "Statistiken",
"statsWithGps": " {count, plural, =1{1 Element mit Standort} other{{count} Elemente mit Standort}}",
"statsWithGps": "{count, plural, =1{1 Element mit Standort} other{{count} Elemente mit Standort}}",
"statsTopCountries": "Top-Länder",
"statsTopPlaces": "Top-Plätze",
"statsTopTags": "Top-Tags",
@ -509,7 +517,7 @@
"mapEmptyRegion": "Keine Bilder in dieser Region",
"viewerInfoOpenEmbeddedFailureFeedback": "Eingebettete Daten konnten nicht extrahiert werden",
"viewerInfoOpenLinkText": "Öffnen Sie",
"viewerInfoOpenLinkText": "Öffnen",
"viewerInfoViewXmlLinkText": "Ansicht XML",
"viewerInfoSearchFieldLabel": "Metadaten suchen",
@ -534,5 +542,5 @@
"filePickerDoNotShowHiddenFiles": "Versteckte Dateien nicht anzeigen",
"filePickerOpenFrom": "Öffnen von",
"filePickerNoItems": "Keine Elemente",
"filePickerUseThisFolder": "Verwenden Sie diesen Ordner"
"filePickerUseThisFolder": "Diesen Ordner verwenden"
}

View file

@ -22,6 +22,15 @@
"minutes": {}
}
},
"focalLength": "{length} mm",
"@focalLength": {
"placeholders": {
"length": {
"type": "String",
"example": "5.4"
}
}
},
"applyButtonLabel": "APPLY",
"deleteButtonLabel": "DELETE",
@ -88,6 +97,7 @@
"videoActionSettings": "Settings",
"entryInfoActionEditDate": "Edit date & time",
"entryInfoActionEditLocation": "Edit location",
"entryInfoActionEditRating": "Edit rating",
"entryInfoActionEditTags": "Edit tags",
"entryInfoActionRemoveMetadata": "Remove metadata",
@ -291,6 +301,8 @@
},
"exportEntryDialogFormat": "Format:",
"exportEntryDialogWidth": "Width",
"exportEntryDialogHeight": "Height",
"renameEntryDialogLabel": "New name",
@ -304,6 +316,13 @@
"editEntryDateDialogHours": "Hours",
"editEntryDateDialogMinutes": "Minutes",
"editEntryLocationDialogTitle": "Location",
"editEntryLocationDialogChooseOnMapTooltip": "Choose on map",
"editEntryLocationDialogLatitude": "Latitude",
"editEntryLocationDialogLongitude": "Longitude",
"locationPickerUseThisLocationButton": "Use this location",
"editEntryRatingDialogTitle": "Rating",
"removeEntryMetadataDialogTitle": "Metadata Removal",
@ -342,13 +361,6 @@
"aboutLinkLicense": "License",
"aboutLinkPolicy": "Privacy Policy",
"aboutUpdate": "New Version Available",
"aboutUpdateLinks1": "A new version of Aves is available on",
"aboutUpdateLinks2": "and",
"aboutUpdateLinks3": ".",
"aboutUpdateGitHub": "GitHub",
"aboutUpdateGooglePlay": "Google Play",
"aboutBug": "Bug Report",
"aboutBugSaveLogInstruction": "Save app logs to a file",
"aboutBugSaveLogButton": "Save",
@ -530,6 +542,10 @@
"settingsActionExport": "Export",
"settingsActionImport": "Import",
"appExportCovers": "Covers",
"appExportFavourites": "Favourites",
"appExportSettings": "Settings",
"settingsSectionNavigation": "Navigation",
"settingsHome": "Home",
"settingsKeepScreenOnTile": "Keep screen on",
@ -708,6 +724,5 @@
"filePickerDoNotShowHiddenFiles": "Dont show hidden files",
"filePickerOpenFrom": "Open from",
"filePickerNoItems": "No items",
"filePickerUseThisFolder": "Use this folder",
"@filePickerUseThisFolder": {}
"filePickerUseThisFolder": "Use this folder"
}

View file

@ -7,6 +7,7 @@
"timeSeconds": "{seconds, plural, =1{1 segundo} other{{seconds} segundos}}",
"timeMinutes": "{minutes, plural, =1{1 minuto} other{{minutes} minutos}}",
"focalLength": "{length} mm",
"applyButtonLabel": "APLICAR",
"deleteButtonLabel": "BORRAR",
@ -40,7 +41,7 @@
"chipActionPin": "Fijar",
"chipActionUnpin": "Dejar de fijar",
"chipActionRename": "Renombrar",
"chipActionSetCover": "Elegir portada",
"chipActionSetCover": "Elegir carátula",
"chipActionCreateAlbum": "Crear álbum",
"entryActionCopyToClipboard": "Copiar al portapapeles",
@ -110,8 +111,8 @@
"mapStyleGoogleHybrid": "Mapas de Google (Híbrido)",
"mapStyleGoogleTerrain": "Mapas de Google (Superficie)",
"mapStyleOsmHot": "OSM Humanitario",
"mapStyleStamenToner": "Stamen Monocromático (Toner)",
"mapStyleStamenWatercolor": "Stamen Acuarela (Watercolor)",
"mapStyleStamenToner": "Stamen Toner (Monocromático)",
"mapStyleStamenWatercolor": "Stamen Watercolor (Acuarela)",
"nameConflictStrategyRename": "Renombrar",
"nameConflictStrategyReplace": "Reemplazar",
@ -231,13 +232,6 @@
"aboutLinkLicense": "Licencia",
"aboutLinkPolicy": "Política de privacidad",
"aboutUpdate": "Nueva versión disponible",
"aboutUpdateLinks1": "Una nueva versión de Aves se encuentra disponible en",
"aboutUpdateLinks2": "y",
"aboutUpdateLinks3": ".",
"aboutUpdateGitHub": "GitHub",
"aboutUpdateGooglePlay": "Google Play",
"aboutBug": "Reporte de errores",
"aboutBugSaveLogInstruction": "Guardar registros de la aplicación a un archivo",
"aboutBugSaveLogButton": "Guardar",
@ -362,6 +356,10 @@
"settingsActionExport": "Exportar",
"settingsActionImport": "Importar",
"appExportCovers": "Carátulas",
"appExportFavourites": "Favoritos",
"appExportSettings": "Ajustes",
"settingsSectionNavigation": "Navegación",
"settingsHome": "Inicio",
"settingsKeepScreenOnTile": "Mantener pantalla encendida",
@ -463,9 +461,9 @@
"settingsSectionAccessibility": "Accesibilidad",
"settingsRemoveAnimationsTile": "Remover animaciones",
"settingsRemoveAnimationsTitle": "Remove animaciones",
"settingsTimeToTakeActionTile": "Hora de entrar en acción",
"settingsTimeToTakeActionTitle": "Hora de entrar en acción",
"settingsRemoveAnimationsTitle": "Remover animaciones",
"settingsTimeToTakeActionTile": "Retraso para ejecutar una acción",
"settingsTimeToTakeActionTitle": "Retraso para ejecutar una acción",
"settingsSectionLanguage": "Idioma y formatos",
"settingsLanguage": "Idioma",
@ -535,6 +533,5 @@
"filePickerDoNotShowHiddenFiles": "No mostrar archivos ocultos",
"filePickerOpenFrom": "Abrir desde",
"filePickerNoItems": "Sin elementos",
"filePickerUseThisFolder": "Usar esta carpeta",
"@filePickerUseThisFolder": {}
"filePickerUseThisFolder": "Usar esta carpeta"
}

View file

@ -7,6 +7,7 @@
"timeSeconds": "{seconds, plural, =1{1 seconde} other{{seconds} secondes}}",
"timeMinutes": "{minutes, plural, =1{1 minute} other{{minutes} minutes}}",
"focalLength": "{length} mm",
"applyButtonLabel": "ENREGISTRER",
"deleteButtonLabel": "SUPPRIMER",
@ -73,6 +74,7 @@
"videoActionSettings": "Préférences",
"entryInfoActionEditDate": "Modifier la date",
"entryInfoActionEditLocation": "Modifier le lieu",
"entryInfoActionEditRating": "Modifier la notation",
"entryInfoActionEditTags": "Modifier les libellés",
"entryInfoActionRemoveMetadata": "Retirer les métadonnées",
@ -110,8 +112,8 @@
"mapStyleGoogleHybrid": "Google Maps (Satellite)",
"mapStyleGoogleTerrain": "Google Maps (Relief)",
"mapStyleOsmHot": "OSM Humanitaire",
"mapStyleStamenToner": "Stamen Toner",
"mapStyleStamenWatercolor": "Stamen Watercolor",
"mapStyleStamenToner": "Stamen Toner (Monochrome)",
"mapStyleStamenWatercolor": "Stamen Watercolor (Aquarelle)",
"nameConflictStrategyRename": "Renommer",
"nameConflictStrategyReplace": "Remplacer",
@ -179,6 +181,8 @@
"deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{Voulez-vous vraiment supprimer ces albums et leur élément ?} other{Voulez-vous vraiment supprimer ces albums et leurs {count} éléments ?}}",
"exportEntryDialogFormat": "Format :",
"exportEntryDialogWidth": "Largeur",
"exportEntryDialogHeight": "Hauteur",
"renameEntryDialogLabel": "Nouveau nom",
@ -192,6 +196,13 @@
"editEntryDateDialogHours": "Heures",
"editEntryDateDialogMinutes": "Minutes",
"editEntryLocationDialogTitle": "Lieu",
"editEntryLocationDialogChooseOnMapTooltip": "Sélectionner sur la carte",
"editEntryLocationDialogLatitude": "Latitude",
"editEntryLocationDialogLongitude": "Longitude",
"locationPickerUseThisLocationButton": "Utiliser ce lieu",
"editEntryRatingDialogTitle": "Notation",
"removeEntryMetadataDialogTitle": "Retrait de métadonnées",
@ -230,13 +241,6 @@
"aboutLinkLicense": "Licence",
"aboutLinkPolicy": "Politique de confidentialité",
"aboutUpdate": "Nouvelle Version",
"aboutUpdateLinks1": "Une nouvelle version dAves est disponible sur",
"aboutUpdateLinks2": "et",
"aboutUpdateLinks3": ".",
"aboutUpdateGitHub": "GitHub",
"aboutUpdateGooglePlay": "Google Play",
"aboutBug": "Rapports derreur",
"aboutBugSaveLogInstruction": "Sauvegarder les logs de lapp vers un fichier",
"aboutBugSaveLogButton": "Sauvegarder",
@ -361,6 +365,10 @@
"settingsActionExport": "Exporter",
"settingsActionImport": "Importer",
"appExportCovers": "Couvertures",
"appExportFavourites": "Favoris",
"appExportSettings": "Réglages",
"settingsSectionNavigation": "Navigation",
"settingsHome": "Page daccueil",
"settingsKeepScreenOnTile": "Maintenir lécran allumé",

View file

@ -7,6 +7,7 @@
"timeSeconds": "{seconds, plural, other{{seconds}초}}",
"timeMinutes": "{minutes, plural, other{{minutes}분}}",
"focalLength": "{length} mm",
"applyButtonLabel": "확인",
"deleteButtonLabel": "삭제",
@ -73,6 +74,7 @@
"videoActionSettings": "설정",
"entryInfoActionEditDate": "날짜 및 시간 수정",
"entryInfoActionEditLocation": "위치 수정",
"entryInfoActionEditRating": "별점 수정",
"entryInfoActionEditTags": "태그 수정",
"entryInfoActionRemoveMetadata": "메타데이터 삭제",
@ -110,8 +112,8 @@
"mapStyleGoogleHybrid": "구글 지도 (위성)",
"mapStyleGoogleTerrain": "구글 지도 (지형)",
"mapStyleOsmHot": "Humanitarian OSM",
"mapStyleStamenToner": "Stamen 토너",
"mapStyleStamenWatercolor": "Stamen 수채화",
"mapStyleStamenToner": "Stamen Toner (토너)",
"mapStyleStamenWatercolor": "Stamen Watercolor (수채화)",
"nameConflictStrategyRename": "이름 변경",
"nameConflictStrategyReplace": "대체",
@ -179,6 +181,8 @@
"deleteMultiAlbumConfirmationDialogMessage": "{count, plural, other{이 앨범들의 항목 {count}개를 삭제하시겠습니까?}}",
"exportEntryDialogFormat": "형식:",
"exportEntryDialogWidth": "가로",
"exportEntryDialogHeight": "세로",
"renameEntryDialogLabel": "이름",
@ -192,6 +196,13 @@
"editEntryDateDialogHours": "시간",
"editEntryDateDialogMinutes": "분",
"editEntryLocationDialogTitle": "위치",
"editEntryLocationDialogChooseOnMapTooltip": "지도에서 선택",
"editEntryLocationDialogLatitude": "위도",
"editEntryLocationDialogLongitude": "경도",
"locationPickerUseThisLocationButton": "이 위치 사용",
"editEntryRatingDialogTitle": "별점",
"removeEntryMetadataDialogTitle": "메타데이터 삭제",
@ -230,13 +241,6 @@
"aboutLinkLicense": "라이선스",
"aboutLinkPolicy": "개인정보 보호정책",
"aboutUpdate": "업데이트 사용 가능",
"aboutUpdateLinks1": "앱의 최신 버전을",
"aboutUpdateLinks2": "와",
"aboutUpdateLinks3": "에서 다운로드 사용 가능합니다.",
"aboutUpdateGitHub": "깃허브",
"aboutUpdateGooglePlay": "구글 플레이",
"aboutBug": "버그 보고",
"aboutBugSaveLogInstruction": "앱 로그를 파일에 저장하기",
"aboutBugSaveLogButton": "저장",
@ -361,6 +365,10 @@
"settingsActionExport": "내보내기",
"settingsActionImport": "가져오기",
"appExportCovers": "대표 이미지",
"appExportFavourites": "즐겨찾기",
"appExportSettings": "설정",
"settingsSectionNavigation": "탐색",
"settingsHome": "홈",
"settingsKeepScreenOnTile": "화면 자동 꺼짐 방지",

546
lib/l10n/app_pt.arb Normal file
View file

@ -0,0 +1,546 @@
{
"appName": "Aves",
"welcomeMessage": "Bem-vindo ao Aves",
"welcomeOptional": "Opcional",
"welcomeTermsToggle": "Eu concordo com os Termos e Condições",
"itemCount": "{count, plural, =1{1 item} other{{count} itens}}",
"timeSeconds": "{seconds, plural, =1{1 segundo} other{{seconds} segundos}}",
"timeMinutes": "{minutes, plural, =1{1 minuto} other{{minutes} minutos}}",
"focalLength": "{length} mm",
"applyButtonLabel": "APLIQUE",
"deleteButtonLabel": "EXCLUIR",
"nextButtonLabel": "PROXIMO",
"showButtonLabel": "MOSTRAR",
"hideButtonLabel": "OCULTAR",
"continueButtonLabel": "CONTINUAR",
"cancelTooltip": "Cancela",
"changeTooltip": "Mudar",
"clearTooltip": "Claro",
"previousTooltip": "Anterior",
"nextTooltip": "Proximo",
"showTooltip": "Mostrar",
"hideTooltip": "Ocultar",
"actionRemove": "Remover",
"resetButtonTooltip": "Resetar",
"doubleBackExitMessage": "Toque em “voltar” novamente para sair.",
"sourceStateLoading": "Carregando",
"sourceStateCataloguing": "Catalogação",
"sourceStateLocatingCountries": "Localizando países",
"sourceStateLocatingPlaces": "Localizando lugares",
"chipActionDelete": "Deletar",
"chipActionGoToAlbumPage": "Mostrar nos Álbuns",
"chipActionGoToCountryPage": "Mostrar em Países",
"chipActionGoToTagPage": "Mostrar em Etiquetas",
"chipActionHide": "Ocultar",
"chipActionPin": "Fixar no topo",
"chipActionUnpin": "Desafixar do topo",
"chipActionRename": "Renomear",
"chipActionSetCover": "Definir capa",
"chipActionCreateAlbum": "Criar álbum",
"entryActionCopyToClipboard": "Copiar para área de transferência",
"entryActionDelete": "Excluir",
"entryActionExport": "Exportar",
"entryActionInfo": "Informações",
"entryActionRename": "Renomear",
"entryActionRotateCCW": "Rotacionar para esquerda",
"entryActionRotateCW": "Rotacionar para direita",
"entryActionFlip": "Virar horizontalmente",
"entryActionPrint": "Imprimir",
"entryActionShare": "Compartilhado",
"entryActionViewSource": "Ver fonte",
"entryActionViewMotionPhotoVideo": "Abrir foto em movimento",
"entryActionEdit": "Editar com…",
"entryActionOpen": "Abrir com…",
"entryActionSetAs": "Definir como…",
"entryActionOpenMap": "Mostrar no aplicativo de mapa…",
"entryActionRotateScreen": "Girar a tela",
"entryActionAddFavourite": "Adicionar aos favoritos",
"entryActionRemoveFavourite": "Remova dos favoritos",
"videoActionCaptureFrame": "Capturar quadro",
"videoActionPause": "Pausa",
"videoActionPlay": "Toque",
"videoActionReplay10": "Retroceda 10 segundos",
"videoActionSkip10": "Avançar 10 segundos",
"videoActionSelectStreams": "Selecione as faixas",
"videoActionSetSpeed": "Velocidade de reprodução",
"videoActionSettings": "Configurações",
"entryInfoActionEditDate": "Editar data e hora",
"entryInfoActionEditLocation": "Editar localização",
"entryInfoActionEditRating": "Editar classificação",
"entryInfoActionEditTags": "Editar etiquetas",
"entryInfoActionRemoveMetadata": "Remover metadados",
"filterFavouriteLabel": "Favorito",
"filterLocationEmptyLabel": "Não localizado",
"filterTagEmptyLabel": "Sem etiqueta",
"filterRatingUnratedLabel": "Sem classificação",
"filterRatingRejectedLabel": "Rejeitado",
"filterTypeAnimatedLabel": "Animado",
"filterTypeMotionPhotoLabel": "Foto em movimento",
"filterTypePanoramaLabel": "Panorama",
"filterTypeRawLabel": "Raw",
"filterTypeSphericalVideoLabel": "360° vídeo",
"filterTypeGeotiffLabel": "GeoTIFF",
"filterMimeImageLabel": "Imagem",
"filterMimeVideoLabel": "Vídeo",
"coordinateFormatDms": "DMS",
"coordinateFormatDecimal": "Graus decimais",
"coordinateDms": "{coordinate} {direction}",
"coordinateDmsNorth": "N",
"coordinateDmsSouth": "S",
"coordinateDmsEast": "L",
"coordinateDmsWest": "O",
"unitSystemMetric": "Métrica",
"unitSystemImperial": "Imperial",
"videoLoopModeNever": "Nunca",
"videoLoopModeShortOnly": "Apenas vídeos curtos",
"videoLoopModeAlways": "Sempre",
"mapStyleGoogleNormal": "Google Maps",
"mapStyleGoogleHybrid": "Google Maps (Híbrido)",
"mapStyleGoogleTerrain": "Google Maps (Terreno)",
"mapStyleOsmHot": "OSM Humanitário",
"mapStyleStamenToner": "Stamen Toner (Monocromático)",
"mapStyleStamenWatercolor": "Stamen Watercolor (Aquarela)",
"nameConflictStrategyRename": "Renomear",
"nameConflictStrategyReplace": "Substituir",
"nameConflictStrategySkip": "Pular",
"keepScreenOnNever": "Nunca",
"keepScreenOnViewerOnly": "Somente página do visualizador",
"keepScreenOnAlways": "Sempre",
"accessibilityAnimationsRemove": "Prevenir efeitos de tela",
"accessibilityAnimationsKeep": "Manter efeitos de tela",
"albumTierNew": "Novo",
"albumTierPinned": "Fixada",
"albumTierSpecial": "Comum",
"albumTierApps": "Aplicativos",
"albumTierRegular": "Outras",
"storageVolumeDescriptionFallbackPrimary": "Armazenamento interno",
"storageVolumeDescriptionFallbackNonPrimary": "cartão SD",
"rootDirectoryDescription": "diretório raiz",
"otherDirectoryDescription": "diretório “{name}”",
"storageAccessDialogTitle": "Acesso de armazenamento",
"storageAccessDialogMessage": "Selecione o {directory} de “{volume}” na próxima tela para dar acesso a este aplicativo.",
"restrictedAccessDialogTitle": "Acesso restrito",
"restrictedAccessDialogMessage": "Este aplicativo não tem permissão para modificar arquivos no {directory} de “{volume}”.\n\nUse um gerenciador de arquivos ou aplicativo de galeria pré-instalado para mover os itens para outro diretório.",
"notEnoughSpaceDialogTitle": "Espaço insuficiente",
"notEnoughSpaceDialogMessage": "Esta operação precisa {neededSize} de espaço livre em “{volume}” para completar, mas só {freeSize} restantes.",
"missingSystemFilePickerDialogTitle": "Seletor de arquivos do sistema ausente",
"missingSystemFilePickerDialogMessage": "O seletor de arquivos do sistema está ausente ou desabilitado. Por favor, habilite e tente novamente.",
"unsupportedTypeDialogTitle": "Tipos não suportados",
"unsupportedTypeDialogMessage": "{count, plural, =1{Esta operação não é suportada para itens do seguinte tipo: {types}.} other{Esta operação não é suportada para itens dos seguintes tipos: {types}.}}",
"nameConflictDialogSingleSourceMessage": "Alguns arquivos na pasta de destino têm o mesmo nome.",
"nameConflictDialogMultipleSourceMessage": "Alguns arquivos têm o mesmo nome.",
"addShortcutDialogLabel": "Rótulo de atalho",
"addShortcutButtonLabel": "ADICIONAR",
"noMatchingAppDialogTitle": "Nenhum aplicativo correspondente",
"noMatchingAppDialogMessage": "Não há aplicativos que possam lidar com isso.",
"deleteEntriesConfirmationDialogMessage": "{count, plural, =1{Tem certeza de que deseja excluir este item?} other{Tem certeza de que deseja excluir estes {count} itens?}}",
"videoResumeDialogMessage": "Deseja continuar jogando em {time}?",
"videoStartOverButtonLabel": "RECOMEÇAR",
"videoResumeButtonLabel": "RETOMAR",
"setCoverDialogTitle": "Definir capa",
"setCoverDialogLatest": "Último item",
"setCoverDialogCustom": "Personalizado",
"hideFilterConfirmationDialogMessage": "Fotos e vídeos correspondentes serão ocultados da sua coleção. Você pode mostrá-los novamente nas configurações de “Privacidade”.\n\nTem certeza de que deseja ocultá-los?",
"newAlbumDialogTitle": "Novo álbum",
"newAlbumDialogNameLabel": "Nome do álbum",
"newAlbumDialogNameLabelAlreadyExistsHelper": "O diretório já existe",
"newAlbumDialogStorageLabel": "Armazenar:",
"renameAlbumDialogLabel": "Novo nome",
"renameAlbumDialogLabelAlreadyExistsHelper": "O diretório já existe",
"deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{Tem certeza de que deseja excluir este álbum e seu item?} other{Tem certeza de que deseja excluir este álbum e seus {count} itens?}}",
"deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{Tem certeza de que deseja excluir estes álbuns e seus itens?} other{Tem certeza de que deseja excluir estes álbuns e seus {count} itens?}}",
"exportEntryDialogFormat": "Formato:",
"exportEntryDialogWidth": "Largura",
"exportEntryDialogHeight": "Altura",
"renameEntryDialogLabel": "Novo nome",
"editEntryDateDialogTitle": "Data e hora",
"editEntryDateDialogSetCustom": "Definir data personalizada",
"editEntryDateDialogCopyField": "Copiar de outra data",
"editEntryDateDialogExtractFromTitle": "Extrair do título",
"editEntryDateDialogShift": "Mudança",
"editEntryDateDialogSourceFileModifiedDate": "Data de modificação do arquivo",
"editEntryDateDialogTargetFieldsHeader": "Campos para modificar",
"editEntryDateDialogHours": "Horas",
"editEntryDateDialogMinutes": "Minutos",
"editEntryLocationDialogTitle": "Localização",
"editEntryLocationDialogChooseOnMapTooltip": "Escolha no mapa",
"editEntryLocationDialogLatitude": "Latitude",
"editEntryLocationDialogLongitude": "Longitude",
"locationPickerUseThisLocationButton": "Usar essa localização",
"editEntryRatingDialogTitle": "Avaliação",
"removeEntryMetadataDialogTitle": "Remoção de metadados",
"removeEntryMetadataDialogMore": "Mais",
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP é necessário para reproduzir o vídeo dentro de uma foto em movimento.\n\nTem certeza de que deseja removê-lo?",
"videoSpeedDialogLabel": "Velocidade de reprodução",
"videoStreamSelectionDialogVideo": "Video",
"videoStreamSelectionDialogAudio": "Áudio",
"videoStreamSelectionDialogText": "Legendas",
"videoStreamSelectionDialogOff": "Fora",
"videoStreamSelectionDialogTrack": "Acompanhar",
"videoStreamSelectionDialogNoSelection": "Não há outras faixas.",
"genericSuccessFeedback": "Feito!",
"genericFailureFeedback": "Falhou",
"menuActionConfigureView": "Visualizar",
"menuActionSelect": "Selecionar",
"menuActionSelectAll": "Selecionar tudo",
"menuActionSelectNone": "Selecione nenhum",
"menuActionMap": "Mapa",
"menuActionStats": "Estatísticas",
"viewDialogTabSort": "Organizar",
"viewDialogTabGroup": "Grupo",
"viewDialogTabLayout": "Layout",
"tileLayoutGrid": "Grid",
"tileLayoutList": "Lista",
"aboutPageTitle": "Sobre",
"aboutLinkSources": "Fontes",
"aboutLinkLicense": "Licença",
"aboutLinkPolicy": "Política de Privacidade",
"aboutBug": "Relatório de erro",
"aboutBugSaveLogInstruction": "Salvar registros de aplicativos em um arquivo",
"aboutBugSaveLogButton": "Salve",
"aboutBugCopyInfoInstruction": "Copiar informações do sistema",
"aboutBugCopyInfoButton": "Copiar",
"aboutBugReportInstruction": "Relatório no GitHub com os logs e informações do sistema",
"aboutBugReportButton": "Relatório",
"aboutCredits": "Créditos",
"aboutCreditsWorldAtlas1": "Este aplicativo usa um arquivo de TopoJSON",
"aboutCreditsWorldAtlas2": "sob licença ISC.",
"aboutCreditsTranslators": "Tradutores:",
"aboutCreditsTranslatorLine": "{language}: {names}",
"aboutLicenses": "Licenças de código aberto",
"aboutLicensesBanner": "Este aplicativo usa os seguintes pacotes e bibliotecas de código aberto.",
"aboutLicensesAndroidLibraries": "Bibliotecas Android",
"aboutLicensesFlutterPlugins": "Plug-ins Flutter",
"aboutLicensesFlutterPackages": "Pacotes Flutter",
"aboutLicensesDartPackages": "Pacotes Dart",
"aboutLicensesShowAllButtonLabel": "Mostrar todas as licenças",
"policyPageTitle": "Política de Privacidade",
"collectionPageTitle": "Coleção",
"collectionPickPageTitle": "Escolher",
"collectionSelectionPageTitle": "{count, plural, =0{Selecionar itens} =1{1 item} other{{count} itens}}",
"collectionActionShowTitleSearch": "Mostrar filtro de título",
"collectionActionHideTitleSearch": "Ocultar filtro de título",
"collectionActionAddShortcut": "Adicionar atalho",
"collectionActionCopy": "Copiar para o álbum",
"collectionActionMove": "Mover para o álbum",
"collectionActionRescan": "Reexaminar",
"collectionActionEdit": "Editar",
"collectionSearchTitlesHintText": "Pesquisar títulos",
"collectionSortDate": "Por data",
"collectionSortSize": "Por tamanho",
"collectionSortName": "Por álbum e nome de arquivo",
"collectionSortRating": "Por classificação",
"collectionGroupAlbum": "Por álbum",
"collectionGroupMonth": "Por mês",
"collectionGroupDay": "Por dia",
"collectionGroupNone": "Não agrupe",
"sectionUnknown": "Desconhecido",
"dateToday": "Hoje",
"dateYesterday": "Ontem",
"dateThisMonth": "Este mês",
"collectionDeleteFailureFeedback": "{count, plural, =1{Falha ao excluir 1 item} other{Falha ao excluir {count} itens}}",
"collectionCopyFailureFeedback": "{count, plural, =1{Falha ao copiar 1 item} other{Falha ao copiar {count} itens}}",
"collectionMoveFailureFeedback": "{count, plural, =1{Falha ao mover 1 item} other{Falha ao mover {count} itens}}",
"collectionEditFailureFeedback": "{count, plural, =1{Falha ao editar 1 item} other{Falha ao editar {count} itens}}",
"collectionExportFailureFeedback": "{count, plural, =1{Falha ao exportar 1 página} other{Falha ao exportar {count} páginas}}",
"collectionCopySuccessFeedback": "{count, plural, =1{1 item copiado} other{Copiado {count} itens}}",
"collectionMoveSuccessFeedback": "{count, plural, =1{1 item movido} other{Mudou-se {count} itens}}",
"collectionEditSuccessFeedback": "{count, plural, =1{Editado 1 item} other{Editado {count} itens}}",
"collectionEmptyFavourites": "Nenhum favorito",
"collectionEmptyVideos": "Nenhum video",
"collectionEmptyImages": "Nenhuma image",
"collectionSelectSectionTooltip": "Selecionar seção",
"collectionDeselectSectionTooltip": "Desmarcar seção",
"drawerCollectionAll": "Toda a coleção",
"drawerCollectionFavourites": "Favoritos",
"drawerCollectionImages": "Imagens",
"drawerCollectionVideos": "Vídeos",
"drawerCollectionAnimated": "Animado",
"drawerCollectionMotionPhotos": "Fotos em movimento",
"drawerCollectionPanoramas": "Panoramas",
"drawerCollectionRaws": "Fotos Raw",
"drawerCollectionSphericalVideos": "360° Videos",
"chipSortDate": "Por data",
"chipSortName": "Por nome",
"chipSortCount": "Por contagem de itens",
"albumGroupTier": "Por nível",
"albumGroupVolume": "Por volume de armazenamento",
"albumGroupNone": "Não agrupe",
"albumPickPageTitleCopy": "Copiar para o álbum",
"albumPickPageTitleExport": "Exportar para o álbum",
"albumPickPageTitleMove": "Mover para o álbum",
"albumPickPageTitlePick": "Escolher álbum",
"albumCamera": "Câmera",
"albumDownload": "Download",
"albumScreenshots": "Capturas de tela",
"albumScreenRecordings": "Gravações de tela",
"albumVideoCaptures": "Capturas de vídeo",
"albumPageTitle": "Álbuns",
"albumEmpty": "Nenhum álbum",
"createAlbumTooltip": "Criar álbum",
"createAlbumButtonLabel": "CRIA",
"newFilterBanner": "novo",
"countryPageTitle": "Países",
"countryEmpty": "Nenhum país",
"tagPageTitle": "Etiquetas",
"tagEmpty": "Sem etiquetas",
"searchCollectionFieldHint": "Pesquisar coleção",
"searchSectionRecent": "Recente",
"searchSectionAlbums": "Álbuns",
"searchSectionCountries": "Países",
"searchSectionPlaces": "Locais",
"searchSectionTags": "Etiquetas",
"searchSectionRating": "Classificações",
"settingsPageTitle": "Configurações",
"settingsSystemDefault": "Sistema",
"settingsDefault": "Padrão",
"settingsActionExport": "Exportar",
"settingsActionImport": "Importar",
"appExportCovers": "Capas",
"appExportFavourites": "Favoritos",
"appExportSettings": "Configurações",
"settingsSectionNavigation": "Navegação",
"settingsHome": "Início",
"settingsKeepScreenOnTile": "Manter a tela ligada",
"settingsKeepScreenOnTitle": "Manter a tela ligada",
"settingsDoubleBackExit": "Toque em “voltar” duas vezes para sair",
"settingsNavigationDrawerTile": "Menu de navegação",
"settingsNavigationDrawerEditorTitle": "Menu de navegação",
"settingsNavigationDrawerBanner": "Toque e segure para mover e reordenar os itens do menu.",
"settingsNavigationDrawerTabTypes": "Tipos",
"settingsNavigationDrawerTabAlbums": "Álbuns",
"settingsNavigationDrawerTabPages": "Páginas",
"settingsNavigationDrawerAddAlbum": "Adicionar álbum",
"settingsSectionThumbnails": "Miniaturas",
"settingsThumbnailShowFavouriteIcon": "Mostrar ícone favorito",
"settingsThumbnailShowLocationIcon": "Mostrar ícone de localização",
"settingsThumbnailShowMotionPhotoIcon": "Mostrar ícone de foto em movimento",
"settingsThumbnailShowRating": "Mostrar classificação",
"settingsThumbnailShowRawIcon": "Mostrar ícone raw",
"settingsThumbnailShowVideoDuration": "Mostrar duração do vídeo",
"settingsCollectionQuickActionsTile": "Ações rápidas",
"settingsCollectionQuickActionEditorTitle": "Ações rápidas",
"settingsCollectionQuickActionTabBrowsing": "Navegando",
"settingsCollectionQuickActionTabSelecting": "Selecionando",
"settingsCollectionBrowsingQuickActionEditorBanner": "Toque e segure para mover os botões e selecionar quais ações são exibidas ao navegar pelos itens.",
"settingsCollectionSelectionQuickActionEditorBanner": "Toque e segure para mover os botões e selecionar quais ações são exibidas ao selecionar itens.",
"settingsSectionViewer": "Visualizador",
"settingsViewerUseCutout": "Usar área de recorte",
"settingsViewerMaximumBrightness": "Brilho máximo",
"settingsMotionPhotoAutoPlay": "Reprodução automática de fotos em movimento",
"settingsImageBackground": "Plano de fundo da imagem",
"settingsViewerQuickActionsTile": "Ações rápidas",
"settingsViewerQuickActionEditorTitle": "Ações rápidas",
"settingsViewerQuickActionEditorBanner": "Toque e segure para mover os botões e selecionar quais ações são exibidas no visualizador.",
"settingsViewerQuickActionEditorDisplayedButtons": "Botões exibidos",
"settingsViewerQuickActionEditorAvailableButtons": "Botões disponíveis",
"settingsViewerQuickActionEmpty": "Sem botões",
"settingsViewerOverlayTile": "Sobreposição",
"settingsViewerOverlayTitle": "Sobreposição",
"settingsViewerShowOverlayOnOpening": "Mostrar na abertura",
"settingsViewerShowMinimap": "Mostrar minimapa",
"settingsViewerShowInformation": "Mostrar informações",
"settingsViewerShowInformationSubtitle": "Mostrar título, data, local, etc.",
"settingsViewerShowShootingDetails": "Mostrar detalhes de disparo",
"settingsViewerEnableOverlayBlurEffect": "Efeito de desfoque",
"settingsVideoPageTitle": "Configurações de vídeo",
"settingsSectionVideo": "Vídeo",
"settingsVideoShowVideos": "Mostrar vídeos",
"settingsVideoEnableHardwareAcceleration": "Aceleraçao do hardware",
"settingsVideoEnableAutoPlay": "Reprodução automática",
"settingsVideoLoopModeTile": "Modo de loop",
"settingsVideoLoopModeTitle": "Modo de loop",
"settingsVideoQuickActionsTile": "Ações rápidas para vídeos",
"settingsVideoQuickActionEditorTitle": "Ações rápidas",
"settingsSubtitleThemeTile": "Legendas",
"settingsSubtitleThemeTitle": "Legendas",
"settingsSubtitleThemeSample": "Esta é uma amostra.",
"settingsSubtitleThemeTextAlignmentTile": "Alinhamento de texto",
"settingsSubtitleThemeTextAlignmentTitle": "Alinhamento de Texto",
"settingsSubtitleThemeTextSize": "Tamanho do texto",
"settingsSubtitleThemeShowOutline": "Mostrar contorno e sombra",
"settingsSubtitleThemeTextColor": "Cor do texto",
"settingsSubtitleThemeTextOpacity": "Opacidade do texto",
"settingsSubtitleThemeBackgroundColor": "Cor de fundo",
"settingsSubtitleThemeBackgroundOpacity": "Opacidade do plano de fundo",
"settingsSubtitleThemeTextAlignmentLeft": "Esquerda",
"settingsSubtitleThemeTextAlignmentCenter": "Centro",
"settingsSubtitleThemeTextAlignmentRight": "Direita",
"settingsSectionPrivacy": "Privacidade",
"settingsAllowInstalledAppAccess": "Permitir acesso ao inventário de aplicativos",
"settingsAllowInstalledAppAccessSubtitle": "Usado para melhorar a exibição do álbum",
"settingsAllowErrorReporting": "Permitir relatórios de erros anônimos",
"settingsSaveSearchHistory": "Salvar histórico de pesquisa",
"settingsHiddenItemsTile": "Itens ocultos",
"settingsHiddenItemsTitle": "Itens ocultos",
"settingsHiddenFiltersTitle": "Filtros ocultos",
"settingsHiddenFiltersBanner": "Fotos e vídeos que correspondem a filtros ocultos não aparecerão em sua coleção.",
"settingsHiddenFiltersEmpty": "Sem filtros ocultos",
"settingsHiddenPathsTitle": "Caminhos Ocultos",
"settingsHiddenPathsBanner": "Fotos e vídeos nessas pastas, ou em qualquer uma de suas subpastas, não aparecerão em sua coleção.",
"addPathTooltip": "Adicionar caminho",
"settingsStorageAccessTile": "Acesso ao armazenamento",
"settingsStorageAccessTitle": "Acesso ao armazenamento",
"settingsStorageAccessBanner": "Alguns diretórios exigem uma concessão de acesso explícito para modificar arquivos neles. Você pode revisar aqui os diretórios aos quais você deu acesso anteriormente.",
"settingsStorageAccessEmpty": "Sem concessões de acesso",
"settingsStorageAccessRevokeTooltip": "Revogar",
"settingsSectionAccessibility": "Acessibilidade",
"settingsRemoveAnimationsTile": "Remover animações",
"settingsRemoveAnimationsTitle": "Remover Animações",
"settingsTimeToTakeActionTile": "Tempo para executar uma ação",
"settingsTimeToTakeActionTitle": "Tempo para executar uma ação",
"settingsSectionLanguage": "Idioma e Formatos",
"settingsLanguage": "Língua",
"settingsCoordinateFormatTile": "Formato de coordenadas",
"settingsCoordinateFormatTitle": "Formato de coordenadas",
"settingsUnitSystemTile": "Unidades",
"settingsUnitSystemTitle": "Unidades",
"statsPageTitle": "Estatísticas",
"statsWithGps": "{count, plural, =1{1 item com localização} other{{count} itens com localização}}",
"statsTopCountries": "Principais Países",
"statsTopPlaces": "Principais Lugares",
"statsTopTags": "Principais Etiquetas",
"viewerOpenPanoramaButtonLabel": "ABRIR PANORAMA",
"viewerErrorUnknown": "Algo não está certo!",
"viewerErrorDoesNotExist": "O arquivo não existe mais.",
"viewerInfoPageTitle": "Informações",
"viewerInfoBackToViewerTooltip": "Voltar ao visualizador",
"viewerInfoUnknown": "desconhecido",
"viewerInfoLabelTitle": "Título",
"viewerInfoLabelDate": "Data",
"viewerInfoLabelResolution": "Resolução",
"viewerInfoLabelSize": "Tamanho",
"viewerInfoLabelUri": "URI",
"viewerInfoLabelPath": "Caminho",
"viewerInfoLabelDuration": "Duração",
"viewerInfoLabelOwner": "Propriedade de",
"viewerInfoLabelCoordinates": "Coordenadas",
"viewerInfoLabelAddress": "Endereço",
"mapStyleTitle": "Estilo do mapa",
"mapStyleTooltip": "Selecione o estilo do mapa",
"mapZoomInTooltip": "Mais zoom",
"mapZoomOutTooltip": "Reduzir o zoom",
"mapPointNorthUpTooltip": "Aponte para o norte para cima",
"mapAttributionOsmHot": "Dados do mapa © [OpenStreetMap](https://www.openstreetmap.org/copyright) colaboradores • Blocos por [HOT](https://www.hotosm.org/) • Hospedado por [OSM France](https://openstreetmap.fr/)",
"mapAttributionStamen": "Dados do mapa © [OpenStreetMap](https://www.openstreetmap.org/copyright) colaboradores • Blocos por [Stamen Design](http://stamen.com), [CC BY 3.0](http://creativecommons.org/licenses/by/3.0)",
"openMapPageTooltip": "Visualizar na página do mapa",
"mapEmptyRegion": "Nenhuma imagem nesta região",
"viewerInfoOpenEmbeddedFailureFeedback": "Falha ao extrair dados incorporados",
"viewerInfoOpenLinkText": "Abrir",
"viewerInfoViewXmlLinkText": "Visualizar XML",
"viewerInfoSearchFieldLabel": "Pesquisar metadados",
"viewerInfoSearchEmpty": "Nenhuma chave correspondente",
"viewerInfoSearchSuggestionDate": "Data e Hora",
"viewerInfoSearchSuggestionDescription": "Descrição",
"viewerInfoSearchSuggestionDimensions": "Dimensões",
"viewerInfoSearchSuggestionResolution": "Resolução",
"viewerInfoSearchSuggestionRights": "Direitos",
"tagEditorPageTitle": "Editar etiquetas",
"tagEditorPageNewTagFieldLabel": "Nova etiqueta",
"tagEditorPageAddTagTooltip": "Adicionar etiqueta",
"tagEditorSectionRecent": "Recente",
"panoramaEnableSensorControl": "Ativar o controle do sensor",
"panoramaDisableSensorControl": "Desabilitar o controle do sensor",
"sourceViewerPageTitle": "Fonte",
"filePickerShowHiddenFiles": "Mostrar arquivos ocultos",
"filePickerDoNotShowHiddenFiles": "Não mostre arquivos ocultos",
"filePickerOpenFrom": "Abrir de",
"filePickerNoItems": "Nenhum itens",
"filePickerUseThisFolder": "Usar esta pasta"
}

View file

@ -7,6 +7,7 @@
"timeSeconds": "{seconds, plural, =1{1 секунда} few{{seconds} секунды} other{{seconds} секунд}}",
"timeMinutes": "{minutes, plural, =1{1 минута} few{{minutes} минуты} other{{minutes} минут}}",
"focalLength": "{length} mm",
"applyButtonLabel": "ПРИМЕНИТЬ",
"deleteButtonLabel": "УДАЛИТЬ",
@ -33,9 +34,9 @@
"sourceStateLocatingPlaces": "Расположение локаций",
"chipActionDelete": "Удалить",
"chipActionGoToAlbumPage": "Показывать в Альбомах",
"chipActionGoToCountryPage": "Показывать в Странах",
"chipActionGoToTagPage": "Показывать в тегах",
"chipActionGoToAlbumPage": "Показать в Альбомах",
"chipActionGoToCountryPage": "Показать в Странах",
"chipActionGoToTagPage": "Показать в тегах",
"chipActionHide": "Скрыть",
"chipActionPin": "Закрепить",
"chipActionUnpin": "Открепить",
@ -73,6 +74,7 @@
"videoActionSettings": "Настройки",
"entryInfoActionEditDate": "Изменить дату и время",
"entryInfoActionEditLocation": "Изменить местоположение",
"entryInfoActionEditRating": "Изменить рейтинг",
"entryInfoActionEditTags": "Изменить теги",
"entryInfoActionRemoveMetadata": "Удалить метаданные",
@ -81,7 +83,7 @@
"filterLocationEmptyLabel": "Без местоположения",
"filterTagEmptyLabel": "Без тегов",
"filterRatingUnratedLabel": "Без рейтинга",
"filterRatingRejectedLabel": "Отклонённые",
"filterRatingRejectedLabel": "Отклонённое",
"filterTypeAnimatedLabel": "GIF",
"filterTypeMotionPhotoLabel": "Живое фото",
"filterTypePanoramaLabel": "Панорама",
@ -179,11 +181,13 @@
"deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{Вы уверены, что хотите удалить эти альбомы и их объекты?} few{Вы уверены, что хотите удалить эти альбомы и их {count} объекта?} other{Вы уверены, что хотите удалить эти альбомы и их {count} объектов?}}",
"exportEntryDialogFormat": "Формат:",
"exportEntryDialogWidth": "Ширина",
"exportEntryDialogHeight": "Высота",
"renameEntryDialogLabel": "Новое название",
"editEntryDateDialogTitle": "Дата и время",
"editEntryDateDialogSetCustom": "Задайте дату",
"editEntryDateDialogSetCustom": "Установить дату",
"editEntryDateDialogCopyField": "Копировать с другой даты",
"editEntryDateDialogExtractFromTitle": "Извлечь из названия",
"editEntryDateDialogShift": "Сдвиг",
@ -192,6 +196,13 @@
"editEntryDateDialogHours": "Часов",
"editEntryDateDialogMinutes": "Минут",
"editEntryLocationDialogTitle": "Местоположение",
"editEntryLocationDialogChooseOnMapTooltip": "Выбрать на карте",
"editEntryLocationDialogLatitude": "Широта",
"editEntryLocationDialogLongitude": "Долгота",
"locationPickerUseThisLocationButton": "Использовать это местоположение",
"editEntryRatingDialogTitle": "Рейтинг",
"removeEntryMetadataDialogTitle": "Удаление метаданных",
@ -230,13 +241,6 @@
"aboutLinkLicense": "Лицензия",
"aboutLinkPolicy": "Политика конфиденциальности",
"aboutUpdate": "Доступна новая версия",
"aboutUpdateLinks1": "Новая версия Aves доступна на",
"aboutUpdateLinks2": "и",
"aboutUpdateLinks3": ".",
"aboutUpdateGitHub": "GitHub",
"aboutUpdateGooglePlay": "Play Маркет",
"aboutBug": "Отчет об ошибке",
"aboutBugSaveLogInstruction": "Сохраните логи приложения в файл",
"aboutBugSaveLogButton": "Сохранить",
@ -361,11 +365,15 @@
"settingsActionExport": "Экспорт",
"settingsActionImport": "Импорт",
"appExportCovers": "Обложки",
"appExportFavourites": "Избранное",
"appExportSettings": "Настройки",
"settingsSectionNavigation": "Навигация",
"settingsHome": "Домашний каталог",
"settingsKeepScreenOnTile": "Держать экран включенным",
"settingsKeepScreenOnTitle": "Держать экран включенным",
"settingsDoubleBackExit": "Дважды нажмите «назад», чтобы выйти",
"settingsDoubleBackExit": "Дважды нажмите «Назад», чтобы выйти",
"settingsNavigationDrawerTile": "Навигационное меню",
"settingsNavigationDrawerEditorTitle": "Навигационное меню",
@ -376,11 +384,12 @@
"settingsNavigationDrawerAddAlbum": "Добавить альбом",
"settingsSectionThumbnails": "Эскизы",
"settingsThumbnailShowFavouriteIcon": "Показать значок избранного",
"settingsThumbnailShowLocationIcon": "Показать значок местоположения",
"settingsThumbnailShowMotionPhotoIcon": "Показать значок живого фото",
"settingsThumbnailShowRating": "Показывать рейтинг",
"settingsThumbnailShowMotionPhotoIcon": "Показать значок «живого фото»",
"settingsThumbnailShowRating": "Показать рейтинг",
"settingsThumbnailShowRawIcon": "Показать значок RAW-файла",
"settingsThumbnailShowVideoDuration": "Показывать продолжительность видео",
"settingsThumbnailShowVideoDuration": "Показать продолжительность видео",
"settingsCollectionQuickActionsTile": "Быстрые действия",
"settingsCollectionQuickActionEditorTitle": "Быстрые действия",
@ -392,7 +401,7 @@
"settingsSectionViewer": "Просмотрщик",
"settingsViewerUseCutout": "Использовать область выреза",
"settingsViewerMaximumBrightness": "Максимальная яркость",
"settingsMotionPhotoAutoPlay": "Автовоспроизведение «Живых фото»",
"settingsMotionPhotoAutoPlay": "Автовоспроизведение «живых фото»",
"settingsImageBackground": "Фон изображения",
"settingsViewerQuickActionsTile": "Быстрые действия",
@ -404,26 +413,26 @@
"settingsViewerOverlayTile": "Наложение",
"settingsViewerOverlayTitle": "Наложение",
"settingsViewerShowOverlayOnOpening": "Показывать наложение при открытии",
"settingsViewerShowOverlayOnOpening": "Показать наложение при открытии",
"settingsViewerShowMinimap": "Показать миникарту",
"settingsViewerShowInformation": "Показывать информацию",
"settingsViewerShowInformation": "Показать информацию",
"settingsViewerShowInformationSubtitle": "Показать название, дату, местоположение и т.д.",
"settingsViewerShowShootingDetails": "Показать детали съёмки",
"settingsViewerEnableOverlayBlurEffect": "Наложение эффекта размытия",
"settingsVideoPageTitle": "Настройки видео",
"settingsSectionVideo": "Видео",
"settingsVideoShowVideos": "Показывать видео",
"settingsVideoShowVideos": "Показать видео",
"settingsVideoEnableHardwareAcceleration": "Аппаратное ускорение",
"settingsVideoEnableAutoPlay": "Автозапуск воспроизведения",
"settingsVideoLoopModeTile": "Цикличный режим",
"settingsVideoLoopModeTile": "Циклический режим",
"settingsVideoLoopModeTitle": "Цикличный режим",
"settingsVideoQuickActionsTile": "Быстрые действия для видео",
"settingsVideoQuickActionEditorTitle": "Быстрые действия",
"settingsSubtitleThemeTile": "Субтитры",
"settingsSubtitleThemeTitle": "Субтитры",
"settingsSubtitleThemeSample": "Это образец.",
"settingsSubtitleThemeSample": "Образец.",
"settingsSubtitleThemeTextAlignmentTile": "Выравнивание текста",
"settingsSubtitleThemeTextAlignmentTitle": "Выравнивание текста",
"settingsSubtitleThemeTextSize": "Размер текста",
@ -438,7 +447,7 @@
"settingsSectionPrivacy": "Конфиденциальность",
"settingsAllowInstalledAppAccess": "Разрешить доступ к библиотеке приложения",
"settingsAllowInstalledAppAccessSubtitle": "Используется для улучшения отображения альбома",
"settingsAllowInstalledAppAccessSubtitle": "Используется для улучшения отображения альбомов",
"settingsAllowErrorReporting": "Разрешить анонимную отправку логов",
"settingsSaveSearchHistory": "Сохранять историю поиска",

View file

@ -28,5 +28,10 @@ void mainCommon(AppFlavor flavor) {
reportService.recordError(errorAndStacktrace.first, errorAndStacktrace.last);
}).sendPort);
// Errors during the widget build phase will show by default:
// - in debug mode: error on red background
// - in release mode: plain grey background
// This can be modified via `ErrorWidget.builder`
runApp(AvesApp(flavor: flavor));
}

View file

@ -5,6 +5,7 @@ import 'package:flutter/widgets.dart';
enum EntryInfoAction {
// general
editDate,
editLocation,
editRating,
editTags,
removeMetadata,
@ -15,6 +16,7 @@ enum EntryInfoAction {
class EntryInfoActions {
static const all = [
EntryInfoAction.editDate,
EntryInfoAction.editLocation,
EntryInfoAction.editRating,
EntryInfoAction.editTags,
EntryInfoAction.removeMetadata,
@ -28,6 +30,8 @@ extension ExtraEntryInfoAction on EntryInfoAction {
// general
case EntryInfoAction.editDate:
return context.l10n.entryInfoActionEditDate;
case EntryInfoAction.editLocation:
return context.l10n.entryInfoActionEditLocation;
case EntryInfoAction.editRating:
return context.l10n.entryInfoActionEditRating;
case EntryInfoAction.editTags:
@ -49,6 +53,8 @@ extension ExtraEntryInfoAction on EntryInfoAction {
// general
case EntryInfoAction.editDate:
return AIcons.date;
case EntryInfoAction.editLocation:
return AIcons.location;
case EntryInfoAction.editRating:
return AIcons.editRating;
case EntryInfoAction.editTags:

View file

@ -15,17 +15,18 @@ enum EntrySetAction {
// browsing or selecting
map,
stats,
rescan,
// selecting
share,
delete,
copy,
move,
rescan,
toggleFavourite,
rotateCCW,
rotateCW,
flip,
editDate,
editLocation,
editRating,
editTags,
removeMetadata,
@ -45,6 +46,7 @@ class EntrySetActions {
EntrySetAction.addShortcut,
EntrySetAction.map,
EntrySetAction.stats,
EntrySetAction.rescan,
];
static const selection = [
@ -53,9 +55,9 @@ class EntrySetActions {
EntrySetAction.copy,
EntrySetAction.move,
EntrySetAction.toggleFavourite,
EntrySetAction.rescan,
EntrySetAction.map,
EntrySetAction.stats,
EntrySetAction.rescan,
// editing actions are in their subsection
];
}
@ -85,6 +87,8 @@ extension ExtraEntrySetAction on EntrySetAction {
return context.l10n.menuActionMap;
case EntrySetAction.stats:
return context.l10n.menuActionStats;
case EntrySetAction.rescan:
return context.l10n.collectionActionRescan;
// selecting
case EntrySetAction.share:
return context.l10n.entryActionShare;
@ -94,8 +98,6 @@ extension ExtraEntrySetAction on EntrySetAction {
return context.l10n.collectionActionCopy;
case EntrySetAction.move:
return context.l10n.collectionActionMove;
case EntrySetAction.rescan:
return context.l10n.collectionActionRescan;
case EntrySetAction.toggleFavourite:
// different data depending on toggle state
return context.l10n.entryActionAddFavourite;
@ -107,6 +109,8 @@ extension ExtraEntrySetAction on EntrySetAction {
return context.l10n.entryActionFlip;
case EntrySetAction.editDate:
return context.l10n.entryInfoActionEditDate;
case EntrySetAction.editLocation:
return context.l10n.entryInfoActionEditLocation;
case EntrySetAction.editRating:
return context.l10n.entryInfoActionEditRating;
case EntrySetAction.editTags:
@ -144,6 +148,8 @@ extension ExtraEntrySetAction on EntrySetAction {
return AIcons.map;
case EntrySetAction.stats:
return AIcons.stats;
case EntrySetAction.rescan:
return AIcons.refresh;
// selecting
case EntrySetAction.share:
return AIcons.share;
@ -153,8 +159,6 @@ extension ExtraEntrySetAction on EntrySetAction {
return AIcons.copy;
case EntrySetAction.move:
return AIcons.move;
case EntrySetAction.rescan:
return AIcons.refresh;
case EntrySetAction.toggleFavourite:
// different data depending on toggle state
return AIcons.favourite;
@ -166,6 +170,8 @@ extension ExtraEntrySetAction on EntrySetAction {
return AIcons.flip;
case EntrySetAction.editDate:
return AIcons.date;
case EntrySetAction.editLocation:
return AIcons.location;
case EntrySetAction.editRating:
return AIcons.editRating;
case EntrySetAction.editTags:

View file

@ -1,12 +1,7 @@
import 'package:aves/model/device.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/durations.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:github/github.dart';
import 'package:google_api_availability/google_api_availability.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:version/version.dart';
abstract class AvesAvailability {
void onResume();
@ -18,12 +13,10 @@ abstract class AvesAvailability {
Future<bool> get canLocatePlaces;
Future<bool> get canUseGoogleMaps;
Future<bool> get isNewVersionAvailable;
}
class LiveAvesAvailability implements AvesAvailability {
bool? _isConnected, _hasPlayServices, _isNewVersionAvailable;
bool? _isConnected, _hasPlayServices;
LiveAvesAvailability() {
Connectivity().onConnectivityChanged.listen(_updateConnectivityFromResult);
@ -63,30 +56,4 @@ class LiveAvesAvailability implements AvesAvailability {
@override
Future<bool> get canUseGoogleMaps async => device.canRenderGoogleMaps && await hasPlayServices;
@override
Future<bool> get isNewVersionAvailable async {
if (_isNewVersionAvailable != null) return SynchronousFuture(_isNewVersionAvailable!);
final now = DateTime.now();
final dueDate = settings.lastVersionCheckDate.add(Durations.lastVersionCheckInterval);
if (now.isBefore(dueDate)) {
_isNewVersionAvailable = false;
return SynchronousFuture(_isNewVersionAvailable!);
}
if (!(await isConnected)) return false;
Version version(String s) => Version.parse(s.replaceFirst('v', ''));
final currentTag = (await PackageInfo.fromPlatform()).version;
final latestTag = (await GitHub().repositories.getLatestRelease(RepositorySlug('deckerst', 'aves'))).tagName!;
_isNewVersionAvailable = version(latestTag) > version(currentTag);
if (_isNewVersionAvailable!) {
debugPrint('Aves $latestTag is available on github');
} else {
debugPrint('Aves $currentTag is the latest version');
settings.lastVersionCheckDate = now;
}
return _isNewVersionAvailable!;
}
}

View file

@ -1,11 +1,12 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:collection/collection.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
final Covers covers = Covers._private();
@ -20,6 +21,8 @@ class Covers with ChangeNotifier {
int get count => _rows.length;
Set<CoverRow> get all => Set.unmodifiable(_rows);
int? coverContentId(CollectionFilter filter) => _rows.firstWhereOrNull((row) => row.filter == filter)?.contentId;
Future<void> set(CollectionFilter filter, int? contentId) async {
@ -75,6 +78,61 @@ class Covers with ChangeNotifier {
notifyListeners();
}
// import/export
List<Map<String, dynamic>>? export(CollectionSource source) {
final visibleEntries = source.visibleEntries;
final jsonList = covers.all
.map((row) {
final id = row.contentId;
final path = visibleEntries.firstWhereOrNull((entry) => id == entry.contentId)?.path;
if (path == null) return null;
final volume = androidFileUtils.getStorageVolume(path)?.path;
if (volume == null) return null;
final relativePath = path.substring(volume.length);
return {
'filter': row.filter.toJson(),
'volume': volume,
'relativePath': relativePath,
};
})
.whereNotNull()
.toList();
return jsonList.isNotEmpty ? jsonList : null;
}
void import(dynamic jsonList, CollectionSource source) {
if (jsonList is! List) {
debugPrint('failed to import covers for jsonMap=$jsonList');
return;
}
final visibleEntries = source.visibleEntries;
jsonList.forEach((row) {
final filter = CollectionFilter.fromJson(row['filter']);
if (filter == null) {
debugPrint('failed to import cover for row=$row');
return;
}
final volume = row['volume'];
final relativePath = row['relativePath'];
if (volume is String && relativePath is String) {
final path = pContext.join(volume, relativePath);
final entry = visibleEntries.firstWhereOrNull((entry) => entry.path == path && filter.test(entry));
if (entry != null) {
covers.set(filter, entry.contentId);
} else {
debugPrint('failed to import cover for path=$path, filter=$filter');
}
} else {
debugPrint('failed to import cover for volume=$volume, relativePath=$relativePath, filter=$filter');
}
});
}
}
@immutable

View file

@ -16,6 +16,7 @@ import 'package:aves/services/geocoding_service.dart';
import 'package:aves/services/metadata/svg_metadata_service.dart';
import 'package:aves/theme/format.dart';
import 'package:aves/utils/change_notifier.dart';
import 'package:aves/utils/time_utils.dart';
import 'package:collection/collection.dart';
import 'package:country_code/country_code.dart';
import 'package:flutter/foundation.dart';
@ -236,6 +237,8 @@ class AvesEntry {
bool get canEditDate => canEdit && (canEditExif || canEditXmp);
bool get canEditLocation => canEdit && canEditExif;
bool get canEditRating => canEdit && canEditXmp;
bool get canEditTags => canEdit && canEditXmp;
@ -348,15 +351,7 @@ class AvesEntry {
DateTime? _bestDate;
DateTime? get bestDate {
if (_bestDate == null) {
if ((_catalogDateMillis ?? 0) > 0) {
_bestDate = DateTime.fromMillisecondsSinceEpoch(_catalogDateMillis!);
} else if ((sourceDateTakenMillis ?? 0) > 0) {
_bestDate = DateTime.fromMillisecondsSinceEpoch(sourceDateTakenMillis!);
} else if ((dateModifiedSecs ?? 0) > 0) {
_bestDate = DateTime.fromMillisecondsSinceEpoch(dateModifiedSecs! * 1000);
}
}
_bestDate ??= dateTimeFromMillis(_catalogDateMillis) ?? dateTimeFromMillis(sourceDateTakenMillis) ?? dateTimeFromMillis((dateModifiedSecs ?? 0) * 1000);
return _bestDate;
}
@ -504,10 +499,13 @@ class AvesEntry {
}
Future<void> locate({required bool background, required bool force, required Locale geocoderLocale}) async {
if (!hasGps) return;
await _locateCountry(force: force);
if (await availability.canLocatePlaces) {
await locatePlace(background: background, force: force, geocoderLocale: geocoderLocale);
if (hasGps) {
await _locateCountry(force: force);
if (await availability.canLocatePlaces) {
await locatePlace(background: background, force: force, geocoderLocale: geocoderLocale);
}
} else {
addressDetails = null;
}
}
@ -748,13 +746,11 @@ class AvesEntry {
return c != 0 ? c : compareAsciiUpperCase(a.extension ?? '', b.extension ?? '');
}
static final _epoch = DateTime.fromMillisecondsSinceEpoch(0);
// compare by:
// 1) date descending
// 2) name descending
static int compareByDate(AvesEntry a, AvesEntry b) {
var c = (b.bestDate ?? _epoch).compareTo(a.bestDate ?? _epoch);
var c = (b.bestDate ?? epoch).compareTo(a.bestDate ?? epoch);
if (c != 0) return c;
return compareByName(b, a);
}

View file

@ -57,7 +57,7 @@ extension ExtraAvesEntryImages on AvesEntry {
bool _isReady(Object providerKey) => imageCache!.statusForKey(providerKey).keepAlive;
List<ThumbnailProvider> get cachedThumbnails => EntryCache.thumbnailRequestExtents.map(_getThumbnailProviderKey).where(_isReady).map((key) => ThumbnailProvider(key)).toList();
List<ThumbnailProvider> get cachedThumbnails => EntryCache.thumbnailRequestExtents.map(_getThumbnailProviderKey).where(_isReady).map(ThumbnailProvider.new).toList();
ThumbnailProvider get bestCachedThumbnail {
final sizedThumbnailKey = EntryCache.thumbnailRequestExtents.map(_getThumbnailProviderKey).firstWhereOrNull(_isReady);

View file

@ -4,12 +4,15 @@ import 'dart:io';
import 'package:aves/model/entry.dart';
import 'package:aves/model/metadata/date_modifier.dart';
import 'package:aves/model/metadata/enums.dart';
import 'package:aves/model/metadata/fields.dart';
import 'package:aves/ref/exif.dart';
import 'package:aves/ref/iptc.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/services/metadata/xmp.dart';
import 'package:aves/utils/time_utils.dart';
import 'package:aves/utils/xmp_utils.dart';
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:xml/xml.dart';
@ -73,6 +76,40 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
return dataTypes;
}
Future<Set<EntryDataType>> editLocation(LatLng? latLng) async {
final Set<EntryDataType> dataTypes = {};
await _missingDateCheckAndExifEdit(dataTypes);
// clear every GPS field
final exifFields = Map<MetadataField, dynamic>.fromEntries(MetadataFields.exifGpsFields.map((k) => MapEntry(k, null)));
// add latitude & longitude, if any
if (latLng != null) {
final latitude = latLng.latitude;
final longitude = latLng.longitude;
if (latitude != 0 && longitude != 0) {
exifFields.addAll({
MetadataField.exifGpsLatitude: latitude.abs(),
MetadataField.exifGpsLatitudeRef: latitude >= 0 ? Exif.latitudeNorth : Exif.latitudeSouth,
MetadataField.exifGpsLongitude: longitude.abs(),
MetadataField.exifGpsLongitudeRef: longitude >= 0 ? Exif.longitudeEast : Exif.longitudeWest,
});
}
}
final metadata = {
MetadataType.exif: Map<String, dynamic>.fromEntries(exifFields.entries.map((kv) => MapEntry(kv.key.exifInterfaceTag!, kv.value))),
};
final newFields = await metadataEditService.editMetadata(this, metadata);
if (newFields.isNotEmpty) {
dataTypes.addAll({
EntryDataType.catalog,
EntryDataType.address,
});
}
return dataTypes;
}
Future<Set<EntryDataType>> _changeOrientation(Future<Map<String, dynamic>> Function() apply) async {
final Set<EntryDataType> dataTypes = {};

View file

@ -1,5 +1,7 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:collection/collection.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/widgets.dart';
@ -17,6 +19,8 @@ class Favourites with ChangeNotifier {
int get count => _rows.length;
Set<int> get all => Set.unmodifiable(_rows.map((v) => v.contentId));
bool isFavourite(AvesEntry entry) => _rows.any((row) => row.contentId == entry.contentId);
FavouriteRow _entryToRow(AvesEntry entry) => FavouriteRow(contentId: entry.contentId!, path: entry.path!);
@ -59,6 +63,56 @@ class Favourites with ChangeNotifier {
notifyListeners();
}
// import/export
Map<String, List<String>>? export(CollectionSource source) {
final visibleEntries = source.visibleEntries;
final ids = favourites.all;
final paths = visibleEntries.where((entry) => ids.contains(entry.contentId)).map((entry) => entry.path).whereNotNull().toSet();
final byVolume = groupBy<String, StorageVolume?>(paths, androidFileUtils.getStorageVolume);
final jsonMap = Map.fromEntries(byVolume.entries.map((kv) {
final volume = kv.key?.path;
if (volume == null) return null;
final rootLength = volume.length;
final relativePaths = kv.value.map((v) => v.substring(rootLength)).toList();
return MapEntry(volume, relativePaths);
}).whereNotNull());
return jsonMap.isNotEmpty ? jsonMap : null;
}
void import(dynamic jsonMap, CollectionSource source) {
if (jsonMap is! Map) {
debugPrint('failed to import favourites for jsonMap=$jsonMap');
return;
}
final visibleEntries = source.visibleEntries;
final foundEntries = <AvesEntry>{};
final missedPaths = <String>{};
jsonMap.forEach((volume, relativePaths) {
if (volume is String && relativePaths is List) {
relativePaths.forEach((relativePath) {
final path = pContext.join(volume, relativePath);
final entry = visibleEntries.firstWhereOrNull((entry) => entry.path == path);
if (entry != null) {
foundEntries.add(entry);
} else {
missedPaths.add(path);
}
});
} else {
debugPrint('failed to import favourites for volume=$volume, relativePaths=${relativePaths.runtimeType}');
}
if (foundEntries.isNotEmpty) {
favourites.add(foundEntries);
}
if (missedPaths.isNotEmpty) {
debugPrint('failed to import favourites with ${missedPaths.length} missed paths');
}
});
}
}
@immutable

View file

@ -21,17 +21,21 @@ import 'package:flutter/widgets.dart';
abstract class CollectionFilter extends Equatable implements Comparable<CollectionFilter> {
static const List<String> categoryOrder = [
QueryFilter.type,
FavouriteFilter.type,
MimeFilter.type,
TypeFilter.type,
AlbumFilter.type,
TypeFilter.type,
LocationFilter.type,
CoordinateFilter.type,
FavouriteFilter.type,
RatingFilter.type,
TagFilter.type,
PathFilter.type,
];
final bool not;
const CollectionFilter({this.not = false});
static CollectionFilter? fromJson(String jsonString) {
if (jsonString.isEmpty) return null;
@ -69,8 +73,6 @@ abstract class CollectionFilter extends Equatable implements Comparable<Collecti
return null;
}
const CollectionFilter();
Map<String, dynamic> toMap();
String toJson() => jsonEncode(toMap());

View file

@ -12,23 +12,25 @@ class TagFilter extends CollectionFilter {
@override
List<Object?> get props => [tag];
TagFilter(this.tag) {
TagFilter(this.tag, {bool not = false}) : super(not: not) {
if (tag.isEmpty) {
_test = (entry) => entry.tags.isEmpty;
_test = not ? (entry) => entry.tags.isNotEmpty : (entry) => entry.tags.isEmpty;
} else {
_test = (entry) => entry.tags.contains(tag);
_test = not ? (entry) => !entry.tags.contains(tag) : (entry) => entry.tags.contains(tag);
}
}
TagFilter.fromMap(Map<String, dynamic> json)
: this(
json['tag'],
not: json['not'] ?? false,
);
@override
Map<String, dynamic> toMap() => {
'type': type,
'tag': tag,
'not': not,
};
@override

View file

@ -1,14 +1,16 @@
import 'package:aves/model/metadata/enums.dart';
import 'package:aves/model/metadata/fields.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
@immutable
class DateModifier {
class DateModifier extends Equatable {
static const writableDateFields = [
MetadataField.exifDate,
MetadataField.exifDateOriginal,
MetadataField.exifDateDigitized,
MetadataField.exifGpsDate,
MetadataField.exifGpsDatestamp,
MetadataField.xmpCreateDate,
];
@ -18,6 +20,9 @@ class DateModifier {
final DateFieldSource? copyFieldSource;
final int? shiftMinutes;
@override
List<Object?> get props => [action, fields, setDateTime, copyFieldSource, shiftMinutes];
const DateModifier._private(
this.action,
this.fields, {

View file

@ -1,10 +1,4 @@
enum MetadataField {
exifDate,
exifDateOriginal,
exifDateDigitized,
exifGpsDate,
xmpCreateDate,
}
import 'package:aves/model/metadata/fields.dart';
enum DateEditAction {
setCustom,
@ -91,35 +85,6 @@ extension ExtraMetadataType on MetadataType {
}
}
extension ExtraMetadataField on MetadataField {
MetadataType get type {
switch (this) {
case MetadataField.exifDate:
case MetadataField.exifDateOriginal:
case MetadataField.exifDateDigitized:
case MetadataField.exifGpsDate:
return MetadataType.exif;
case MetadataField.xmpCreateDate:
return MetadataType.xmp;
}
}
String? toExifInterfaceTag() {
switch (this) {
case MetadataField.exifDate:
return 'DateTime';
case MetadataField.exifDateOriginal:
return 'DateTimeOriginal';
case MetadataField.exifDateDigitized:
return 'DateTimeDigitized';
case MetadataField.exifGpsDate:
return 'GPSDateStamp';
case MetadataField.xmpCreateDate:
return null;
}
}
}
extension ExtraDateFieldSource on DateFieldSource {
MetadataField? toMetadataField() {
switch (this) {
@ -132,7 +97,7 @@ extension ExtraDateFieldSource on DateFieldSource {
case DateFieldSource.exifDateDigitized:
return MetadataField.exifDateDigitized;
case DateFieldSource.exifGpsDate:
return MetadataField.exifGpsDate;
return MetadataField.exifGpsDatestamp;
}
}
}

View file

@ -0,0 +1,199 @@
import 'package:aves/model/metadata/enums.dart';
enum MetadataField {
exifDate,
exifDateOriginal,
exifDateDigitized,
exifGpsAltitude,
exifGpsAltitudeRef,
exifGpsAreaInformation,
exifGpsDatestamp,
exifGpsDestBearing,
exifGpsDestBearingRef,
exifGpsDestDistance,
exifGpsDestDistanceRef,
exifGpsDestLatitude,
exifGpsDestLatitudeRef,
exifGpsDestLongitude,
exifGpsDestLongitudeRef,
exifGpsDifferential,
exifGpsDOP,
exifGpsHPositioningError,
exifGpsImgDirection,
exifGpsImgDirectionRef,
exifGpsLatitude,
exifGpsLatitudeRef,
exifGpsLongitude,
exifGpsLongitudeRef,
exifGpsMapDatum,
exifGpsMeasureMode,
exifGpsProcessingMethod,
exifGpsSatellites,
exifGpsSpeed,
exifGpsSpeedRef,
exifGpsStatus,
exifGpsTimestamp,
exifGpsTrack,
exifGpsTrackRef,
exifGpsVersionId,
xmpCreateDate,
}
class MetadataFields {
static const Set<MetadataField> exifGpsFields = {
MetadataField.exifGpsAltitude,
MetadataField.exifGpsAltitudeRef,
MetadataField.exifGpsAreaInformation,
MetadataField.exifGpsDatestamp,
MetadataField.exifGpsDestBearing,
MetadataField.exifGpsDestBearingRef,
MetadataField.exifGpsDestDistance,
MetadataField.exifGpsDestDistanceRef,
MetadataField.exifGpsDestLatitude,
MetadataField.exifGpsDestLatitudeRef,
MetadataField.exifGpsDestLongitude,
MetadataField.exifGpsDestLongitudeRef,
MetadataField.exifGpsDifferential,
MetadataField.exifGpsDOP,
MetadataField.exifGpsHPositioningError,
MetadataField.exifGpsImgDirection,
MetadataField.exifGpsImgDirectionRef,
MetadataField.exifGpsLatitude,
MetadataField.exifGpsLatitudeRef,
MetadataField.exifGpsLongitude,
MetadataField.exifGpsLongitudeRef,
MetadataField.exifGpsMapDatum,
MetadataField.exifGpsMeasureMode,
MetadataField.exifGpsProcessingMethod,
MetadataField.exifGpsSatellites,
MetadataField.exifGpsSpeed,
MetadataField.exifGpsSpeedRef,
MetadataField.exifGpsStatus,
MetadataField.exifGpsTimestamp,
MetadataField.exifGpsTrack,
MetadataField.exifGpsTrackRef,
MetadataField.exifGpsVersionId,
};
}
extension ExtraMetadataField on MetadataField {
MetadataType get type {
switch (this) {
case MetadataField.exifDate:
case MetadataField.exifDateOriginal:
case MetadataField.exifDateDigitized:
case MetadataField.exifGpsAltitude:
case MetadataField.exifGpsAltitudeRef:
case MetadataField.exifGpsAreaInformation:
case MetadataField.exifGpsDatestamp:
case MetadataField.exifGpsDestBearing:
case MetadataField.exifGpsDestBearingRef:
case MetadataField.exifGpsDestDistance:
case MetadataField.exifGpsDestDistanceRef:
case MetadataField.exifGpsDestLatitude:
case MetadataField.exifGpsDestLatitudeRef:
case MetadataField.exifGpsDestLongitude:
case MetadataField.exifGpsDestLongitudeRef:
case MetadataField.exifGpsDifferential:
case MetadataField.exifGpsDOP:
case MetadataField.exifGpsHPositioningError:
case MetadataField.exifGpsImgDirection:
case MetadataField.exifGpsImgDirectionRef:
case MetadataField.exifGpsLatitude:
case MetadataField.exifGpsLatitudeRef:
case MetadataField.exifGpsLongitude:
case MetadataField.exifGpsLongitudeRef:
case MetadataField.exifGpsMapDatum:
case MetadataField.exifGpsMeasureMode:
case MetadataField.exifGpsProcessingMethod:
case MetadataField.exifGpsSatellites:
case MetadataField.exifGpsSpeed:
case MetadataField.exifGpsSpeedRef:
case MetadataField.exifGpsStatus:
case MetadataField.exifGpsTimestamp:
case MetadataField.exifGpsTrack:
case MetadataField.exifGpsTrackRef:
case MetadataField.exifGpsVersionId:
return MetadataType.exif;
case MetadataField.xmpCreateDate:
return MetadataType.xmp;
}
}
String? get exifInterfaceTag {
switch (this) {
case MetadataField.exifDate:
return 'DateTime';
case MetadataField.exifDateOriginal:
return 'DateTimeOriginal';
case MetadataField.exifDateDigitized:
return 'DateTimeDigitized';
case MetadataField.exifGpsAltitude:
return 'GPSAltitude';
case MetadataField.exifGpsAltitudeRef:
return 'GPSAltitudeRef';
case MetadataField.exifGpsAreaInformation:
return 'GPSAreaInformation';
case MetadataField.exifGpsDatestamp:
return 'GPSDateStamp';
case MetadataField.exifGpsDestBearing:
return 'GPSDestBearing';
case MetadataField.exifGpsDestBearingRef:
return 'GPSDestBearingRef';
case MetadataField.exifGpsDestDistance:
return 'GPSDestDistance';
case MetadataField.exifGpsDestDistanceRef:
return 'GPSDestDistanceRef';
case MetadataField.exifGpsDestLatitude:
return 'GPSDestLatitude';
case MetadataField.exifGpsDestLatitudeRef:
return 'GPSDestLatitudeRef';
case MetadataField.exifGpsDestLongitude:
return 'GPSDestLongitude';
case MetadataField.exifGpsDestLongitudeRef:
return 'GPSDestLongitudeRef';
case MetadataField.exifGpsDifferential:
return 'GPSDifferential';
case MetadataField.exifGpsDOP:
return 'GPSDOP';
case MetadataField.exifGpsHPositioningError:
return 'GPSHPositioningError';
case MetadataField.exifGpsImgDirection:
return 'GPSImgDirection';
case MetadataField.exifGpsImgDirectionRef:
return 'GPSImgDirectionRef';
case MetadataField.exifGpsLatitude:
return 'GPSLatitude';
case MetadataField.exifGpsLatitudeRef:
return 'GPSLatitudeRef';
case MetadataField.exifGpsLongitude:
return 'GPSLongitude';
case MetadataField.exifGpsLongitudeRef:
return 'GPSLongitudeRef';
case MetadataField.exifGpsMapDatum:
return 'GPSMapDatum';
case MetadataField.exifGpsMeasureMode:
return 'GPSMeasureMode';
case MetadataField.exifGpsProcessingMethod:
return 'GPSProcessingMethod';
case MetadataField.exifGpsSatellites:
return 'GPSSatellites';
case MetadataField.exifGpsSpeed:
return 'GPSSpeed';
case MetadataField.exifGpsSpeedRef:
return 'GPSSpeedRef';
case MetadataField.exifGpsStatus:
return 'GPSStatus';
case MetadataField.exifGpsTimestamp:
return 'GPSTimeStamp';
case MetadataField.exifGpsTrack:
return 'GPSTrack';
case MetadataField.exifGpsTrackRef:
return 'GPSTrackRef';
case MetadataField.exifGpsVersionId:
return 'GPSVersionID';
case MetadataField.xmpCreateDate:
return null;
}
}
}

View file

@ -231,7 +231,7 @@ class SqfliteMetadataDb implements MetadataDb {
Future<Set<AvesEntry>> loadAllEntries() async {
final db = await _database;
final maps = await db.query(entryTable);
final entries = maps.map((map) => AvesEntry.fromMap(map)).toSet();
final entries = maps.map(AvesEntry.fromMap).toSet();
return entries;
}
@ -273,7 +273,7 @@ class SqfliteMetadataDb implements MetadataDb {
orderBy: 'sourceDateTakenMillis DESC',
limit: limit,
);
return maps.map((map) => AvesEntry.fromMap(map)).toSet();
return maps.map(AvesEntry.fromMap).toSet();
}
// date taken
@ -306,7 +306,7 @@ class SqfliteMetadataDb implements MetadataDb {
Future<List<CatalogMetadata>> loadAllMetadataEntries() async {
final db = await _database;
final maps = await db.query(metadataTable);
final metadataEntries = maps.map((map) => CatalogMetadata.fromMap(map)).toList();
final metadataEntries = maps.map(CatalogMetadata.fromMap).toList();
return metadataEntries;
}
@ -367,7 +367,7 @@ class SqfliteMetadataDb implements MetadataDb {
Future<List<AddressDetails>> loadAllAddresses() async {
final db = await _database;
final maps = await db.query(addressTable);
final addresses = maps.map((map) => AddressDetails.fromMap(map)).toList();
final addresses = maps.map(AddressDetails.fromMap).toList();
return addresses;
}
@ -413,7 +413,7 @@ class SqfliteMetadataDb implements MetadataDb {
Future<Set<FavouriteRow>> loadAllFavourites() async {
final db = await _database;
final maps = await db.query(favouriteTable);
final rows = maps.map((map) => FavouriteRow.fromMap(map)).toSet();
final rows = maps.map(FavouriteRow.fromMap).toSet();
return rows;
}

View file

@ -36,7 +36,7 @@ class MultiPageInfo {
factory MultiPageInfo.fromPageMaps(AvesEntry mainEntry, List<Map> pageMaps) {
return MultiPageInfo(
mainEntry: mainEntry,
pages: pageMaps.map((page) => SinglePageInfo.fromMap(page)).toList(),
pages: pageMaps.map(SinglePageInfo.fromMap).toList(),
);
}

View file

@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:aves/l10n/l10n.dart';
@ -35,7 +34,6 @@ class Settings extends ChangeNotifier {
catalogTimeZoneKey,
videoShowRawTimedTextKey,
searchHistoryKey,
lastVersionCheckDateKey,
};
// app
@ -116,9 +114,6 @@ class Settings extends ChangeNotifier {
static const accessibilityAnimationsKey = 'accessibility_animations';
static const timeToTakeActionKey = 'time_to_take_action';
// version
static const lastVersionCheckDateKey = 'last_version_check_date';
// file picker
static const filePickerShowHiddenFilesKey = 'file_picker_show_hidden_files';
@ -478,12 +473,6 @@ class Settings extends ChangeNotifier {
set timeToTakeAction(AccessibilityTimeout newValue) => setAndNotify(timeToTakeActionKey, newValue.toString());
// version
DateTime get lastVersionCheckDate => DateTime.fromMillisecondsSinceEpoch(_prefs!.getInt(lastVersionCheckDateKey) ?? 0);
set lastVersionCheckDate(DateTime newValue) => setAndNotify(lastVersionCheckDateKey, newValue.millisecondsSinceEpoch);
// file picker
bool get filePickerShowHiddenFiles => getBoolOrDefault(filePickerShowHiddenFilesKey, SettingsDefaults.filePickerShowHiddenFiles);
@ -580,12 +569,11 @@ class Settings extends ChangeNotifier {
// import/export
String toJson() => jsonEncode(Map.fromEntries(
Map<String, dynamic> export() => Map.fromEntries(
_prefs!.getKeys().whereNot(internalKeys.contains).map((k) => MapEntry(k, _prefs!.get(k))),
));
);
Future<void> fromJson(String jsonString) async {
final jsonMap = jsonDecode(jsonString);
Future<void> import(dynamic jsonMap) async {
if (jsonMap is Map<String, dynamic>) {
// clear to restore defaults
await reset(includeInternalKeys: false);

View file

@ -146,9 +146,14 @@ mixin AlbumMixin on SourceBase {
_filterEntryCountMap.clear();
_filterRecentEntryMap.clear();
} else {
directories ??= entries!.map((entry) => entry.directory).toSet();
directories.forEach(_filterEntryCountMap.remove);
directories.forEach(_filterRecentEntryMap.remove);
directories ??= {};
if (entries != null) {
directories.addAll(entries.map((entry) => entry.directory).whereNotNull());
}
directories.forEach((directory) {
_filterEntryCountMap.remove(directory);
_filterRecentEntryMap.remove(directory);
});
}
eventBus.fire(AlbumSummaryInvalidatedEvent(directories));
}

View file

@ -84,8 +84,8 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
_visibleEntries = null;
_sortedEntriesByDate = null;
invalidateAlbumFilterSummary(entries: entries);
invalidateCountryFilterSummary(entries);
invalidateTagFilterSummary(entries);
invalidateCountryFilterSummary(entries: entries);
invalidateTagFilterSummary(entries: entries);
}
void updateDerivedFilters([Set<AvesEntry>? entries]) {
@ -292,6 +292,18 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
Future<void> refreshEntry(AvesEntry entry, Set<EntryDataType> dataTypes) async {
await entry.refresh(background: false, persist: true, dataTypes: dataTypes, geocoderLocale: settings.appliedLocale);
// update/delete in DB
final contentId = entry.contentId!;
if (dataTypes.contains(EntryDataType.catalog)) {
await metadataDb.updateMetadataId(contentId, entry.catalogMetadata);
onCatalogMetadataChanged();
}
if (dataTypes.contains(EntryDataType.address)) {
await metadataDb.updateAddressId(contentId, entry.addressDetails);
onAddressMetadataChanged();
}
updateDerivedFilters({entry});
eventBus.fire(EntryRefreshedEvent({entry}));
}

View file

@ -30,6 +30,12 @@ mixin LocationMixin on SourceBase {
Future<void> locateEntries(AnalysisController controller, Set<AvesEntry> candidateEntries) async {
await _locateCountries(controller, candidateEntries);
await _locatePlaces(controller, candidateEntries);
final unlocatedIds = candidateEntries.where((entry) => !entry.hasGps).map((entry) => entry.contentId).whereNotNull().toSet();
if (unlocatedIds.isNotEmpty) {
await metadataDb.removeIds(unlocatedIds, dataTypes: {EntryDataType.address});
onAddressMetadataChanged();
}
}
static bool locateCountriesTest(AvesEntry entry) => entry.hasGps && !entry.hasAddress;
@ -176,16 +182,21 @@ mixin LocationMixin on SourceBase {
final Map<String, int> _filterEntryCountMap = {};
final Map<String, AvesEntry?> _filterRecentEntryMap = {};
void invalidateCountryFilterSummary([Set<AvesEntry>? entries]) {
void invalidateCountryFilterSummary({Set<AvesEntry>? entries, Set<String>? countryCodes}) {
if (_filterEntryCountMap.isEmpty && _filterRecentEntryMap.isEmpty) return;
Set<String>? countryCodes;
if (entries == null) {
if (entries == null && countryCodes == null) {
_filterEntryCountMap.clear();
_filterRecentEntryMap.clear();
} else {
countryCodes = entries.where((entry) => entry.hasAddress).map((entry) => entry.addressDetails?.countryCode).whereNotNull().toSet();
countryCodes.forEach(_filterEntryCountMap.remove);
countryCodes ??= {};
if (entries != null) {
countryCodes.addAll(entries.where((entry) => entry.hasAddress).map((entry) => entry.addressDetails?.countryCode).whereNotNull());
}
countryCodes.forEach((countryCode) {
_filterEntryCountMap.remove(countryCode);
_filterRecentEntryMap.remove(countryCode);
});
}
eventBus.fire(CountrySummaryInvalidatedEvent(countryCodes));
}

View file

@ -77,16 +77,21 @@ mixin TagMixin on SourceBase {
final Map<String, int> _filterEntryCountMap = {};
final Map<String, AvesEntry?> _filterRecentEntryMap = {};
void invalidateTagFilterSummary([Set<AvesEntry>? entries]) {
void invalidateTagFilterSummary({Set<AvesEntry>? entries, Set<String>? tags}) {
if (_filterEntryCountMap.isEmpty && _filterRecentEntryMap.isEmpty) return;
Set<String>? tags;
if (entries == null) {
if (entries == null && tags == null) {
_filterEntryCountMap.clear();
_filterRecentEntryMap.clear();
} else {
tags = entries.where((entry) => entry.isCatalogued).expand((entry) => entry.tags).toSet();
tags.forEach(_filterEntryCountMap.remove);
tags ??= {};
if (entries != null) {
tags.addAll(entries.where((entry) => entry.isCatalogued).expand((entry) => entry.tags));
}
tags.forEach((tag) {
_filterEntryCountMap.remove(tag);
_filterRecentEntryMap.remove(tag);
});
}
eventBus.fire(TagSummaryInvalidatedEvent(tags));
}

View file

@ -15,14 +15,15 @@ import 'package:aves/theme/format.dart';
import 'package:aves/utils/file_utils.dart';
import 'package:aves/utils/math_utils.dart';
import 'package:aves/utils/string_utils.dart';
import 'package:aves/utils/time_utils.dart';
import 'package:aves/widgets/viewer/video/fijkplayer.dart';
import 'package:collection/collection.dart';
import 'package:fijkplayer/fijkplayer.dart';
import 'package:flutter/foundation.dart';
class VideoMetadataFormatter {
static final _epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
static final _anotherDatePattern = RegExp(r'(\d{4})[-/](\d{2})[-/](\d{2}) (\d{2}):(\d{2}):(\d{2})');
static final _dateY4M2D2H2m2s2Pattern = RegExp(r'(\d{4})[-/](\d{2})[-/](\d{2}) (\d{2}):(\d{2}):(\d{2})');
static final _dateY4M2D2H2m2s2APmPattern = RegExp(r'(\d{4})[-/](\d{2})[-/](\d{2})T(\d+):(\d+):(\d+) ([ap]m)Z');
static final _durationPattern = RegExp(r'(\d+):(\d+):(\d+)(.\d+)');
static final _locationPattern = RegExp(r'([+-][.0-9]+)');
static final Map<String, String> _codecNames = {
@ -115,9 +116,10 @@ class VideoMetadataFormatter {
// `DateTime` does not recognize these values found in the wild:
// - `UTC 2021-05-30 19:14:21`
// - `2021/10/31 21:23:17`
// - `2021-09-10T7:14:49 pmZ`
// - `2021` (not enough to build a date)
final match = _anotherDatePattern.firstMatch(dateString);
var match = _dateY4M2D2H2m2s2Pattern.firstMatch(dateString);
if (match != null) {
final year = int.tryParse(match.group(1)!);
final month = int.tryParse(match.group(2)!);
@ -132,6 +134,22 @@ class VideoMetadataFormatter {
}
}
match = _dateY4M2D2H2m2s2APmPattern.firstMatch(dateString);
if (match != null) {
final year = int.tryParse(match.group(1)!);
final month = int.tryParse(match.group(2)!);
final day = int.tryParse(match.group(3)!);
final hour = int.tryParse(match.group(4)!);
final minute = int.tryParse(match.group(5)!);
final second = int.tryParse(match.group(6)!);
final pm = match.group(7) == 'pm';
if (year != null && month != null && day != null && hour != null && minute != null && second != null) {
final date = DateTime(year, month, day, hour + (pm ? 12 : 0), minute, second, 0);
return date.millisecondsSinceEpoch;
}
}
return null;
}
@ -349,7 +367,7 @@ class VideoMetadataFormatter {
static String? _formatDate(String value) {
final date = DateTime.tryParse(value);
if (date == null) return value;
if (date == _epoch) return null;
if (date == epoch) return null;
return date.toIso8601String();
}

Some files were not shown because too many files have changed in this diff Show more