diff --git a/.gitignore b/.gitignore index 0fa6b675c..8ee0fc58c 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,7 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +# screenshot generation +/test_driver/assets/screenshots/ +/screenshots/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 006314a32..3e4465e83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,29 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [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 + ## [v1.5.10] - 2022-01-07 ### Added diff --git a/README.md b/README.md index c4975eba2..c7dec61cd 100644 --- a/README.md +++ b/README.md @@ -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 -Collection screenshotImage screenshotStats screenshotInfo (basic) screenshotInfo (metadata) screenshotCountries screenshot +
+ +[Collection screenshot](https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/play/en/1.png) +[Image screenshot](https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/play/en/2.png) +[Stats screenshot](https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/play/en/5.png) +[Info (basic) screenshot](https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/play/en/3.png) +[Info (metadata) screenshot](https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/play/en/4.png) +[Countries screenshot](https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/play/en/6.png) + +
## 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 diff --git a/android/app/build.gradle b/android/app/build.gradle index 9116411b4..8acc2d333 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -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 { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt index e906ab72c..f56a7f838 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt @@ -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 } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/GeocodingHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/GeocodingHandler.kt index 17ced0c0b..ddb100edd 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/GeocodingHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/GeocodingHandler.kt @@ -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 diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt index 5c9d40242..68c1c5293 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt @@ -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) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt index a305180b2..a574fb74b 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt @@ -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) }) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/decoder/SvgGlideModule.kt b/android/app/src/main/kotlin/deckers/thibault/aves/decoder/SvgGlideModule.kt index 4a7a048f6..d544ba203 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/decoder/SvgGlideModule.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/decoder/SvgGlideModule.kt @@ -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 { - override fun buildLoadData(model: SvgThumbnail, width: Int, height: Int, options: Options): ModelLoader.LoadData { +internal class SvgLoader : ModelLoader { + override fun buildLoadData(model: SvgImage, width: Int, height: Int, options: Options): ModelLoader.LoadData { 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 { - override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader = SvgLoader() + internal class Factory : ModelLoaderFactory { + override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader = SvgLoader() override fun teardown() {} } } -internal class SvgFetcher(val model: SvgThumbnail, val width: Int, val height: Int) : DataFetcher { +internal class SvgFetcher(val model: SvgImage, val width: Int, val height: Int) : DataFetcher { override fun loadData(priority: Priority, callback: DataFetcher.DataCallback) { val context = model.context val uri = model.uri diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifInterfaceHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifInterfaceHelper.kt index 102387552..2fc085469 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifInterfaceHelper.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifInterfaceHelper.kt @@ -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): Boolean { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MediaMetadataRetrieverHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MediaMetadataRetrieverHelper.kt index cbb92eee9..cfde73855 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MediaMetadataRetrieverHelper.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MediaMetadataRetrieverHelper.kt @@ -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) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt index 84d5959f2..61b17d9c9 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt @@ -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 diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt index 9ef5a9112..0466552bd 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt @@ -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, + 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? = 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() if (!editIptc( diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt index 4e4a305d1..4ada34d6f 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt @@ -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 { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt index c801c30ec..bc9f217f2 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt @@ -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 { // Final set of paths val paths = HashSet() - // 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 - 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 + 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()) { diff --git a/android/app/src/main/res/values-pt/strings.xml b/android/app/src/main/res/values-pt/strings.xml new file mode 100644 index 000000000..336c769db --- /dev/null +++ b/android/app/src/main/res/values-pt/strings.xml @@ -0,0 +1,10 @@ + + + Aves + Procurar + Vídeos + Digitalização de mídia + Digitalizar imagens & vídeos + Digitalizando mídia + Pare + diff --git a/android/build.gradle b/android/build.gradle index a5d165073..57fd9118c 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -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' diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index b83b4bcb4..6672c658d 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/fastlane/metadata/android/de/full_description.txt b/fastlane/metadata/android/de/full_description.txt index 8e7929a6f..6f57ace2b 100644 --- a/fastlane/metadata/android/de/full_description.txt +++ b/fastlane/metadata/android/de/full_description.txt @@ -2,4 +2,4 @@ Navigation und Suche ist ein wichtiger Bestandteil von Aves. Das Ziel besteht darin, dass Benutzer problemlos von Alben zu Fotos zu Tags zu Karten usw. wechseln können. -Aves lässt sich mit Android (von API 19 bis 31, d. h. von KitKat bis S) mit Funktionen wie App-Verknüpfungen und globaler Suche integrieren. Es funktioniert auch als Medienbetrachter und -auswahl. \ No newline at end of file +Aves lässt sich mit Android (von API 19 bis 32, d. h. von KitKat bis Android 12L) mit Funktionen wie App-Verknüpfungen und globaler Suche integrieren. Es funktioniert auch als Medienbetrachter und -auswahl. \ No newline at end of file diff --git a/fastlane/metadata/android/de/images/phoneScreenshots/1.png b/fastlane/metadata/android/de/images/phoneScreenshots/1.png new file mode 100644 index 000000000..7bb979df1 Binary files /dev/null and b/fastlane/metadata/android/de/images/phoneScreenshots/1.png differ diff --git a/fastlane/metadata/android/de/images/phoneScreenshots/2.png b/fastlane/metadata/android/de/images/phoneScreenshots/2.png new file mode 100644 index 000000000..b770a7c9e Binary files /dev/null and b/fastlane/metadata/android/de/images/phoneScreenshots/2.png differ diff --git a/fastlane/metadata/android/de/images/phoneScreenshots/3.png b/fastlane/metadata/android/de/images/phoneScreenshots/3.png new file mode 100644 index 000000000..56ef9ded6 Binary files /dev/null and b/fastlane/metadata/android/de/images/phoneScreenshots/3.png differ diff --git a/fastlane/metadata/android/de/images/phoneScreenshots/4.png b/fastlane/metadata/android/de/images/phoneScreenshots/4.png new file mode 100644 index 000000000..0a8c2deb7 Binary files /dev/null and b/fastlane/metadata/android/de/images/phoneScreenshots/4.png differ diff --git a/fastlane/metadata/android/de/images/phoneScreenshots/5.png b/fastlane/metadata/android/de/images/phoneScreenshots/5.png new file mode 100644 index 000000000..a048d09d4 Binary files /dev/null and b/fastlane/metadata/android/de/images/phoneScreenshots/5.png differ diff --git a/fastlane/metadata/android/de/images/phoneScreenshots/6.png b/fastlane/metadata/android/de/images/phoneScreenshots/6.png new file mode 100644 index 000000000..e42e4d034 Binary files /dev/null and b/fastlane/metadata/android/de/images/phoneScreenshots/6.png differ diff --git a/fastlane/metadata/android/en-US/changelogs/1064.txt b/fastlane/metadata/android/en-US/changelogs/1064.txt new file mode 100644 index 000000000..3ce411869 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/1064.txt @@ -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 \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/1065.txt b/fastlane/metadata/android/en-US/changelogs/1065.txt new file mode 100644 index 000000000..c75dc2346 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/1065.txt @@ -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 \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index 8a74c8d0b..eba12d85e 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -2,4 +2,4 @@ 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. \ No newline at end of file +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. \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png index 1b9e0262c..4674df71c 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png index b2e45ff00..d699af62a 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png index f8e7cee24..20457a45f 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png index dc95c05ab..92fec2632 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png index 64a8a9588..95ef69497 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png index 336e6e21d..c2383bd70 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png differ diff --git a/fastlane/metadata/android/es-MX/full_description.txt b/fastlane/metadata/android/es-MX/full_description.txt index 5015acfec..68859bb5d 100644 --- a/fastlane/metadata/android/es-MX/full_description.txt +++ b/fastlane/metadata/android/es-MX/full_description.txt @@ -2,4 +2,4 @@ La navegación y búsqueda son partes importantes de Aves. Su propósito es que los usuarios puedan fácimente ir de álbumes a fotos, etiquetas, mapas, etc. -Aves se integra con Android (desde API 19 a 31, por ej. desde KitKat hasta S) con características como vínculos de aplicación y manejo de búsqueda global. También funciona como un visor y seleccionador multimedia. \ No newline at end of file +Aves se integra con Android (desde API 19 a 32, por ej. desde KitKat hasta Android 12L) con características como vínculos de aplicación y manejo de búsqueda global. También funciona como un visor y seleccionador multimedia. \ No newline at end of file diff --git a/fastlane/metadata/android/es-MX/images/phoneScreenshots/1.png b/fastlane/metadata/android/es-MX/images/phoneScreenshots/1.png new file mode 100644 index 000000000..a4ca6bdf7 Binary files /dev/null and b/fastlane/metadata/android/es-MX/images/phoneScreenshots/1.png differ diff --git a/fastlane/metadata/android/es-MX/images/phoneScreenshots/2.png b/fastlane/metadata/android/es-MX/images/phoneScreenshots/2.png new file mode 100644 index 000000000..bc1654404 Binary files /dev/null and b/fastlane/metadata/android/es-MX/images/phoneScreenshots/2.png differ diff --git a/fastlane/metadata/android/es-MX/images/phoneScreenshots/3.png b/fastlane/metadata/android/es-MX/images/phoneScreenshots/3.png new file mode 100644 index 000000000..f86f0c4a2 Binary files /dev/null and b/fastlane/metadata/android/es-MX/images/phoneScreenshots/3.png differ diff --git a/fastlane/metadata/android/es-MX/images/phoneScreenshots/4.png b/fastlane/metadata/android/es-MX/images/phoneScreenshots/4.png new file mode 100644 index 000000000..b5eb49bcc Binary files /dev/null and b/fastlane/metadata/android/es-MX/images/phoneScreenshots/4.png differ diff --git a/fastlane/metadata/android/es-MX/images/phoneScreenshots/5.png b/fastlane/metadata/android/es-MX/images/phoneScreenshots/5.png new file mode 100644 index 000000000..7021c0374 Binary files /dev/null and b/fastlane/metadata/android/es-MX/images/phoneScreenshots/5.png differ diff --git a/fastlane/metadata/android/es-MX/images/phoneScreenshots/6.png b/fastlane/metadata/android/es-MX/images/phoneScreenshots/6.png new file mode 100644 index 000000000..7735c2cb3 Binary files /dev/null and b/fastlane/metadata/android/es-MX/images/phoneScreenshots/6.png differ diff --git a/fastlane/metadata/android/fr/images/featureGraphic.png b/fastlane/metadata/android/fr/images/featureGraphic.png new file mode 100644 index 000000000..a0b3a3e77 Binary files /dev/null and b/fastlane/metadata/android/fr/images/featureGraphic.png differ diff --git a/fastlane/metadata/android/fr/images/phoneScreenshots/1.png b/fastlane/metadata/android/fr/images/phoneScreenshots/1.png new file mode 100644 index 000000000..eb62e1b14 Binary files /dev/null and b/fastlane/metadata/android/fr/images/phoneScreenshots/1.png differ diff --git a/fastlane/metadata/android/fr/images/phoneScreenshots/2.png b/fastlane/metadata/android/fr/images/phoneScreenshots/2.png new file mode 100644 index 000000000..6aa5def2a Binary files /dev/null and b/fastlane/metadata/android/fr/images/phoneScreenshots/2.png differ diff --git a/fastlane/metadata/android/fr/images/phoneScreenshots/3.png b/fastlane/metadata/android/fr/images/phoneScreenshots/3.png new file mode 100644 index 000000000..ad50c09d1 Binary files /dev/null and b/fastlane/metadata/android/fr/images/phoneScreenshots/3.png differ diff --git a/fastlane/metadata/android/fr/images/phoneScreenshots/4.png b/fastlane/metadata/android/fr/images/phoneScreenshots/4.png new file mode 100644 index 000000000..070238313 Binary files /dev/null and b/fastlane/metadata/android/fr/images/phoneScreenshots/4.png differ diff --git a/fastlane/metadata/android/fr/images/phoneScreenshots/5.png b/fastlane/metadata/android/fr/images/phoneScreenshots/5.png new file mode 100644 index 000000000..cc70a692c Binary files /dev/null and b/fastlane/metadata/android/fr/images/phoneScreenshots/5.png differ diff --git a/fastlane/metadata/android/fr/images/phoneScreenshots/6.png b/fastlane/metadata/android/fr/images/phoneScreenshots/6.png new file mode 100644 index 000000000..7961778de Binary files /dev/null and b/fastlane/metadata/android/fr/images/phoneScreenshots/6.png differ diff --git a/fastlane/metadata/android/ko/images/featureGraphics.png b/fastlane/metadata/android/ko/images/featureGraphics.png new file mode 100644 index 000000000..cb81a914d Binary files /dev/null and b/fastlane/metadata/android/ko/images/featureGraphics.png differ diff --git a/fastlane/metadata/android/ko/images/phoneScreenshots/1.png b/fastlane/metadata/android/ko/images/phoneScreenshots/1.png new file mode 100644 index 000000000..d4fcc34c3 Binary files /dev/null and b/fastlane/metadata/android/ko/images/phoneScreenshots/1.png differ diff --git a/fastlane/metadata/android/ko/images/phoneScreenshots/2.png b/fastlane/metadata/android/ko/images/phoneScreenshots/2.png new file mode 100644 index 000000000..13a2135ee Binary files /dev/null and b/fastlane/metadata/android/ko/images/phoneScreenshots/2.png differ diff --git a/fastlane/metadata/android/ko/images/phoneScreenshots/3.png b/fastlane/metadata/android/ko/images/phoneScreenshots/3.png new file mode 100644 index 000000000..b9593c029 Binary files /dev/null and b/fastlane/metadata/android/ko/images/phoneScreenshots/3.png differ diff --git a/fastlane/metadata/android/ko/images/phoneScreenshots/4.png b/fastlane/metadata/android/ko/images/phoneScreenshots/4.png new file mode 100644 index 000000000..3bfa8781a Binary files /dev/null and b/fastlane/metadata/android/ko/images/phoneScreenshots/4.png differ diff --git a/fastlane/metadata/android/ko/images/phoneScreenshots/5.png b/fastlane/metadata/android/ko/images/phoneScreenshots/5.png new file mode 100644 index 000000000..ad6518250 Binary files /dev/null and b/fastlane/metadata/android/ko/images/phoneScreenshots/5.png differ diff --git a/fastlane/metadata/android/ko/images/phoneScreenshots/6.png b/fastlane/metadata/android/ko/images/phoneScreenshots/6.png new file mode 100644 index 000000000..6aaa08e7e Binary files /dev/null and b/fastlane/metadata/android/ko/images/phoneScreenshots/6.png differ diff --git a/fastlane/metadata/android/pt-BR/full_description.txt b/fastlane/metadata/android/pt-BR/full_description.txt new file mode 100644 index 000000000..76ce49ba4 --- /dev/null +++ b/fastlane/metadata/android/pt-BR/full_description.txt @@ -0,0 +1,5 @@ +Aves 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 páginas múltiplas TIFFs, SVGs, AVIs antigos e muito mais! Ele verifica sua coleção de mídia para identificar fotos em movimento, panoramas (aka photo spheres), vídeos em 360°, assim como GeoTIFF arquivos. + +Navegação e pesquisa é uma parte importante do Aves. O objetivo é que os usuários fluam facilmente de álbuns para fotos, etiquetas, mapas, etc. + +Aves integra com Android (de API 19 para 32, i.e. de KitKat para Android 12L) com recursos como atalhos de apps e pesquisa global manipulação. Também funciona como um visualizador e selecionador de mídia. diff --git a/fastlane/metadata/android/pt-BR/images/featureGraphics.png b/fastlane/metadata/android/pt-BR/images/featureGraphics.png new file mode 100644 index 000000000..677f87431 Binary files /dev/null and b/fastlane/metadata/android/pt-BR/images/featureGraphics.png differ diff --git a/fastlane/metadata/android/pt-BR/images/phoneScreenshots/1.png b/fastlane/metadata/android/pt-BR/images/phoneScreenshots/1.png new file mode 100644 index 000000000..8763df15d Binary files /dev/null and b/fastlane/metadata/android/pt-BR/images/phoneScreenshots/1.png differ diff --git a/fastlane/metadata/android/pt-BR/images/phoneScreenshots/2.png b/fastlane/metadata/android/pt-BR/images/phoneScreenshots/2.png new file mode 100644 index 000000000..8c6c9597f Binary files /dev/null and b/fastlane/metadata/android/pt-BR/images/phoneScreenshots/2.png differ diff --git a/fastlane/metadata/android/pt-BR/images/phoneScreenshots/3.png b/fastlane/metadata/android/pt-BR/images/phoneScreenshots/3.png new file mode 100644 index 000000000..200da6361 Binary files /dev/null and b/fastlane/metadata/android/pt-BR/images/phoneScreenshots/3.png differ diff --git a/fastlane/metadata/android/pt-BR/images/phoneScreenshots/4.png b/fastlane/metadata/android/pt-BR/images/phoneScreenshots/4.png new file mode 100644 index 000000000..d72ae6282 Binary files /dev/null and b/fastlane/metadata/android/pt-BR/images/phoneScreenshots/4.png differ diff --git a/fastlane/metadata/android/pt-BR/images/phoneScreenshots/5.png b/fastlane/metadata/android/pt-BR/images/phoneScreenshots/5.png new file mode 100644 index 000000000..30a9ebd14 Binary files /dev/null and b/fastlane/metadata/android/pt-BR/images/phoneScreenshots/5.png differ diff --git a/fastlane/metadata/android/pt-BR/images/phoneScreenshots/6.png b/fastlane/metadata/android/pt-BR/images/phoneScreenshots/6.png new file mode 100644 index 000000000..02b87189c Binary files /dev/null and b/fastlane/metadata/android/pt-BR/images/phoneScreenshots/6.png differ diff --git a/fastlane/metadata/android/pt-BR/short_description.txt b/fastlane/metadata/android/pt-BR/short_description.txt new file mode 100644 index 000000000..df48f8c44 --- /dev/null +++ b/fastlane/metadata/android/pt-BR/short_description.txt @@ -0,0 +1 @@ +Galeria e explorador de metadados diff --git a/fastlane/metadata/android/ru/images/featureGraphics.png b/fastlane/metadata/android/ru/images/featureGraphics.png new file mode 100644 index 000000000..5fa1a582a Binary files /dev/null and b/fastlane/metadata/android/ru/images/featureGraphics.png differ diff --git a/fastlane/metadata/android/ru/images/phoneScreenshots/1.png b/fastlane/metadata/android/ru/images/phoneScreenshots/1.png new file mode 100644 index 000000000..80af968b3 Binary files /dev/null and b/fastlane/metadata/android/ru/images/phoneScreenshots/1.png differ diff --git a/fastlane/metadata/android/ru/images/phoneScreenshots/2.png b/fastlane/metadata/android/ru/images/phoneScreenshots/2.png new file mode 100644 index 000000000..1d42a1cb0 Binary files /dev/null and b/fastlane/metadata/android/ru/images/phoneScreenshots/2.png differ diff --git a/fastlane/metadata/android/ru/images/phoneScreenshots/3.png b/fastlane/metadata/android/ru/images/phoneScreenshots/3.png new file mode 100644 index 000000000..811a42242 Binary files /dev/null and b/fastlane/metadata/android/ru/images/phoneScreenshots/3.png differ diff --git a/fastlane/metadata/android/ru/images/phoneScreenshots/4.png b/fastlane/metadata/android/ru/images/phoneScreenshots/4.png new file mode 100644 index 000000000..daa6be34e Binary files /dev/null and b/fastlane/metadata/android/ru/images/phoneScreenshots/4.png differ diff --git a/fastlane/metadata/android/ru/images/phoneScreenshots/5.png b/fastlane/metadata/android/ru/images/phoneScreenshots/5.png new file mode 100644 index 000000000..b22e9d202 Binary files /dev/null and b/fastlane/metadata/android/ru/images/phoneScreenshots/5.png differ diff --git a/fastlane/metadata/android/ru/images/phoneScreenshots/6.png b/fastlane/metadata/android/ru/images/phoneScreenshots/6.png new file mode 100644 index 000000000..0a4a2630a Binary files /dev/null and b/fastlane/metadata/android/ru/images/phoneScreenshots/6.png differ diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 867ecab0d..438c05eca 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -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" +} \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a4b3ac6a8..0407e89cb 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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": "Don’t show hidden files", "filePickerOpenFrom": "Open from", "filePickerNoItems": "No items", - "filePickerUseThisFolder": "Use this folder", - "@filePickerUseThisFolder": {} + "filePickerUseThisFolder": "Use this folder" } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index f23a0af12..232abb8d3 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -4,10 +4,11 @@ "welcomeOptional": "Opcional", "welcomeTermsToggle": "Acepto los términos y condiciones", "itemCount": "{count, plural, =1{1 elemento} other{{count} elementos}}", - + "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", "nextButtonLabel": "SIGUIENTE", @@ -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", @@ -140,13 +141,13 @@ "restrictedAccessDialogMessage": "Esta aplicación no tiene permiso para modificar archivos de {directory} en «{volume}».\n\nPor favor use un gestor de archivos o la aplicación de galería preinstalada para mover los elementos a otro directorio.", "notEnoughSpaceDialogTitle": "Espacio insuficiente", "notEnoughSpaceDialogMessage": "Esta operación necesita {neededSize} de espacio libre en «{volume}» para completarse, pero sólo hay {freeSize} disponible.", - + "missingSystemFilePickerDialogTitle": "Selector de archivos del sistema no disponible", "missingSystemFilePickerDialogMessage": "El selector de archivos del sistema no se encuentra disponible o fue deshabilitado. Por favor habilítelo e intente nuevamente.", "unsupportedTypeDialogTitle": "Tipos de archivo incompatibles", "unsupportedTypeDialogMessage": "{count, plural, =1{Esta operación no está disponible para un elemento del siguiente tipo: {types}.} other{Esta operación no está disponible para elementos de los siguientes tipos: {types}.}}", - + "nameConflictDialogSingleSourceMessage": "Algunos archivos en el directorio de destino tienen el mismo nombre.", "nameConflictDialogMultipleSourceMessage": "Algunos archivos tienen el mismo nombre.", @@ -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", @@ -251,7 +245,7 @@ "aboutCreditsWorldAtlas2": "bajo licencia ISC.", "aboutCreditsTranslators": "Traductores:", "aboutCreditsTranslatorLine": "{language}: {names}", - + "aboutLicenses": "Licencias de código abierto", "aboutLicensesBanner": "Esta aplicación usa los siguientes paquetes y librerías de código abierto.", "aboutLicensesAndroidLibraries": "Librerías de Android", @@ -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" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 701a808e8..115812126 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -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 d’Aves est disponible sur", - "aboutUpdateLinks2": "et", - "aboutUpdateLinks3": ".", - "aboutUpdateGitHub": "GitHub", - "aboutUpdateGooglePlay": "Google Play", - "aboutBug": "Rapports d’erreur", "aboutBugSaveLogInstruction": "Sauvegarder les logs de l’app vers un fichier", "aboutBugSaveLogButton": "Sauvegarder", @@ -361,6 +365,10 @@ "settingsActionExport": "Exporter", "settingsActionImport": "Importer", + "appExportCovers": "Couvertures", + "appExportFavourites": "Favoris", + "appExportSettings": "Réglages", + "settingsSectionNavigation": "Navigation", "settingsHome": "Page d’accueil", "settingsKeepScreenOnTile": "Maintenir l’écran allumé", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index ac2d83ce4..2edf0589b 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -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": "화면 자동 꺼짐 방지", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb new file mode 100644 index 000000000..b4dfef910 --- /dev/null +++ b/lib/l10n/app_pt.arb @@ -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" +} diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 1d8649cc9..06d891c34 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -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": "Сохранять историю поиска", diff --git a/lib/main_common.dart b/lib/main_common.dart index 98d2790f0..1a1820c9d 100644 --- a/lib/main_common.dart +++ b/lib/main_common.dart @@ -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)); } diff --git a/lib/model/actions/entry_info_actions.dart b/lib/model/actions/entry_info_actions.dart index ffc6ca198..d23c65e02 100644 --- a/lib/model/actions/entry_info_actions.dart +++ b/lib/model/actions/entry_info_actions.dart @@ -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: diff --git a/lib/model/actions/entry_set_actions.dart b/lib/model/actions/entry_set_actions.dart index f3601560b..5ef1ac427 100644 --- a/lib/model/actions/entry_set_actions.dart +++ b/lib/model/actions/entry_set_actions.dart @@ -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: diff --git a/lib/model/availability.dart b/lib/model/availability.dart index 008d0e76c..bdbb691fd 100644 --- a/lib/model/availability.dart +++ b/lib/model/availability.dart @@ -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 get canLocatePlaces; Future get canUseGoogleMaps; - - Future 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 get canUseGoogleMaps async => device.canRenderGoogleMaps && await hasPlayServices; - - @override - Future 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!; - } } diff --git a/lib/model/covers.dart b/lib/model/covers.dart index d10e67ca7..2b339889c 100644 --- a/lib/model/covers.dart +++ b/lib/model/covers.dart @@ -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 get all => Set.unmodifiable(_rows); + int? coverContentId(CollectionFilter filter) => _rows.firstWhereOrNull((row) => row.filter == filter)?.contentId; Future set(CollectionFilter filter, int? contentId) async { @@ -75,6 +78,61 @@ class Covers with ChangeNotifier { notifyListeners(); } + + // import/export + + List>? 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 diff --git a/lib/model/entry.dart b/lib/model/entry.dart index be81490c3..20ee4573b 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -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 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); } diff --git a/lib/model/entry_images.dart b/lib/model/entry_images.dart index 57b4684c6..c10467f77 100644 --- a/lib/model/entry_images.dart +++ b/lib/model/entry_images.dart @@ -57,7 +57,7 @@ extension ExtraAvesEntryImages on AvesEntry { bool _isReady(Object providerKey) => imageCache!.statusForKey(providerKey).keepAlive; - List get cachedThumbnails => EntryCache.thumbnailRequestExtents.map(_getThumbnailProviderKey).where(_isReady).map((key) => ThumbnailProvider(key)).toList(); + List get cachedThumbnails => EntryCache.thumbnailRequestExtents.map(_getThumbnailProviderKey).where(_isReady).map(ThumbnailProvider.new).toList(); ThumbnailProvider get bestCachedThumbnail { final sizedThumbnailKey = EntryCache.thumbnailRequestExtents.map(_getThumbnailProviderKey).firstWhereOrNull(_isReady); diff --git a/lib/model/entry_metadata_edition.dart b/lib/model/entry_metadata_edition.dart index a69300c39..ad14a3923 100644 --- a/lib/model/entry_metadata_edition.dart +++ b/lib/model/entry_metadata_edition.dart @@ -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> editLocation(LatLng? latLng) async { + final Set dataTypes = {}; + + await _missingDateCheckAndExifEdit(dataTypes); + + // clear every GPS field + final exifFields = Map.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.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> _changeOrientation(Future> Function() apply) async { final Set dataTypes = {}; diff --git a/lib/model/favourites.dart b/lib/model/favourites.dart index aef618695..cc8768f1e 100644 --- a/lib/model/favourites.dart +++ b/lib/model/favourites.dart @@ -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 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>? 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(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 = {}; + final missedPaths = {}; + 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 diff --git a/lib/model/filters/filters.dart b/lib/model/filters/filters.dart index f2389dce7..058715629 100644 --- a/lib/model/filters/filters.dart +++ b/lib/model/filters/filters.dart @@ -21,17 +21,21 @@ import 'package:flutter/widgets.dart'; abstract class CollectionFilter extends Equatable implements Comparable { static const List 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 toMap(); String toJson() => jsonEncode(toMap()); diff --git a/lib/model/filters/tag.dart b/lib/model/filters/tag.dart index 1ec3b2a4d..0c6105ba7 100644 --- a/lib/model/filters/tag.dart +++ b/lib/model/filters/tag.dart @@ -12,23 +12,25 @@ class TagFilter extends CollectionFilter { @override List 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 json) : this( json['tag'], + not: json['not'] ?? false, ); @override Map toMap() => { 'type': type, 'tag': tag, + 'not': not, }; @override diff --git a/lib/model/metadata/date_modifier.dart b/lib/model/metadata/date_modifier.dart index 86c312f62..73d648463 100644 --- a/lib/model/metadata/date_modifier.dart +++ b/lib/model/metadata/date_modifier.dart @@ -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 get props => [action, fields, setDateTime, copyFieldSource, shiftMinutes]; + const DateModifier._private( this.action, this.fields, { diff --git a/lib/model/metadata/enums.dart b/lib/model/metadata/enums.dart index 530dc932b..086f35894 100644 --- a/lib/model/metadata/enums.dart +++ b/lib/model/metadata/enums.dart @@ -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; } } } diff --git a/lib/model/metadata/fields.dart b/lib/model/metadata/fields.dart new file mode 100644 index 000000000..040e5eaaa --- /dev/null +++ b/lib/model/metadata/fields.dart @@ -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 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; + } + } +} diff --git a/lib/model/metadata_db.dart b/lib/model/metadata_db.dart index ca9f09623..2dfbe54ec 100644 --- a/lib/model/metadata_db.dart +++ b/lib/model/metadata_db.dart @@ -231,7 +231,7 @@ class SqfliteMetadataDb implements MetadataDb { Future> 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> 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> 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> 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; } diff --git a/lib/model/multipage.dart b/lib/model/multipage.dart index 08e3a888c..7593a4c0b 100644 --- a/lib/model/multipage.dart +++ b/lib/model/multipage.dart @@ -36,7 +36,7 @@ class MultiPageInfo { factory MultiPageInfo.fromPageMaps(AvesEntry mainEntry, List pageMaps) { return MultiPageInfo( mainEntry: mainEntry, - pages: pageMaps.map((page) => SinglePageInfo.fromMap(page)).toList(), + pages: pageMaps.map(SinglePageInfo.fromMap).toList(), ); } diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index 1b7f9c872..2740db8fd 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -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 export() => Map.fromEntries( _prefs!.getKeys().whereNot(internalKeys.contains).map((k) => MapEntry(k, _prefs!.get(k))), - )); + ); - Future fromJson(String jsonString) async { - final jsonMap = jsonDecode(jsonString); + Future import(dynamic jsonMap) async { if (jsonMap is Map) { // clear to restore defaults await reset(includeInternalKeys: false); diff --git a/lib/model/source/album.dart b/lib/model/source/album.dart index 64bed85ae..25fe1eb68 100644 --- a/lib/model/source/album.dart +++ b/lib/model/source/album.dart @@ -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)); } diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index 75ca4ada2..4c1591ba3 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -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? entries]) { @@ -292,6 +292,18 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM Future refreshEntry(AvesEntry entry, Set 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})); } diff --git a/lib/model/source/enums.dart b/lib/model/source/enums.dart index e39413950..6035caa19 100644 --- a/lib/model/source/enums.dart +++ b/lib/model/source/enums.dart @@ -8,4 +8,4 @@ enum EntrySortFactor { date, name, rating, size } enum EntryGroupFactor { none, album, month, day } -enum TileLayout { grid, list } \ No newline at end of file +enum TileLayout { grid, list } diff --git a/lib/model/source/location.dart b/lib/model/source/location.dart index e150ceb9a..dbabb7954 100644 --- a/lib/model/source/location.dart +++ b/lib/model/source/location.dart @@ -30,6 +30,12 @@ mixin LocationMixin on SourceBase { Future locateEntries(AnalysisController controller, Set 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 _filterEntryCountMap = {}; final Map _filterRecentEntryMap = {}; - void invalidateCountryFilterSummary([Set? entries]) { + void invalidateCountryFilterSummary({Set? entries, Set? countryCodes}) { if (_filterEntryCountMap.isEmpty && _filterRecentEntryMap.isEmpty) return; - Set? 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)); } diff --git a/lib/model/source/tag.dart b/lib/model/source/tag.dart index 121371d63..16583cc2a 100644 --- a/lib/model/source/tag.dart +++ b/lib/model/source/tag.dart @@ -77,16 +77,21 @@ mixin TagMixin on SourceBase { final Map _filterEntryCountMap = {}; final Map _filterRecentEntryMap = {}; - void invalidateTagFilterSummary([Set? entries]) { + void invalidateTagFilterSummary({Set? entries, Set? tags}) { if (_filterEntryCountMap.isEmpty && _filterRecentEntryMap.isEmpty) return; - Set? 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)); } diff --git a/lib/model/video/metadata.dart b/lib/model/video/metadata.dart index 924d45918..c83759bc4 100644 --- a/lib/model/video/metadata.dart +++ b/lib/model/video/metadata.dart @@ -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 _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(); } diff --git a/lib/ref/exif.dart b/lib/ref/exif.dart index 26fa9bb6d..cd3ecad91 100644 --- a/lib/ref/exif.dart +++ b/lib/ref/exif.dart @@ -1,4 +1,11 @@ class Exif { + // constants used by GPS related Exif tags + // they are locale independent + static const String latitudeNorth = 'N'; + static const String latitudeSouth = 'S'; + static const String longitudeEast = 'E'; + static const String longitudeWest = 'W'; + static String getColorSpaceDescription(String valueString) { final value = int.tryParse(valueString); if (value == null) return valueString; diff --git a/lib/ref/iptc.dart b/lib/ref/iptc.dart index 908f18914..8e88eea70 100644 --- a/lib/ref/iptc.dart +++ b/lib/ref/iptc.dart @@ -3,4 +3,4 @@ class IPTC { // ApplicationRecord tags static const int keywordsTag = 25; -} \ No newline at end of file +} diff --git a/lib/ref/mime_types.dart b/lib/ref/mime_types.dart index 2b43ebf5e..978668f31 100644 --- a/lib/ref/mime_types.dart +++ b/lib/ref/mime_types.dart @@ -103,4 +103,13 @@ class MimeTypes { return a == b; } } + + static String? forExtension(String extension) { + switch (extension) { + case '.jpg': + return jpeg; + case '.svg': + return svg; + } + } } diff --git a/lib/services/android_app_service.dart b/lib/services/android_app_service.dart index fa47d60ed..ee0366e31 100644 --- a/lib/services/android_app_service.dart +++ b/lib/services/android_app_service.dart @@ -38,7 +38,7 @@ class PlatformAndroidAppService implements AndroidAppService { Future> getPackages() async { try { final result = await platform.invokeMethod('getPackages'); - final packages = (result as List).cast().map((map) => Package.fromMap(map)).toSet(); + final packages = (result as List).cast().map(Package.fromMap).toSet(); // additional info for known directories final kakaoTalk = packages.firstWhereOrNull((package) => package.packageName == 'com.kakao.talk'); if (kakaoTalk != null) { diff --git a/lib/services/common/service_policy.dart b/lib/services/common/service_policy.dart index 0406f4f29..c8fe1f09b 100644 --- a/lib/services/common/service_policy.dart +++ b/lib/services/common/service_policy.dart @@ -66,7 +66,7 @@ class ServicePolicy { } } - LinkedHashMap _getQueue(int priority) => _queues.putIfAbsent(priority, () => LinkedHashMap()); + LinkedHashMap _getQueue(int priority) => _queues.putIfAbsent(priority, LinkedHashMap.new); void _pickNext() { _notifyQueueState(); diff --git a/lib/services/common/services.dart b/lib/services/common/services.dart index 795571bfb..3a877b3d8 100644 --- a/lib/services/common/services.dart +++ b/lib/services/common/services.dart @@ -32,18 +32,18 @@ final StorageService storageService = getIt(); final WindowService windowService = getIt(); void initPlatformServices() { - getIt.registerLazySingleton(() => p.Context()); - getIt.registerLazySingleton(() => LiveAvesAvailability()); - getIt.registerLazySingleton(() => SqfliteMetadataDb()); + getIt.registerLazySingleton(p.Context.new); + getIt.registerLazySingleton(LiveAvesAvailability.new); + getIt.registerLazySingleton(SqfliteMetadataDb.new); - getIt.registerLazySingleton(() => PlatformAndroidAppService()); - getIt.registerLazySingleton(() => PlatformDeviceService()); - getIt.registerLazySingleton(() => PlatformEmbeddedDataService()); - getIt.registerLazySingleton(() => PlatformMediaFileService()); - getIt.registerLazySingleton(() => PlatformMediaStoreService()); - getIt.registerLazySingleton(() => PlatformMetadataEditService()); - getIt.registerLazySingleton(() => PlatformMetadataFetchService()); - getIt.registerLazySingleton(() => PlatformReportService()); - getIt.registerLazySingleton(() => PlatformStorageService()); - getIt.registerLazySingleton(() => PlatformWindowService()); + getIt.registerLazySingleton(PlatformAndroidAppService.new); + getIt.registerLazySingleton(PlatformDeviceService.new); + getIt.registerLazySingleton(PlatformEmbeddedDataService.new); + getIt.registerLazySingleton(PlatformMediaFileService.new); + getIt.registerLazySingleton(PlatformMediaStoreService.new); + getIt.registerLazySingleton(PlatformMetadataEditService.new); + getIt.registerLazySingleton(PlatformMetadataFetchService.new); + getIt.registerLazySingleton(PlatformReportService.new); + getIt.registerLazySingleton(PlatformStorageService.new); + getIt.registerLazySingleton(PlatformWindowService.new); } diff --git a/lib/services/geocoding_service.dart b/lib/services/geocoding_service.dart index d411dfea2..28f6990d3 100644 --- a/lib/services/geocoding_service.dart +++ b/lib/services/geocoding_service.dart @@ -20,9 +20,9 @@ class GeocodingService { // returns nothing with `maxResults` of 1, but succeeds with `maxResults` of 2+ 'maxResults': 2, }); - return (result as List).cast().map((map) => Address.fromMap(map)).toList(); + return (result as List).cast().map(Address.fromMap).toList(); } on PlatformException catch (e, stack) { - if (e.code != 'getAddress-empty') { + if (e.code != 'getAddress-empty' && e.code != 'getAddress-network') { await reportService.recordError(e, stack); } } diff --git a/lib/services/media/media_file_service.dart b/lib/services/media/media_file_service.dart index 9c65a6c44..a73e74319 100644 --- a/lib/services/media/media_file_service.dart +++ b/lib/services/media/media_file_service.dart @@ -10,6 +10,7 @@ import 'package:aves/services/common/output_buffer.dart'; import 'package:aves/services/common/service_policy.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/services/media/enums.dart'; +import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:streams_channel/streams_channel.dart'; @@ -87,7 +88,7 @@ abstract class MediaFileService { Stream export( Iterable entries, { - required String mimeType, + required EntryExportOptions options, required String destinationAlbum, required NameConflictStrategy nameConflictStrategy, }); @@ -325,11 +326,14 @@ class PlatformMediaFileService implements MediaFileService { required Iterable entries, }) { try { - return _opStreamChannel.receiveBroadcastStream({ - 'op': 'delete', - 'id': opId, - 'entries': entries.map(_toPlatformEntryMap).toList(), - }).map((event) => ImageOpEvent.fromMap(event)); + return _opStreamChannel + .receiveBroadcastStream({ + 'op': 'delete', + 'id': opId, + 'entries': entries.map(_toPlatformEntryMap).toList(), + }) + .where((event) => event is Map) + .map((event) => ImageOpEvent.fromMap(event as Map)); } on PlatformException catch (e, stack) { reportService.recordError(e, stack); return Stream.error(e); @@ -345,14 +349,17 @@ class PlatformMediaFileService implements MediaFileService { required NameConflictStrategy nameConflictStrategy, }) { try { - return _opStreamChannel.receiveBroadcastStream({ - 'op': 'move', - 'id': opId, - 'entries': entries.map(_toPlatformEntryMap).toList(), - 'copy': copy, - 'destinationPath': destinationAlbum, - 'nameConflictStrategy': nameConflictStrategy.toPlatform(), - }).map((event) => MoveOpEvent.fromMap(event)); + return _opStreamChannel + .receiveBroadcastStream({ + 'op': 'move', + 'id': opId, + 'entries': entries.map(_toPlatformEntryMap).toList(), + 'copy': copy, + 'destinationPath': destinationAlbum, + 'nameConflictStrategy': nameConflictStrategy.toPlatform(), + }) + .where((event) => event is Map) + .map((event) => MoveOpEvent.fromMap(event as Map)); } on PlatformException catch (e, stack) { reportService.recordError(e, stack); return Stream.error(e); @@ -362,18 +369,23 @@ class PlatformMediaFileService implements MediaFileService { @override Stream export( Iterable entries, { - required String mimeType, + required EntryExportOptions options, required String destinationAlbum, required NameConflictStrategy nameConflictStrategy, }) { try { - return _opStreamChannel.receiveBroadcastStream({ - 'op': 'export', - 'entries': entries.map(_toPlatformEntryMap).toList(), - 'mimeType': mimeType, - 'destinationPath': destinationAlbum, - 'nameConflictStrategy': nameConflictStrategy.toPlatform(), - }).map((event) => ExportOpEvent.fromMap(event)); + return _opStreamChannel + .receiveBroadcastStream({ + 'op': 'export', + 'entries': entries.map(_toPlatformEntryMap).toList(), + 'mimeType': options.mimeType, + 'width': options.width, + 'height': options.height, + 'destinationPath': destinationAlbum, + 'nameConflictStrategy': nameConflictStrategy.toPlatform(), + }) + .where((event) => event is Map) + .map((event) => ExportOpEvent.fromMap(event as Map)); } on PlatformException catch (e, stack) { reportService.recordError(e, stack); return Stream.error(e); @@ -386,11 +398,14 @@ class PlatformMediaFileService implements MediaFileService { required String newName, }) { try { - return _opStreamChannel.receiveBroadcastStream({ - 'op': 'rename', - 'entries': entries.map(_toPlatformEntryMap).toList(), - 'newName': newName, - }).map((event) => MoveOpEvent.fromMap(event)); + return _opStreamChannel + .receiveBroadcastStream({ + 'op': 'rename', + 'entries': entries.map(_toPlatformEntryMap).toList(), + 'newName': newName, + }) + .where((event) => event is Map) + .map((event) => MoveOpEvent.fromMap(event as Map)); } on PlatformException catch (e, stack) { reportService.recordError(e, stack); return Stream.error(e); @@ -422,3 +437,18 @@ class PlatformMediaFileService implements MediaFileService { return {}; } } + +@immutable +class EntryExportOptions extends Equatable { + final String mimeType; + final int width, height; + + @override + List get props => [mimeType, width, height]; + + const EntryExportOptions({ + required this.mimeType, + required this.width, + required this.height, + }); +} diff --git a/lib/services/media/media_store_service.dart b/lib/services/media/media_store_service.dart index 67ccb7b5a..9bb493e92 100644 --- a/lib/services/media/media_store_service.dart +++ b/lib/services/media/media_store_service.dart @@ -50,9 +50,12 @@ class PlatformMediaStoreService implements MediaStoreService { @override Stream getEntries(Map knownEntries) { try { - return _streamChannel.receiveBroadcastStream({ - 'knownEntries': knownEntries, - }).map((event) => AvesEntry.fromMap(event)); + return _streamChannel + .receiveBroadcastStream({ + 'knownEntries': knownEntries, + }) + .where((event) => event is Map) + .map((event) => AvesEntry.fromMap(event as Map)); } on PlatformException catch (e, stack) { reportService.recordError(e, stack); return Stream.error(e); diff --git a/lib/services/metadata/metadata_edit_service.dart b/lib/services/metadata/metadata_edit_service.dart index 164b5d7e9..1c40637cd 100644 --- a/lib/services/metadata/metadata_edit_service.dart +++ b/lib/services/metadata/metadata_edit_service.dart @@ -3,6 +3,7 @@ import 'dart:async'; 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/services/common/services.dart'; import 'package:collection/collection.dart'; import 'package:flutter/services.dart'; @@ -77,7 +78,7 @@ class PlatformMetadataEditService implements MetadataEditService { 'entry': _toPlatformEntryMap(entry), 'dateMillis': modifier.setDateTime?.millisecondsSinceEpoch, 'shiftMinutes': modifier.shiftMinutes, - 'fields': modifier.fields.where((v) => v.type == MetadataType.exif).map((v) => v.toExifInterfaceTag()).whereNotNull().toList(), + 'fields': modifier.fields.where((v) => v.type == MetadataType.exif).map((v) => v.exifInterfaceTag).whereNotNull().toList(), }); if (result != null) return (result as Map).cast(); } on PlatformException catch (e, stack) { diff --git a/lib/services/metadata/metadata_fetch_service.dart b/lib/services/metadata/metadata_fetch_service.dart index 5a665af3d..40e6143bf 100644 --- a/lib/services/metadata/metadata_fetch_service.dart +++ b/lib/services/metadata/metadata_fetch_service.dart @@ -1,12 +1,13 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/metadata/catalog.dart'; -import 'package:aves/model/metadata/enums.dart'; +import 'package:aves/model/metadata/fields.dart'; import 'package:aves/model/metadata/overlay.dart'; import 'package:aves/model/multipage.dart'; import 'package:aves/model/panorama.dart'; import 'package:aves/services/common/service_policy.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/services/metadata/xmp.dart'; +import 'package:aves/utils/time_utils.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -235,9 +236,11 @@ class PlatformMetadataFetchService implements MetadataFetchService { 'mimeType': entry.mimeType, 'uri': entry.uri, 'sizeBytes': entry.sizeBytes, - 'field': field.toExifInterfaceTag(), + 'field': field.exifInterfaceTag, }); - if (result is int) return DateTime.fromMillisecondsSinceEpoch(result); + if (result is int) { + return dateTimeFromMillis(result, isUtc: false); + } } on PlatformException catch (e, stack) { if (!entry.isMissingAtPath) { await reportService.recordError(e, stack); diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index a1fc9bd36..003deb434 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -47,7 +47,7 @@ class PlatformStorageService implements StorageService { Future> getStorageVolumes() async { try { final result = await platform.invokeMethod('getStorageVolumes'); - return (result as List).cast().map((map) => StorageVolume.fromMap(map)).toSet(); + return (result as List).cast().map(StorageVolume.fromMap).toSet(); } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); } diff --git a/lib/theme/durations.dart b/lib/theme/durations.dart index 92909675c..bed800993 100644 --- a/lib/theme/durations.dart +++ b/lib/theme/durations.dart @@ -20,9 +20,6 @@ class Durations { static const appBarTitleAnimation = Duration(milliseconds: 300); static const appBarActionChangeAnimation = Duration(milliseconds: 200); - // drawer - static const newsBadgeAnimation = Duration(milliseconds: 200); - // filter grids animations static const chipDecorationAnimation = Duration(milliseconds: 200); static const highlightScrollAnimationMinMillis = 400; @@ -68,9 +65,6 @@ class Durations { static const contentChangeDebounceDelay = Duration(milliseconds: 1000); static const mapInfoDebounceDelay = Duration(milliseconds: 150); static const mapIdleDebounceDelay = Duration(milliseconds: 100); - - // app life - static const lastVersionCheckInterval = Duration(days: 7); } class DurationsProvider extends StatelessWidget { diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index 3088114a5..9d8266c28 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -22,6 +22,13 @@ class Constants { ) ]; + // Bidi fun, cf https://www.unicode.org/reports/tr9/ + // First Strong Isolate + static const fsi = '\u2068'; + + // Pop Directional Isolate + static const pdi = '\u2069'; + static const overlayUnknown = '—'; // em dash static final pointNemo = LatLng(-48.876667, -123.393333); @@ -290,11 +297,6 @@ class Constants { license: 'MIT', sourceUrl: 'https://github.com/fluttercommunity/get_it', ), - Dependency( - name: 'GitHub', - license: 'MIT', - sourceUrl: 'https://github.com/SpinlockLabs/github.dart', - ), Dependency( name: 'Intl', license: 'BSD 3-Clause', @@ -325,11 +327,6 @@ class Constants { license: 'BSD 2-Clause', sourceUrl: 'https://github.com/google/tuple.dart', ), - Dependency( - name: 'Version', - license: 'BSD 3-Clause', - sourceUrl: 'https://github.com/dartninja/version', - ), Dependency( name: 'XML', license: 'MIT', diff --git a/lib/utils/time_utils.dart b/lib/utils/time_utils.dart index 6980430ba..b3699462f 100644 --- a/lib/utils/time_utils.dart +++ b/lib/utils/time_utils.dart @@ -1,3 +1,5 @@ +import 'package:flutter/foundation.dart'; + extension ExtraDateTime on DateTime { bool isAtSameYearAs(DateTime? other) => year == other?.year; @@ -14,6 +16,28 @@ extension ExtraDateTime on DateTime { bool get isThisYear => isAtSameYearAs(DateTime.now()); } +final epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); + +// Overflowing timestamps that are supposed to be in milliseconds +// will be retried after stripping extra digits. +const _millisMaxDigits = 13; // 13 digits can go up to 2286/11/20 + +DateTime? dateTimeFromMillis(int? millis, {bool isUtc = false}) { + if (millis == null || millis == 0) return null; + try { + return DateTime.fromMillisecondsSinceEpoch(millis, isUtc: isUtc); + } catch (e) { + // `DateTime`s can represent time values that are at a distance of at most 100,000,000 + // days from epoch (1970-01-01 UTC): -271821-04-20 to 275760-09-13. + debugPrint('failed to build DateTime from timestamp in millis=$millis'); + } + final digits = '$millis'.length; + if (digits > _millisMaxDigits) { + millis = int.tryParse('$millis'.substring(0, _millisMaxDigits)); + return dateTimeFromMillis(millis, isUtc: isUtc); + } +} + final _unixStampMillisPattern = RegExp(r'\d{13}'); final _unixStampSecPattern = RegExp(r'\d{10}'); final _plainPattern = RegExp(r'(\d{8})([_-\s](\d{6})([_-\s](\d{3}))?)?'); @@ -23,22 +47,22 @@ DateTime? parseUnknownDateFormat(String? s) { var match = _unixStampMillisPattern.firstMatch(s); if (match != null) { - final stampString = match.group(0); - if (stampString != null) { - final stampMillis = int.tryParse(stampString); + final stampMillisString = match.group(0); + if (stampMillisString != null) { + final stampMillis = int.tryParse(stampMillisString); if (stampMillis != null) { - return DateTime.fromMillisecondsSinceEpoch(stampMillis, isUtc: false); + return dateTimeFromMillis(stampMillis, isUtc: false); } } } match = _unixStampSecPattern.firstMatch(s); if (match != null) { - final stampString = match.group(0); - if (stampString != null) { - final stampMillis = int.tryParse(stampString); - if (stampMillis != null) { - return DateTime.fromMillisecondsSinceEpoch(stampMillis * 1000, isUtc: false); + final stampSecString = match.group(0); + if (stampSecString != null) { + final stampSec = int.tryParse(stampSecString); + if (stampSec != null) { + return dateTimeFromMillis(stampSec * 1000, isUtc: false); } } } diff --git a/lib/widgets/about/about_page.dart b/lib/widgets/about/about_page.dart index 7e124c114..f87841ab4 100644 --- a/lib/widgets/about/about_page.dart +++ b/lib/widgets/about/about_page.dart @@ -2,7 +2,6 @@ import 'package:aves/widgets/about/app_ref.dart'; import 'package:aves/widgets/about/bug_report.dart'; import 'package:aves/widgets/about/credits.dart'; import 'package:aves/widgets/about/licenses.dart'; -import 'package:aves/widgets/about/update.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/material.dart'; @@ -27,7 +26,6 @@ class AboutPage extends StatelessWidget { const [ AppReference(), Divider(), - AboutUpdate(), BugReport(), Divider(), AboutCredits(), diff --git a/lib/widgets/about/bug_report.dart b/lib/widgets/about/bug_report.dart index db9195c68..978123c10 100644 --- a/lib/widgets/about/bug_report.dart +++ b/lib/widgets/about/bug_report.dart @@ -4,6 +4,7 @@ import 'dart:typed_data'; import 'package:aves/app_flavor.dart'; import 'package:aves/flutter_version.dart'; +import 'package:aves/model/settings/settings.dart'; import 'package:aves/ref/mime_types.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/theme/durations.dart'; @@ -28,6 +29,7 @@ class BugReport extends StatefulWidget { } class _BugReportState extends State with FeedbackMixin { + final ScrollController _infoScrollController = ScrollController(); late Future _infoLoader; bool _showInstructions = false; @@ -71,16 +73,39 @@ class _BugReportState extends State with FeedbackMixin { final info = snapshot.data; if (info == null) return const SizedBox(); return Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.grey.shade800, - border: Border.all( - color: Colors.white, - ), - borderRadius: const BorderRadius.all(Radius.circular(8)), + decoration: BoxDecoration( + color: Colors.grey.shade800, + border: Border.all( + color: Colors.white, ), - margin: const EdgeInsets.symmetric(vertical: 8), - child: SelectableText(info)); + borderRadius: const BorderRadius.all(Radius.circular(8)), + ), + constraints: const BoxConstraints(maxHeight: 100), + margin: const EdgeInsets.symmetric(vertical: 8), + child: Theme( + data: Theme.of(context).copyWith( + scrollbarTheme: const ScrollbarThemeData( + isAlwaysShown: true, + radius: Radius.circular(16), + crossAxisMargin: 6, + mainAxisMargin: 6, + interactive: true, + ), + ), + child: Scrollbar( + // when using `Scrollbar.isAlwaysShown`, a controller must be provided + // and used by both the `Scrollbar` and the `Scrollable`, but + // as of Flutter v2.8.1, `SelectableText` does not allow passing the `scrollController` + // so we wrap it in a `SingleChildScrollView` + controller: _infoScrollController, + child: SingleChildScrollView( + padding: const EdgeInsetsDirectional.only(start: 8, top: 4, end: 16, bottom: 4), + controller: _infoScrollController, + child: SelectableText(info), + ), + ), + ), + ); }, ), _buildStep(3, l10n.aboutBugReportInstruction, l10n.aboutBugReportButton, _goToGithub), @@ -118,7 +143,7 @@ class _BugReportState extends State with FeedbackMixin { AvesOutlinedButton( label: buttonText, onPressed: onPressed, - ) + ), ], ), ); @@ -136,6 +161,8 @@ class _BugReportState extends State with FeedbackMixin { 'Android build: ${androidInfo.display}', 'Device: ${androidInfo.manufacturer} ${androidInfo.model}', 'Google Play services: ${hasPlayServices ? 'ready' : 'not available'}', + 'System locales: ${WidgetsBinding.instance!.window.locales.join(', ')}', + 'Aves locale: ${settings.locale} -> ${settings.appliedLocale}', ].join('\n'); } @@ -162,6 +189,6 @@ class _BugReportState extends State with FeedbackMixin { } Future _goToGithub() async { - await launch('${Constants.avesGithub}/issues/new'); + await launch('${Constants.avesGithub}/issues/new?labels=type%3Abug&template=bug_report.md'); } } diff --git a/lib/widgets/about/credits.dart b/lib/widgets/about/credits.dart index 35251d760..67ef9be7c 100644 --- a/lib/widgets/about/credits.dart +++ b/lib/widgets/about/credits.dart @@ -9,6 +9,7 @@ class AboutCredits extends StatelessWidget { static const translators = { 'Deutsch': 'JanWaldhorn', 'Español (México)': 'n-berenice', + 'Português (Brasil)': 'Jonatas De Almeida Barros', 'Русский': 'D3ZOXY', }; diff --git a/lib/widgets/about/news_badge.dart b/lib/widgets/about/news_badge.dart deleted file mode 100644 index c0557a82e..000000000 --- a/lib/widgets/about/news_badge.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:flutter/material.dart'; - -class AboutNewsBadge extends StatelessWidget { - const AboutNewsBadge({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return const Icon( - Icons.circle, - size: 12, - color: Colors.red, - ); - } -} diff --git a/lib/widgets/about/policy_page.dart b/lib/widgets/about/policy_page.dart index fbfa3adcc..67c12597c 100644 --- a/lib/widgets/about/policy_page.dart +++ b/lib/widgets/about/policy_page.dart @@ -17,10 +17,13 @@ class PolicyPage extends StatefulWidget { class _PolicyPageState extends State { late Future _termsLoader; + static const termsPath = 'assets/terms.md'; + static const termsDirection = TextDirection.ltr; + @override void initState() { super.initState(); - _termsLoader = rootBundle.loadString('assets/terms.md'); + _termsLoader = rootBundle.loadString(termsPath); } @override @@ -38,7 +41,10 @@ class _PolicyPageState extends State { final terms = snapshot.data!; return Padding( padding: const EdgeInsets.symmetric(vertical: 8), - child: MarkdownContainer(data: terms), + child: MarkdownContainer( + data: terms, + textDirection: termsDirection, + ), ); }, ), diff --git a/lib/widgets/about/update.dart b/lib/widgets/about/update.dart deleted file mode 100644 index 8401c170d..000000000 --- a/lib/widgets/about/update.dart +++ /dev/null @@ -1,93 +0,0 @@ -import 'package:aves/services/common/services.dart'; -import 'package:aves/utils/constants.dart'; -import 'package:aves/widgets/about/news_badge.dart'; -import 'package:aves/widgets/common/basic/link_chip.dart'; -import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:flutter/material.dart'; - -class AboutUpdate extends StatefulWidget { - const AboutUpdate({Key? key}) : super(key: key); - - @override - _AboutUpdateState createState() => _AboutUpdateState(); -} - -class _AboutUpdateState extends State { - late Future _updateChecker; - - @override - void initState() { - super.initState(); - _updateChecker = availability.isNewVersionAvailable; - } - - @override - Widget build(BuildContext context) { - return FutureBuilder( - future: _updateChecker, - builder: (context, snapshot) { - final newVersionAvailable = snapshot.data == true; - if (!newVersionAvailable) return const SizedBox(); - return Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ConstrainedBox( - constraints: const BoxConstraints(minHeight: 48), - child: Align( - alignment: AlignmentDirectional.centerStart, - child: Text.rich( - TextSpan( - children: [ - const WidgetSpan( - child: Padding( - padding: EdgeInsetsDirectional.only(end: 8), - child: AboutNewsBadge(), - ), - alignment: PlaceholderAlignment.middle, - ), - TextSpan(text: context.l10n.aboutUpdate, style: Constants.titleTextStyle), - ], - ), - ), - ), - ), - Text.rich( - TextSpan( - children: [ - TextSpan(text: context.l10n.aboutUpdateLinks1), - WidgetSpan( - child: LinkChip( - text: context.l10n.aboutUpdateGitHub, - url: '${Constants.avesGithub}/releases', - textStyle: const TextStyle(fontWeight: FontWeight.bold), - ), - alignment: PlaceholderAlignment.middle, - ), - TextSpan(text: context.l10n.aboutUpdateLinks2), - WidgetSpan( - child: LinkChip( - text: context.l10n.aboutUpdateGooglePlay, - url: 'https://play.google.com/store/apps/details?id=deckers.thibault.aves', - textStyle: const TextStyle(fontWeight: FontWeight.bold), - ), - alignment: PlaceholderAlignment.middle, - ), - TextSpan(text: context.l10n.aboutUpdateLinks3), - ], - ), - ), - const SizedBox(height: 16), - ], - ), - ), - const Divider(), - ], - ); - }, - ); - } -} diff --git a/lib/widgets/aves_app.dart b/lib/widgets/aves_app.dart index ef35eb8fb..995ad2040 100644 --- a/lib/widgets/aves_app.dart +++ b/lib/widgets/aves_app.dart @@ -129,8 +129,6 @@ class _AvesAppState extends State { locale: settingsLocale, localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, - // checkerboardRasterCacheImages: true, - // checkerboardOffscreenLayers: true, ); }, ); diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index e8934726f..7ba7144ff 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -16,6 +16,7 @@ import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/collection/entry_set_action_delegate.dart'; import 'package:aves/widgets/collection/filter_bar.dart'; import 'package:aves/widgets/collection/query_bar.dart'; +import 'package:aves/widgets/common/animated_icons_fix.dart'; import 'package:aves/widgets/common/app_bar_subtitle.dart'; import 'package:aves/widgets/common/app_bar_title.dart'; import 'package:aves/widgets/common/basic/menu.dart'; @@ -164,8 +165,9 @@ class _CollectionAppBarState extends State with SingleTickerPr return IconButton( // key is expected by test driver key: const Key('appbar-leading-button'), - icon: AnimatedIcon( - icon: AnimatedIcons.menu_arrow, + // TODO TLAD [rtl] replace to regular `AnimatedIcon` when this is fixed: https://github.com/flutter/flutter/issues/60521 + icon: AnimatedIconFixIssue60521( + icon: AnimatedIconsFixIssue60521.menu_arrow, progress: _browseToSelectAnimation, ), onPressed: onPressed, @@ -252,6 +254,7 @@ class _CollectionAppBarState extends State with SingleTickerPr _buildRotateAndFlipMenuItems(context, canApply: canApply), ...[ EntrySetAction.editDate, + EntrySetAction.editLocation, EntrySetAction.editRating, EntrySetAction.editTags, EntrySetAction.removeMetadata, @@ -426,17 +429,18 @@ class _CollectionAppBarState extends State with SingleTickerPr // browsing or selecting case EntrySetAction.map: case EntrySetAction.stats: + case EntrySetAction.rescan: // selecting case EntrySetAction.share: case EntrySetAction.delete: case EntrySetAction.copy: case EntrySetAction.move: - case EntrySetAction.rescan: case EntrySetAction.toggleFavourite: case EntrySetAction.rotateCCW: case EntrySetAction.rotateCW: case EntrySetAction.flip: case EntrySetAction.editDate: + case EntrySetAction.editLocation: case EntrySetAction.editRating: case EntrySetAction.editTags: case EntrySetAction.removeMetadata: diff --git a/lib/widgets/collection/collection_grid.dart b/lib/widgets/collection/collection_grid.dart index b1d647b86..4041019b6 100644 --- a/lib/widgets/collection/collection_grid.dart +++ b/lib/widgets/collection/collection_grid.dart @@ -192,6 +192,7 @@ class _CollectionSectionedContentState extends State<_CollectionSectionedContent final isMainMode = context.select, bool>((vn) => vn.value == AppMode.main); final selector = GridSelectionGestureDetector( + scrollableKey: scrollableKey, selectable: isMainMode, items: collection.sortedEntries, scrollController: scrollController, @@ -238,6 +239,7 @@ class _CollectionScaler extends StatelessWidget { borderWidth: DecoratedThumbnail.borderWidth, borderRadius: Radius.zero, color: DecoratedThumbnail.borderColor, + textDirection: Directionality.of(context), ), child: child, ), diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index dc5b75c6f..db0e0d242 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -67,18 +67,19 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa // browsing or selecting case EntrySetAction.map: case EntrySetAction.stats: + case EntrySetAction.rescan: return appMode == AppMode.main; // selecting case EntrySetAction.share: case EntrySetAction.delete: case EntrySetAction.copy: case EntrySetAction.move: - case EntrySetAction.rescan: case EntrySetAction.toggleFavourite: case EntrySetAction.rotateCCW: case EntrySetAction.rotateCW: case EntrySetAction.flip: case EntrySetAction.editDate: + case EntrySetAction.editLocation: case EntrySetAction.editRating: case EntrySetAction.editTags: case EntrySetAction.removeMetadata: @@ -110,18 +111,19 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa return true; case EntrySetAction.map: case EntrySetAction.stats: + case EntrySetAction.rescan: return (!isSelecting && hasItems) || (isSelecting && hasSelection); // selecting case EntrySetAction.share: case EntrySetAction.delete: case EntrySetAction.copy: case EntrySetAction.move: - case EntrySetAction.rescan: case EntrySetAction.toggleFavourite: case EntrySetAction.rotateCCW: case EntrySetAction.rotateCW: case EntrySetAction.flip: case EntrySetAction.editDate: + case EntrySetAction.editLocation: case EntrySetAction.editRating: case EntrySetAction.editTags: case EntrySetAction.removeMetadata: @@ -154,6 +156,9 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa case EntrySetAction.stats: _goToStats(context); break; + case EntrySetAction.rescan: + _rescan(context); + break; // selecting case EntrySetAction.share: _share(context); @@ -167,9 +172,6 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa case EntrySetAction.move: _move(context, moveType: MoveType.move); break; - case EntrySetAction.rescan: - _rescan(context); - break; case EntrySetAction.toggleFavourite: _toggleFavourite(context); break; @@ -185,6 +187,9 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa case EntrySetAction.editDate: _editDate(context); break; + case EntrySetAction.editLocation: + _editLocation(context); + break; case EntrySetAction.editRating: _editRating(context); break; @@ -210,12 +215,12 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa } void _rescan(BuildContext context) { - final source = context.read(); final selection = context.read>(); - final selectedItems = _getExpandedSelectedItems(selection); + final collection = context.read(); + final entries = (selection.isSelecting ? _getExpandedSelectedItems(selection) : collection.sortedEntries.toSet()); final controller = AnalysisController(canStartService: true, force: true); - source.analyze(controller, entries: selectedItems); + collection.source.analyze(controller, entries: entries); selection.browse(); } @@ -428,6 +433,9 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa if (!await checkStoragePermissionForAlbums(context, selectionDirs, entries: todoItems)) return; + Set obsoleteTags = todoItems.expand((entry) => entry.tags).toSet(); + Set obsoleteCountryCodes = todoItems.where((entry) => entry.hasAddress).map((entry) => entry.addressDetails?.countryCode).whereNotNull().toSet(); + final source = context.read(); source.pauseMonitoring(); var cancelled = false; @@ -448,7 +456,18 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa final editedOps = successOps.where((e) => !e.skipped).toSet(); selection.browse(); source.resumeMonitoring(); - unawaited(source.refreshUris(editedOps.map((v) => v.uri).toSet())); + + unawaited(source.refreshUris(editedOps.map((v) => v.uri).toSet()).then((_) { + // invalidate filters derived from values before edition + // this invalidation must happen after the source is refreshed, + // otherwise filter chips may eagerly rebuild in between with the old state + if (obsoleteCountryCodes.isNotEmpty) { + source.invalidateCountryFilterSummary(countryCodes: obsoleteCountryCodes); + } + if (obsoleteTags.isNotEmpty) { + source.invalidateTagFilterSummary(tags: obsoleteTags); + } + })); final l10n = context.l10n; final successCount = successOps.length; @@ -536,6 +555,20 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa await _edit(context, selection, todoItems, (entry) => entry.editDate(modifier)); } + Future _editLocation(BuildContext context) async { + final collection = context.read(); + final selection = context.read>(); + final selectedItems = _getExpandedSelectedItems(selection); + + final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canEditLocation); + if (todoItems == null || todoItems.isEmpty) return; + + final location = await selectLocation(context, todoItems, collection); + if (location == null) return; + + await _edit(context, selection, todoItems, (entry) => entry.editLocation(location)); + } + Future _editRating(BuildContext context) async { final selection = context.read>(); final selectedItems = _getExpandedSelectedItems(selection); diff --git a/lib/widgets/collection/filter_bar.dart b/lib/widgets/collection/filter_bar.dart index d73656a2f..76f265e2a 100644 --- a/lib/widgets/collection/filter_bar.dart +++ b/lib/widgets/collection/filter_bar.dart @@ -90,7 +90,7 @@ class _FilterBarState extends State { initialItemCount: widget.filters.length, scrollDirection: Axis.horizontal, physics: const BouncingScrollPhysics(), - padding: const EdgeInsets.only(left: 8), + padding: const EdgeInsets.symmetric(horizontal: 4), itemBuilder: (context, index, animation) { if (index >= widget.filters.length) return const SizedBox(); return _buildChip(widget.filters.toList()[index]); @@ -102,7 +102,7 @@ class _FilterBarState extends State { Padding _buildChip(CollectionFilter filter) { return Padding( - padding: const EdgeInsets.only(right: 8), + padding: const EdgeInsets.symmetric(horizontal: 4), child: Center( child: AvesFilterChip( key: ValueKey(filter), diff --git a/lib/widgets/collection/grid/headers/date.dart b/lib/widgets/collection/grid/headers/date.dart index adc11af73..abce6c9dd 100644 --- a/lib/widgets/collection/grid/headers/date.dart +++ b/lib/widgets/collection/grid/headers/date.dart @@ -65,7 +65,7 @@ class MonthSectionHeader extends StatelessWidget { if (date == null) return l10n.sectionUnknown; if (date.isThisMonth) return l10n.dateThisMonth; final locale = l10n.localeName; - final localized = date.isThisYear? DateFormat.MMMM(locale).format(date) : DateFormat.yMMMM(locale).format(date); + final localized = date.isThisYear ? DateFormat.MMMM(locale).format(date) : DateFormat.yMMMM(locale).format(date); return '${localized.substring(0, 1).toUpperCase()}${localized.substring(1)}'; } diff --git a/lib/widgets/collection/grid/list_details.dart b/lib/widgets/collection/grid/list_details.dart index 149ffe83a..5170b5327 100644 --- a/lib/widgets/collection/grid/list_details.dart +++ b/lib/widgets/collection/grid/list_details.dart @@ -25,7 +25,7 @@ class EntryListDetails extends StatelessWidget { return Container( padding: EntryListDetailsTheme.contentPadding, foregroundDecoration: BoxDecoration( - border: Border(top: AvesBorder.side), + border: Border(top: AvesBorder.straightSide), ), margin: EntryListDetailsTheme.contentMargin, child: IconTheme.merge( diff --git a/lib/widgets/common/action_mixins/entry_editor.dart b/lib/widgets/common/action_mixins/entry_editor.dart index 9b84e7d32..6e67e4865 100644 --- a/lib/widgets/common/action_mixins/entry_editor.dart +++ b/lib/widgets/common/action_mixins/entry_editor.dart @@ -1,14 +1,17 @@ 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/source/collection_lens.dart'; import 'package:aves/ref/mime_types.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; -import 'package:aves/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart'; -import 'package:aves/widgets/dialogs/entry_editors/edit_entry_rating_dialog.dart'; -import 'package:aves/widgets/dialogs/entry_editors/edit_entry_tags_dialog.dart'; -import 'package:aves/widgets/dialogs/entry_editors/remove_entry_metadata_dialog.dart'; +import 'package:aves/widgets/dialogs/entry_editors/edit_date_dialog.dart'; +import 'package:aves/widgets/dialogs/entry_editors/edit_location_dialog.dart'; +import 'package:aves/widgets/dialogs/entry_editors/edit_rating_dialog.dart'; +import 'package:aves/widgets/dialogs/entry_editors/edit_tags_dialog.dart'; +import 'package:aves/widgets/dialogs/entry_editors/remove_metadata_dialog.dart'; import 'package:flutter/material.dart'; +import 'package:latlong2/latlong.dart'; mixin EntryEditorMixin { Future selectDateModifier(BuildContext context, Set entries) async { @@ -23,10 +26,23 @@ mixin EntryEditorMixin { return modifier; } + Future selectLocation(BuildContext context, Set entries, CollectionLens? collection) async { + if (entries.isEmpty) return null; + + final location = await showDialog( + context: context, + builder: (context) => EditEntryLocationDialog( + entry: entries.first, + collection: collection, + ), + ); + return location; + } + Future selectRating(BuildContext context, Set entries) async { if (entries.isEmpty) return null; - final rating = await showDialog( + final rating = await showDialog( context: context, builder: (context) => EditEntryRatingDialog( entry: entries.first, diff --git a/lib/widgets/common/animated_icons_fix.dart b/lib/widgets/common/animated_icons_fix.dart new file mode 100644 index 000000000..505364bc2 --- /dev/null +++ b/lib/widgets/common/animated_icons_fix.dart @@ -0,0 +1,1315 @@ +// TODO TLAD [rtl] remove the whole file when this is fixed: https://github.com/flutter/flutter/issues/60521 +// as of Flutter v2.8.1, mirrored animated icon is misplaced +// cf PR https://github.com/flutter/flutter/pull/93312 + +// ignore_for_file: constant_identifier_names, curly_braces_in_flow_control_structures, unnecessary_null_comparison +import 'dart:math' as math show pi; +import 'dart:ui' as ui show Paint, Path, Canvas; +import 'dart:ui' show lerpDouble; + +import 'package:flutter/widgets.dart'; + +abstract class AnimatedIconData { + /// Abstract const constructor. This constructor enables subclasses to provide + /// const constructors so that they can be used in const expressions. + const AnimatedIconData(); + + /// Whether this icon should be mirrored horizontally when text direction is + /// right-to-left. + /// + /// See also: + /// + /// * [TextDirection], which discusses concerns regarding reading direction + /// in Flutter. + /// * [Directionality], a widget which determines the ambient directionality. + bool get matchTextDirection; +} + +class _AnimatedIconData extends AnimatedIconData { + const _AnimatedIconData(this.size, this.paths, {this.matchTextDirection = false}); + + final Size size; + final List<_PathFrames> paths; + + @override + final bool matchTextDirection; +} + +class AnimatedIconFixIssue60521 extends StatelessWidget { + /// Creates an AnimatedIcon. + /// + /// The [progress] and [icon] arguments must not be null. + /// The [size] and [color] default to the value given by the current [IconTheme]. + const AnimatedIconFixIssue60521({ + Key? key, + required this.icon, + required this.progress, + this.color, + this.size, + this.semanticLabel, + this.textDirection, + }) : assert(progress != null), + assert(icon != null), + super(key: key); + + /// The animation progress for the animated icon. + /// + /// The value is clamped to be between 0 and 1. + /// + /// This determines the actual frame that is displayed. + final Animation progress; + + /// The color to use when drawing the icon. + /// + /// Defaults to the current [IconTheme] color, if any. + /// + /// The given color will be adjusted by the opacity of the current + /// [IconTheme], if any. + /// + /// In material apps, if there is a [Theme] without any [IconTheme]s + /// specified, icon colors default to white if the theme is dark + /// and black if the theme is light. + /// + /// If no [IconTheme] and no [Theme] is specified, icons will default to black. + /// + /// See [Theme] to set the current theme and [ThemeData.brightness] + /// for setting the current theme's brightness. + final Color? color; + + /// The size of the icon in logical pixels. + /// + /// Icons occupy a square with width and height equal to size. + /// + /// Defaults to the current [IconTheme] size. + final double? size; + + /// The icon to display. Available icons are listed in [AnimatedIcons]. + final AnimatedIconData icon; + + /// Semantic label for the icon. + /// + /// Announced in accessibility modes (e.g TalkBack/VoiceOver). + /// This label does not show in the UI. + /// + /// See also: + /// + /// * [SemanticsProperties.label], which is set to [semanticLabel] in the + /// underlying [Semantics] widget. + final String? semanticLabel; + + /// The text direction to use for rendering the icon. + /// + /// If this is null, the ambient [Directionality] is used instead. + /// + /// If the text direction is [TextDirection.rtl], the icon will be mirrored + /// horizontally (e.g back arrow will point right). + final TextDirection? textDirection; + + static ui.Path _pathFactory() => ui.Path(); + + @override + Widget build(BuildContext context) { + assert(debugCheckHasDirectionality(context)); + final _AnimatedIconData iconData = icon as _AnimatedIconData; + final IconThemeData iconTheme = IconTheme.of(context); + assert(iconTheme.isConcrete); + final double iconSize = size ?? iconTheme.size!; + final TextDirection textDirection = this.textDirection ?? Directionality.of(context); + final double iconOpacity = iconTheme.opacity!; + Color iconColor = color ?? iconTheme.color!; + if (iconOpacity != 1.0) iconColor = iconColor.withOpacity(iconColor.opacity * iconOpacity); + return Semantics( + label: semanticLabel, + child: CustomPaint( + size: Size(iconSize, iconSize), + painter: _AnimatedIconPainter( + paths: iconData.paths, + progress: progress, + color: iconColor, + scale: iconSize / iconData.size.width, + shouldMirror: textDirection == TextDirection.rtl && iconData.matchTextDirection, + uiPathFactory: _pathFactory, + ), + ), + ); + } +} + +typedef _UiPathFactory = ui.Path Function(); + +class _AnimatedIconPainter extends CustomPainter { + _AnimatedIconPainter({ + required this.paths, + required this.progress, + required this.color, + required this.scale, + required this.shouldMirror, + required this.uiPathFactory, + }) : super(repaint: progress); + + // This list is assumed to be immutable, changes to the contents of the list + // will not trigger a redraw as shouldRepaint will keep returning false. + final List<_PathFrames> paths; + final Animation progress; + final Color color; + final double scale; + + /// If this is true the image will be mirrored horizontally. + final bool shouldMirror; + final _UiPathFactory uiPathFactory; + + @override + void paint(ui.Canvas canvas, Size size) { + // The RenderCustomPaint render object performs canvas.save before invoking + // this and canvas.restore after, so we don't need to do it here. + if (shouldMirror) { + canvas.rotate(math.pi); + canvas.translate(-size.width, -size.height); + } + canvas.scale(scale, scale); + + final double clampedProgress = progress.value.clamp(0.0, 1.0); + for (final _PathFrames path in paths) path.paint(canvas, color, uiPathFactory, clampedProgress); + } + + @override + bool shouldRepaint(_AnimatedIconPainter oldDelegate) { + return oldDelegate.progress.value != progress.value || + oldDelegate.color != color + // We are comparing the paths list by reference, assuming the list is + // treated as immutable to be more efficient. + || + oldDelegate.paths != paths || + oldDelegate.scale != scale || + oldDelegate.uiPathFactory != uiPathFactory; + } + + @override + bool? hitTest(Offset position) => null; + + @override + bool shouldRebuildSemantics(CustomPainter oldDelegate) => false; + + @override + SemanticsBuilderCallback? get semanticsBuilder => null; +} + +class _PathFrames { + const _PathFrames({ + required this.commands, + required this.opacities, + }); + + final List<_PathCommand> commands; + final List opacities; + + void paint(ui.Canvas canvas, Color color, _UiPathFactory uiPathFactory, double progress) { + final double opacity = _interpolate(opacities, progress, lerpDouble)!; + final ui.Paint paint = ui.Paint() + ..style = PaintingStyle.fill + ..color = color.withOpacity(color.opacity * opacity); + final ui.Path path = uiPathFactory(); + for (final _PathCommand command in commands) command.apply(path, progress); + canvas.drawPath(path, paint); + } +} + +abstract class _PathCommand { + const _PathCommand(); + + /// Applies the path command to [path]. + /// + /// For example if the object is a [_PathMoveTo] command it will invoke + /// [Path.moveTo] on [path]. + void apply(ui.Path path, double progress); +} + +class _PathMoveTo extends _PathCommand { + const _PathMoveTo(this.points); + + final List points; + + @override + void apply(Path path, double progress) { + final Offset offset = _interpolate(points, progress, Offset.lerp)!; + path.moveTo(offset.dx, offset.dy); + } +} + +class _PathCubicTo extends _PathCommand { + const _PathCubicTo(this.controlPoints1, this.controlPoints2, this.targetPoints); + + final List controlPoints2; + final List controlPoints1; + final List targetPoints; + + @override + void apply(Path path, double progress) { + final Offset controlPoint1 = _interpolate(controlPoints1, progress, Offset.lerp)!; + final Offset controlPoint2 = _interpolate(controlPoints2, progress, Offset.lerp)!; + final Offset targetPoint = _interpolate(targetPoints, progress, Offset.lerp)!; + path.cubicTo( + controlPoint1.dx, + controlPoint1.dy, + controlPoint2.dx, + controlPoint2.dy, + targetPoint.dx, + targetPoint.dy, + ); + } +} + +// ignore: unused_element +class _PathLineTo extends _PathCommand { + const _PathLineTo(this.points); + + final List points; + + @override + void apply(Path path, double progress) { + final Offset point = _interpolate(points, progress, Offset.lerp)!; + path.lineTo(point.dx, point.dy); + } +} + +class _PathClose extends _PathCommand { + const _PathClose(); + + @override + void apply(Path path, double progress) { + path.close(); + } +} + +T _interpolate(List values, double progress, _Interpolator interpolator) { + assert(progress <= 1.0); + assert(progress >= 0.0); + if (values.length == 1) return values[0]; + final double targetIdx = lerpDouble(0, values.length - 1, progress)!; + final int lowIdx = targetIdx.floor(); + final int highIdx = targetIdx.ceil(); + final double t = targetIdx - lowIdx; + return interpolator(values[lowIdx], values[highIdx], t); +} + +typedef _Interpolator = T Function(T a, T b, double progress); + +abstract class AnimatedIconsFixIssue60521 { + static const AnimatedIconData menu_arrow = _AnimatedIconData( + Size(48.0, 48.0), + <_PathFrames>[ + _PathFrames( + opacities: [ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo( + [ + Offset(6.0, 26.0), + Offset(5.976562557689849, 25.638185989482512), + Offset(5.951781669661045, 24.367972149512962), + Offset(6.172793116155802, 21.823631861702058), + Offset(7.363587976838016, 17.665129222832853), + Offset(11.400806749308899, 11.800457098273661), + Offset(17.41878573585796, 8.03287301910486), + Offset(24.257523532175192, 6.996159828679087), + Offset(29.90338248135665, 8.291042849526), + Offset(33.76252909490214, 10.56619705548221), + Offset(36.23501636298456, 12.973675163618006), + Offset(37.77053540180521, 15.158665125787222), + Offset(38.70420448893307, 17.008159945496722), + Offset(39.260392038988186, 18.5104805430827), + Offset(39.58393261852967, 19.691668944482075), + Offset(39.766765502294305, 20.58840471665747), + Offset(39.866421084642994, 21.237322746452932), + Offset(39.91802804639694, 21.671102155152063), + Offset(39.94204075298555, 21.917555098992118), + Offset(39.94920417650143, 21.999827480806236), + Offset(39.94921875, 22.0), + ], + ), + _PathCubicTo( + [ + Offset(6.0, 26.0), + Offset(5.976562557689849, 25.638185989482512), + Offset(5.951781669661045, 24.367972149512962), + Offset(6.172793116155802, 21.823631861702058), + Offset(7.363587976838016, 17.665129222832853), + Offset(11.400806749308899, 11.800457098273661), + Offset(17.41878573585796, 8.03287301910486), + Offset(24.257523532175192, 6.996159828679087), + Offset(29.90338248135665, 8.291042849526), + Offset(33.76252909490214, 10.56619705548221), + Offset(36.23501636298456, 12.973675163618006), + Offset(37.77053540180521, 15.158665125787222), + Offset(38.70420448893307, 17.008159945496722), + Offset(39.260392038988186, 18.5104805430827), + Offset(39.58393261852967, 19.691668944482075), + Offset(39.766765502294305, 20.58840471665747), + Offset(39.866421084642994, 21.237322746452932), + Offset(39.91802804639694, 21.671102155152063), + Offset(39.94204075298555, 21.917555098992118), + Offset(39.94920417650143, 21.999827480806236), + Offset(39.94921875, 22.0), + ], + [ + Offset(42.0, 26.0), + Offset(41.91421333157091, 26.360426629492423), + Offset(41.55655262500356, 27.60382930516768), + Offset(40.57766190556539, 29.99090297157744), + Offset(38.19401046368096, 33.57567286235671), + Offset(32.70215654116029, 37.756226919427284), + Offset(26.22621984436523, 39.26167875408963), + Offset(20.102351173097617, 38.04803275423973), + Offset(15.903199608216863, 35.25316524725598), + Offset(13.57741782841064, 32.27000071222682), + Offset(12.442030802775209, 29.665215617986277), + Offset(11.981806515947115, 27.560177578292762), + Offset(11.879421136842055, 25.918712565594948), + Offset(11.95091483982305, 24.66543021784112), + Offset(12.092167805674123, 23.72603017548901), + Offset(12.245452640806768, 23.03857447590349), + Offset(12.379956070248545, 22.554583229506296), + Offset(12.480582865035407, 22.237279988168645), + Offset(12.541514124262473, 22.059212079933666), + Offset(12.562455771803593, 22.000123717314214), + Offset(12.562499999999996, 22.000000000000004), + ], + [ + Offset(42.0, 26.0), + Offset(41.91421333157091, 26.360426629492423), + Offset(41.55655262500356, 27.60382930516768), + Offset(40.57766190556539, 29.99090297157744), + Offset(38.19401046368096, 33.57567286235671), + Offset(32.70215654116029, 37.756226919427284), + Offset(26.22621984436523, 39.26167875408963), + Offset(20.102351173097617, 38.04803275423973), + Offset(15.903199608216863, 35.25316524725598), + Offset(13.57741782841064, 32.27000071222682), + Offset(12.442030802775209, 29.665215617986277), + Offset(11.981806515947115, 27.560177578292762), + Offset(11.879421136842055, 25.918712565594948), + Offset(11.95091483982305, 24.66543021784112), + Offset(12.092167805674123, 23.72603017548901), + Offset(12.245452640806768, 23.03857447590349), + Offset(12.379956070248545, 22.554583229506296), + Offset(12.480582865035407, 22.237279988168645), + Offset(12.541514124262473, 22.059212079933666), + Offset(12.562455771803593, 22.000123717314214), + Offset(12.562499999999996, 22.000000000000004), + ], + ), + _PathCubicTo( + [ + Offset(42.0, 26.0), + Offset(41.91421333157091, 26.360426629492423), + Offset(41.55655262500356, 27.60382930516768), + Offset(40.57766190556539, 29.99090297157744), + Offset(38.19401046368096, 33.57567286235671), + Offset(32.70215654116029, 37.756226919427284), + Offset(26.22621984436523, 39.26167875408963), + Offset(20.102351173097617, 38.04803275423973), + Offset(15.903199608216863, 35.25316524725598), + Offset(13.57741782841064, 32.27000071222682), + Offset(12.442030802775209, 29.665215617986277), + Offset(11.981806515947115, 27.560177578292762), + Offset(11.879421136842055, 25.918712565594948), + Offset(11.95091483982305, 24.66543021784112), + Offset(12.092167805674123, 23.72603017548901), + Offset(12.245452640806768, 23.03857447590349), + Offset(12.379956070248545, 22.554583229506296), + Offset(12.480582865035407, 22.237279988168645), + Offset(12.541514124262473, 22.059212079933666), + Offset(12.562455771803593, 22.000123717314214), + Offset(12.562499999999996, 22.000000000000004), + ], + [ + Offset(42.0, 22.0), + Offset(41.99458528858859, 22.361234167441474), + Offset(41.91859127809106, 23.620246996030513), + Offset(41.501535596836376, 26.09905798461081), + Offset(40.02840620381446, 30.021099432452637), + Offset(35.79419835461124, 35.2186537827727), + Offset(30.076040790179817, 38.175916954629336), + Offset(24.067012730992623, 38.57855959743385), + Offset(19.453150566288006, 37.096490556388844), + Offset(16.506465839286186, 34.99409280868502), + Offset(14.73924581501028, 32.939784778587686), + Offset(13.715334530064114, 31.165018854170466), + Offset(13.140377980959201, 29.714761542791386), + Offset(12.83036672005031, 28.56755327976071), + Offset(12.672939622830032, 27.683643609921106), + Offset(12.600162038813565, 27.02281609043513), + Offset(12.571432188039635, 26.54999771317575), + Offset(12.56310619400641, 26.23642863509033), + Offset(12.562193301685781, 26.059158626029138), + Offset(12.562499038934627, 26.000123717080207), + Offset(12.562499999999996, 26.000000000000004), + ], + [ + Offset(42.0, 22.0), + Offset(41.99458528858859, 22.361234167441474), + Offset(41.91859127809106, 23.620246996030513), + Offset(41.501535596836376, 26.09905798461081), + Offset(40.02840620381446, 30.021099432452637), + Offset(35.79419835461124, 35.2186537827727), + Offset(30.076040790179817, 38.175916954629336), + Offset(24.067012730992623, 38.57855959743385), + Offset(19.453150566288006, 37.096490556388844), + Offset(16.506465839286186, 34.99409280868502), + Offset(14.73924581501028, 32.939784778587686), + Offset(13.715334530064114, 31.165018854170466), + Offset(13.140377980959201, 29.714761542791386), + Offset(12.83036672005031, 28.56755327976071), + Offset(12.672939622830032, 27.683643609921106), + Offset(12.600162038813565, 27.02281609043513), + Offset(12.571432188039635, 26.54999771317575), + Offset(12.56310619400641, 26.23642863509033), + Offset(12.562193301685781, 26.059158626029138), + Offset(12.562499038934627, 26.000123717080207), + Offset(12.562499999999996, 26.000000000000004), + ], + ), + _PathCubicTo( + [ + Offset(42.0, 22.0), + Offset(41.99458528858859, 22.361234167441474), + Offset(41.91859127809106, 23.620246996030513), + Offset(41.501535596836376, 26.09905798461081), + Offset(40.02840620381446, 30.021099432452637), + Offset(35.79419835461124, 35.2186537827727), + Offset(30.076040790179817, 38.175916954629336), + Offset(24.067012730992623, 38.57855959743385), + Offset(19.453150566288006, 37.096490556388844), + Offset(16.506465839286186, 34.99409280868502), + Offset(14.73924581501028, 32.939784778587686), + Offset(13.715334530064114, 31.165018854170466), + Offset(13.140377980959201, 29.714761542791386), + Offset(12.83036672005031, 28.56755327976071), + Offset(12.672939622830032, 27.683643609921106), + Offset(12.600162038813565, 27.02281609043513), + Offset(12.571432188039635, 26.54999771317575), + Offset(12.56310619400641, 26.23642863509033), + Offset(12.562193301685781, 26.059158626029138), + Offset(12.562499038934627, 26.000123717080207), + Offset(12.562499999999996, 26.000000000000004), + ], + [ + Offset(6.0, 22.0), + Offset(6.056934514707525, 21.63899352743156), + Offset(6.3138203227485405, 20.384389840375796), + Offset(7.096666807426793, 17.931786874735423), + Offset(9.197983716971518, 14.110555792928775), + Offset(14.492848562759846, 9.262883961619078), + Offset(21.26860668167255, 6.947111219644562), + Offset(28.222185090070198, 7.526686671873211), + Offset(33.453333439427794, 10.134368158658866), + Offset(36.69157710577769, 13.290289151940406), + Offset(38.53223137521963, 16.248244324219414), + Offset(39.50406341592221, 18.763506401664923), + Offset(39.965161333050226, 20.80420892269316), + Offset(40.139843919215444, 22.41260360500229), + Offset(40.164704435685586, 23.649282378914172), + Offset(40.1214749003011, 24.572646331189105), + Offset(40.057897202434084, 25.232737230122385), + Offset(40.00055137536795, 25.670250802073745), + Offset(39.96271993040885, 25.917501645087587), + Offset(39.949247443632466, 25.99982748057223), + Offset(39.94921875, 26.0), + ], + [ + Offset(6.0, 22.0), + Offset(6.056934514707525, 21.63899352743156), + Offset(6.3138203227485405, 20.384389840375796), + Offset(7.096666807426793, 17.931786874735423), + Offset(9.197983716971518, 14.110555792928775), + Offset(14.492848562759846, 9.262883961619078), + Offset(21.26860668167255, 6.947111219644562), + Offset(28.222185090070198, 7.526686671873211), + Offset(33.453333439427794, 10.134368158658866), + Offset(36.69157710577769, 13.290289151940406), + Offset(38.53223137521963, 16.248244324219414), + Offset(39.50406341592221, 18.763506401664923), + Offset(39.965161333050226, 20.80420892269316), + Offset(40.139843919215444, 22.41260360500229), + Offset(40.164704435685586, 23.649282378914172), + Offset(40.1214749003011, 24.572646331189105), + Offset(40.057897202434084, 25.232737230122385), + Offset(40.00055137536795, 25.670250802073745), + Offset(39.96271993040885, 25.917501645087587), + Offset(39.949247443632466, 25.99982748057223), + Offset(39.94921875, 26.0), + ], + ), + _PathCubicTo( + [ + Offset(6.0, 22.0), + Offset(6.056934514707525, 21.63899352743156), + Offset(6.3138203227485405, 20.384389840375796), + Offset(7.096666807426793, 17.931786874735423), + Offset(9.197983716971518, 14.110555792928775), + Offset(14.492848562759846, 9.262883961619078), + Offset(21.26860668167255, 6.947111219644562), + Offset(28.222185090070198, 7.526686671873211), + Offset(33.453333439427794, 10.134368158658866), + Offset(36.69157710577769, 13.290289151940406), + Offset(38.53223137521963, 16.248244324219414), + Offset(39.50406341592221, 18.763506401664923), + Offset(39.965161333050226, 20.80420892269316), + Offset(40.139843919215444, 22.41260360500229), + Offset(40.164704435685586, 23.649282378914172), + Offset(40.1214749003011, 24.572646331189105), + Offset(40.057897202434084, 25.232737230122385), + Offset(40.00055137536795, 25.670250802073745), + Offset(39.96271993040885, 25.917501645087587), + Offset(39.949247443632466, 25.99982748057223), + Offset(39.94921875, 26.0), + ], + [ + Offset(6.0, 26.0), + Offset(5.976562557689849, 25.638185989482512), + Offset(5.951781669661045, 24.367972149512962), + Offset(6.172793116155802, 21.823631861702058), + Offset(7.363587976838016, 17.665129222832853), + Offset(11.400806749308899, 11.800457098273661), + Offset(17.41878573585796, 8.03287301910486), + Offset(24.257523532175192, 6.996159828679087), + Offset(29.90338248135665, 8.291042849526), + Offset(33.76252909490214, 10.56619705548221), + Offset(36.23501636298456, 12.973675163618006), + Offset(37.77053540180521, 15.158665125787222), + Offset(38.70420448893307, 17.008159945496722), + Offset(39.260392038988186, 18.5104805430827), + Offset(39.58393261852967, 19.691668944482075), + Offset(39.766765502294305, 20.58840471665747), + Offset(39.866421084642994, 21.237322746452932), + Offset(39.91802804639694, 21.671102155152063), + Offset(39.94204075298555, 21.917555098992118), + Offset(39.94920417650143, 21.999827480806236), + Offset(39.94921875, 22.0), + ], + [ + Offset(6.0, 26.0), + Offset(5.976562557689849, 25.638185989482512), + Offset(5.951781669661045, 24.367972149512962), + Offset(6.172793116155802, 21.823631861702058), + Offset(7.363587976838016, 17.665129222832853), + Offset(11.400806749308899, 11.800457098273661), + Offset(17.41878573585796, 8.03287301910486), + Offset(24.257523532175192, 6.996159828679087), + Offset(29.90338248135665, 8.291042849526), + Offset(33.76252909490214, 10.56619705548221), + Offset(36.23501636298456, 12.973675163618006), + Offset(37.77053540180521, 15.158665125787222), + Offset(38.70420448893307, 17.008159945496722), + Offset(39.260392038988186, 18.5104805430827), + Offset(39.58393261852967, 19.691668944482075), + Offset(39.766765502294305, 20.58840471665747), + Offset(39.866421084642994, 21.237322746452932), + Offset(39.91802804639694, 21.671102155152063), + Offset(39.94204075298555, 21.917555098992118), + Offset(39.94920417650143, 21.999827480806236), + Offset(39.94921875, 22.0), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: [ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo( + [ + Offset(6.0, 36.0), + Offset(5.8396336833594695, 35.66398057820908), + Offset(5.329309336374063, 34.47365089829387), + Offset(4.546341863759643, 32.03857491308836), + Offset(3.9472816617934896, 27.893335303194206), + Offset(4.788314785722232, 21.470485758169694), + Offset(7.406922551234356, 16.186721598040453), + Offset(10.987511722222681, 12.449414121983239), + Offset(14.290737577882037, 10.382465570533384), + Offset(16.84152025666389, 9.340052761292668), + Offset(18.753361861843203, 8.79207829497377), + Offset(20.19495897321279, 8.483469022255434), + Offset(21.293826339887335, 8.297708512391797), + Offset(22.135385178177998, 8.180000583359465), + Offset(22.776244370552647, 8.102975309903787), + Offset(23.25488929254563, 8.051973096906334), + Offset(23.598629725699347, 8.018606137477462), + Offset(23.827700643867974, 7.99783596371886), + Offset(23.95771797811348, 7.986559676107813), + Offset(24.001111438945117, 7.982878122631195), + Offset(24.001202429357242, 7.98287044589657), + ], + ), + _PathCubicTo( + [ + Offset(6.0, 36.0), + Offset(5.8396336833594695, 35.66398057820908), + Offset(5.329309336374063, 34.47365089829387), + Offset(4.546341863759643, 32.03857491308836), + Offset(3.9472816617934896, 27.893335303194206), + Offset(4.788314785722232, 21.470485758169694), + Offset(7.406922551234356, 16.186721598040453), + Offset(10.987511722222681, 12.449414121983239), + Offset(14.290737577882037, 10.382465570533384), + Offset(16.84152025666389, 9.340052761292668), + Offset(18.753361861843203, 8.79207829497377), + Offset(20.19495897321279, 8.483469022255434), + Offset(21.293826339887335, 8.297708512391797), + Offset(22.135385178177998, 8.180000583359465), + Offset(22.776244370552647, 8.102975309903787), + Offset(23.25488929254563, 8.051973096906334), + Offset(23.598629725699347, 8.018606137477462), + Offset(23.827700643867974, 7.99783596371886), + Offset(23.95771797811348, 7.986559676107813), + Offset(24.001111438945117, 7.982878122631195), + Offset(24.001202429357242, 7.98287044589657), + ], + [ + Offset(42.0, 36.0), + Offset(41.7493389152824, 36.20520796529164), + Offset(40.85819701033384, 36.89246335931071), + Offset(39.01294315759756, 38.1256246432051), + Offset(35.758514239960064, 39.76970128020763), + Offset(30.180134511403956, 41.28645636464381), + Offset(24.56603417073137, 41.32925393403815), + Offset(19.271926095830622, 39.91690773672663), + Offset(15.201959304751512, 37.5726832793895), + Offset(12.456295622648877, 35.01429311055303), + Offset(10.686459838185314, 32.608514843335385), + Offset(9.579921816288039, 30.502293804851334), + Offset(8.90802993167501, 28.734147272525124), + Offset(8.513791284564158, 27.294928344333726), + Offset(8.292240475325507, 26.156988797411067), + Offset(8.174465865426919, 25.287693028463128), + Offset(8.11616441641861, 24.655137447505503), + Offset(8.089821190085125, 24.230473791307258), + Offset(8.079382709319852, 23.988506993748523), + Offset(8.076631388780909, 23.907616552409003), + Offset(8.076626005900048, 23.907446869353766), + ], + [ + Offset(42.0, 36.0), + Offset(41.7493389152824, 36.20520796529164), + Offset(40.85819701033384, 36.89246335931071), + Offset(39.01294315759756, 38.1256246432051), + Offset(35.758514239960064, 39.76970128020763), + Offset(30.180134511403956, 41.28645636464381), + Offset(24.56603417073137, 41.32925393403815), + Offset(19.271926095830622, 39.91690773672663), + Offset(15.201959304751512, 37.5726832793895), + Offset(12.456295622648877, 35.01429311055303), + Offset(10.686459838185314, 32.608514843335385), + Offset(9.579921816288039, 30.502293804851334), + Offset(8.90802993167501, 28.734147272525124), + Offset(8.513791284564158, 27.294928344333726), + Offset(8.292240475325507, 26.156988797411067), + Offset(8.174465865426919, 25.287693028463128), + Offset(8.11616441641861, 24.655137447505503), + Offset(8.089821190085125, 24.230473791307258), + Offset(8.079382709319852, 23.988506993748523), + Offset(8.076631388780909, 23.907616552409003), + Offset(8.076626005900048, 23.907446869353766), + ], + ), + _PathCubicTo( + [ + Offset(42.0, 36.0), + Offset(41.7493389152824, 36.20520796529164), + Offset(40.85819701033384, 36.89246335931071), + Offset(39.01294315759756, 38.1256246432051), + Offset(35.758514239960064, 39.76970128020763), + Offset(30.180134511403956, 41.28645636464381), + Offset(24.56603417073137, 41.32925393403815), + Offset(19.271926095830622, 39.91690773672663), + Offset(15.201959304751512, 37.5726832793895), + Offset(12.456295622648877, 35.01429311055303), + Offset(10.686459838185314, 32.608514843335385), + Offset(9.579921816288039, 30.502293804851334), + Offset(8.90802993167501, 28.734147272525124), + Offset(8.513791284564158, 27.294928344333726), + Offset(8.292240475325507, 26.156988797411067), + Offset(8.174465865426919, 25.287693028463128), + Offset(8.11616441641861, 24.655137447505503), + Offset(8.089821190085125, 24.230473791307258), + Offset(8.079382709319852, 23.988506993748523), + Offset(8.076631388780909, 23.907616552409003), + Offset(8.076626005900048, 23.907446869353766), + ], + [ + Offset(42.0, 32.0), + Offset(41.803966700752746, 32.205577011286266), + Offset(41.104447603276626, 32.89996903899956), + Offset(39.64402995767152, 34.17517788052204), + Offset(37.031973302731046, 35.97545970343111), + Offset(32.44508133022271, 37.98012671725157), + Offset(27.6644042246058, 38.77327245743646), + Offset(22.963108117227325, 38.302914175295534), + Offset(19.18039906547299, 36.862333955479784), + Offset(16.509090720567585, 35.04434211490934), + Offset(14.703380298498667, 33.21759365821649), + Offset(13.512146444284534, 31.556733263561572), + Offset(12.740174664860898, 30.12862517729895), + Offset(12.248059307884624, 28.947244716051806), + Offset(11.939734974297815, 28.002595790430043), + Offset(11.750425410476474, 27.27521551305395), + Offset(11.637314290474384, 26.742992599694542), + Offset(11.572897732210654, 26.384358993735816), + Offset(11.54031155133882, 26.17955109507089), + Offset(11.530083003283234, 26.111009046369567), + Offset(11.530061897030713, 26.110865227715482), + ], + [ + Offset(42.0, 32.0), + Offset(41.803966700752746, 32.205577011286266), + Offset(41.104447603276626, 32.89996903899956), + Offset(39.64402995767152, 34.17517788052204), + Offset(37.031973302731046, 35.97545970343111), + Offset(32.44508133022271, 37.98012671725157), + Offset(27.6644042246058, 38.77327245743646), + Offset(22.963108117227325, 38.302914175295534), + Offset(19.18039906547299, 36.862333955479784), + Offset(16.509090720567585, 35.04434211490934), + Offset(14.703380298498667, 33.21759365821649), + Offset(13.512146444284534, 31.556733263561572), + Offset(12.740174664860898, 30.12862517729895), + Offset(12.248059307884624, 28.947244716051806), + Offset(11.939734974297815, 28.002595790430043), + Offset(11.750425410476474, 27.27521551305395), + Offset(11.637314290474384, 26.742992599694542), + Offset(11.572897732210654, 26.384358993735816), + Offset(11.54031155133882, 26.17955109507089), + Offset(11.530083003283234, 26.111009046369567), + Offset(11.530061897030713, 26.110865227715482), + ], + ), + _PathCubicTo( + [ + Offset(42.0, 32.0), + Offset(41.803966700752746, 32.205577011286266), + Offset(41.104447603276626, 32.89996903899956), + Offset(39.64402995767152, 34.17517788052204), + Offset(37.031973302731046, 35.97545970343111), + Offset(32.44508133022271, 37.98012671725157), + Offset(27.6644042246058, 38.77327245743646), + Offset(22.963108117227325, 38.302914175295534), + Offset(19.18039906547299, 36.862333955479784), + Offset(16.509090720567585, 35.04434211490934), + Offset(14.703380298498667, 33.21759365821649), + Offset(13.512146444284534, 31.556733263561572), + Offset(12.740174664860898, 30.12862517729895), + Offset(12.248059307884624, 28.947244716051806), + Offset(11.939734974297815, 28.002595790430043), + Offset(11.750425410476474, 27.27521551305395), + Offset(11.637314290474384, 26.742992599694542), + Offset(11.572897732210654, 26.384358993735816), + Offset(11.54031155133882, 26.17955109507089), + Offset(11.530083003283234, 26.111009046369567), + Offset(11.530061897030713, 26.110865227715482), + ], + [ + Offset(6.0, 32.0), + Offset(5.899914425897517, 31.66443482499171), + Offset(5.601001082666045, 30.482888615847468), + Offset(5.242005036683729, 28.09953280239226), + Offset(5.346316156571252, 24.145975901906155), + Offset(7.249241148069178, 18.317100047682345), + Offset(10.710823881370487, 13.931896549234073), + Offset(14.817117889097364, 11.294374466111893), + Offset(18.288493245756, 10.248489378687303), + Offset(20.784419638077317, 10.013509863155594), + Offset(22.541938014255397, 10.075312777589325), + Offset(23.798109358346892, 10.220508832423288), + Offset(24.71461203122786, 10.370924674281323), + Offset(25.392890381083, 10.501349297587215), + Offset(25.896277759611298, 10.60605174724228), + Offset(26.265268043339944, 10.685909272436422), + Offset(26.526795349038366, 10.74364670273436), + Offset(26.699555102368272, 10.782158496973931), + Offset(26.79709065296033, 10.80399872839147), + Offset(26.829561509459538, 10.811282301423006), + Offset(26.829629554119695, 10.811297570626497), + ], + [ + Offset(6.0, 32.0), + Offset(5.899914425897517, 31.66443482499171), + Offset(5.601001082666045, 30.482888615847468), + Offset(5.242005036683729, 28.09953280239226), + Offset(5.346316156571252, 24.145975901906155), + Offset(7.249241148069178, 18.317100047682345), + Offset(10.710823881370487, 13.931896549234073), + Offset(14.817117889097364, 11.294374466111893), + Offset(18.288493245756, 10.248489378687303), + Offset(20.784419638077317, 10.013509863155594), + Offset(22.541938014255397, 10.075312777589325), + Offset(23.798109358346892, 10.220508832423288), + Offset(24.71461203122786, 10.370924674281323), + Offset(25.392890381083, 10.501349297587215), + Offset(25.896277759611298, 10.60605174724228), + Offset(26.265268043339944, 10.685909272436422), + Offset(26.526795349038366, 10.74364670273436), + Offset(26.699555102368272, 10.782158496973931), + Offset(26.79709065296033, 10.80399872839147), + Offset(26.829561509459538, 10.811282301423006), + Offset(26.829629554119695, 10.811297570626497), + ], + ), + _PathCubicTo( + [ + Offset(6.0, 32.0), + Offset(5.899914425897517, 31.66443482499171), + Offset(5.601001082666045, 30.482888615847468), + Offset(5.242005036683729, 28.09953280239226), + Offset(5.346316156571252, 24.145975901906155), + Offset(7.249241148069178, 18.317100047682345), + Offset(10.710823881370487, 13.931896549234073), + Offset(14.817117889097364, 11.294374466111893), + Offset(18.288493245756, 10.248489378687303), + Offset(20.784419638077317, 10.013509863155594), + Offset(22.541938014255397, 10.075312777589325), + Offset(23.798109358346892, 10.220508832423288), + Offset(24.71461203122786, 10.370924674281323), + Offset(25.392890381083, 10.501349297587215), + Offset(25.896277759611298, 10.60605174724228), + Offset(26.265268043339944, 10.685909272436422), + Offset(26.526795349038366, 10.74364670273436), + Offset(26.699555102368272, 10.782158496973931), + Offset(26.79709065296033, 10.80399872839147), + Offset(26.829561509459538, 10.811282301423006), + Offset(26.829629554119695, 10.811297570626497), + ], + [ + Offset(6.0, 36.0), + Offset(5.839633683308566, 35.66398057820831), + Offset(5.329309336323984, 34.47365089829046), + Offset(4.546341863735712, 32.03857491308413), + Offset(3.947281661825336, 27.893335303206097), + Offset(4.788314785746671, 21.47048575818877), + Offset(7.406922551270995, 16.18672159809414), + Offset(10.98751172223972, 12.449414122039723), + Offset(14.290737577881032, 10.382465570503403), + Offset(16.841520256655304, 9.340052761342939), + Offset(18.753361861827802, 8.792078295019234), + Offset(20.194958973207576, 8.483469022266245), + Offset(21.293826339889407, 8.297708512388375), + Offset(22.13538517817335, 8.180000583365981), + Offset(22.776244370563283, 8.102975309890528), + Offset(23.25488929251534, 8.051973096940955), + Offset(23.598629725644848, 8.018606137536025), + Offset(23.82770064384222, 7.997835963745423), + Offset(23.957717978081078, 7.986559676140466), + Offset(24.001111438940168, 7.982878122636148), + Offset(24.001202429373503, 7.982870445880305), + ], + [ + Offset(6.0, 36.0), + Offset(5.839633683308566, 35.66398057820831), + Offset(5.329309336323984, 34.47365089829046), + Offset(4.546341863735712, 32.03857491308413), + Offset(3.947281661825336, 27.893335303206097), + Offset(4.788314785746671, 21.47048575818877), + Offset(7.406922551270995, 16.18672159809414), + Offset(10.98751172223972, 12.449414122039723), + Offset(14.290737577881032, 10.382465570503403), + Offset(16.841520256655304, 9.340052761342939), + Offset(18.753361861827802, 8.792078295019234), + Offset(20.194958973207576, 8.483469022266245), + Offset(21.293826339889407, 8.297708512388375), + Offset(22.13538517817335, 8.180000583365981), + Offset(22.776244370563283, 8.102975309890528), + Offset(23.25488929251534, 8.051973096940955), + Offset(23.598629725644848, 8.018606137536025), + Offset(23.82770064384222, 7.997835963745423), + Offset(23.957717978081078, 7.986559676140466), + Offset(24.001111438940168, 7.982878122636148), + Offset(24.001202429373503, 7.982870445880305), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: [ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo( + [ + Offset(6.0, 16.0), + Offset(6.222470088677106, 15.614531066984553), + Offset(7.071161725316092, 14.306422712262563), + Offset(9.085869786142727, 11.907139949336411), + Offset(13.311519331212619, 8.711520321213257), + Offset(21.694206315186374, 6.462423500731354), + Offset(30.07031570748504, 8.471955170698632), + Offset(36.20036889900587, 14.155750775196541), + Offset(38.533897479983715, 20.76099122996903), + Offset(38.182626701431914, 26.194302454359914), + Offset(36.59711302702814, 30.110286603895076), + Offset(34.63761335058528, 32.76106836363335), + Offset(32.7272901891386, 34.4927008221791), + Offset(31.04869117038896, 35.596105690451935), + Offset(29.664526028757855, 36.28441549314729), + Offset(28.581655311555835, 36.70452225851578), + Offset(27.782897949107628, 36.95396775456513), + Offset(27.242531133855476, 37.09522522130338), + Offset(26.933380541033216, 37.166375518103024), + Offset(26.82984682779076, 37.188656481991416), + Offset(26.829629554103434, 37.18870242935725), + ], + ), + _PathCubicTo( + [ + Offset(6.0, 16.0), + Offset(6.222470088677106, 15.614531066984553), + Offset(7.071161725316092, 14.306422712262563), + Offset(9.085869786142727, 11.907139949336411), + Offset(13.311519331212619, 8.711520321213257), + Offset(21.694206315186374, 6.462423500731354), + Offset(30.07031570748504, 8.471955170698632), + Offset(36.20036889900587, 14.155750775196541), + Offset(38.533897479983715, 20.76099122996903), + Offset(38.182626701431914, 26.194302454359914), + Offset(36.59711302702814, 30.110286603895076), + Offset(34.63761335058528, 32.76106836363335), + Offset(32.7272901891386, 34.4927008221791), + Offset(31.04869117038896, 35.596105690451935), + Offset(29.664526028757855, 36.28441549314729), + Offset(28.581655311555835, 36.70452225851578), + Offset(27.782897949107628, 36.95396775456513), + Offset(27.242531133855476, 37.09522522130338), + Offset(26.933380541033216, 37.166375518103024), + Offset(26.82984682779076, 37.188656481991416), + Offset(26.829629554103434, 37.18870242935725), + ], + [ + Offset(42.0, 16.0), + Offset(42.119273441095075, 16.516374018071716), + Offset(42.428662704565184, 18.32937541467259), + Offset(42.54812490043565, 21.94159775950881), + Offset(41.3111285319893, 27.683594454682137), + Offset(36.06395079582478, 35.01020271691918), + Offset(28.59459512599702, 38.51093769070532), + Offset(21.239886122259133, 38.07233071493643), + Offset(16.251628495692138, 35.34156866251391), + Offset(13.527101819238178, 32.27103394597236), + Offset(12.16858814546228, 29.604397296366464), + Offset(11.548946515009288, 27.474331231158473), + Offset(11.311114637013635, 25.826563435488687), + Offset(11.262012546535352, 24.572239162454554), + Offset(11.298221100690522, 23.63118177535833), + Offset(11.364474416879979, 22.940254245947138), + Offset(11.431638843687892, 22.451805922237554), + Offset(11.485090012547001, 22.130328573710905), + Offset(11.518417313485447, 21.949395273355513), + Offset(11.530012405933167, 21.889264075838188), + Offset(11.53003696527787, 21.889138124802937), + ], + [ + Offset(42.0, 16.0), + Offset(42.119273441095075, 16.516374018071716), + Offset(42.428662704565184, 18.32937541467259), + Offset(42.54812490043565, 21.94159775950881), + Offset(41.3111285319893, 27.683594454682137), + Offset(36.06395079582478, 35.01020271691918), + Offset(28.59459512599702, 38.51093769070532), + Offset(21.239886122259133, 38.07233071493643), + Offset(16.251628495692138, 35.34156866251391), + Offset(13.527101819238178, 32.27103394597236), + Offset(12.16858814546228, 29.604397296366464), + Offset(11.548946515009288, 27.474331231158473), + Offset(11.311114637013635, 25.826563435488687), + Offset(11.262012546535352, 24.572239162454554), + Offset(11.298221100690522, 23.63118177535833), + Offset(11.364474416879979, 22.940254245947138), + Offset(11.431638843687892, 22.451805922237554), + Offset(11.485090012547001, 22.130328573710905), + Offset(11.518417313485447, 21.949395273355513), + Offset(11.530012405933167, 21.889264075838188), + Offset(11.53003696527787, 21.889138124802937), + ], + ), + _PathCubicTo( + [ + Offset(42.0, 16.0), + Offset(42.119273441095075, 16.516374018071716), + Offset(42.428662704565184, 18.32937541467259), + Offset(42.54812490043565, 21.94159775950881), + Offset(41.3111285319893, 27.683594454682137), + Offset(36.06395079582478, 35.01020271691918), + Offset(28.59459512599702, 38.51093769070532), + Offset(21.239886122259133, 38.07233071493643), + Offset(16.251628495692138, 35.34156866251391), + Offset(13.527101819238178, 32.27103394597236), + Offset(12.16858814546228, 29.604397296366464), + Offset(11.548946515009288, 27.474331231158473), + Offset(11.311114637013635, 25.826563435488687), + Offset(11.262012546535352, 24.572239162454554), + Offset(11.298221100690522, 23.63118177535833), + Offset(11.364474416879979, 22.940254245947138), + Offset(11.431638843687892, 22.451805922237554), + Offset(11.485090012547001, 22.130328573710905), + Offset(11.518417313485447, 21.949395273355513), + Offset(11.530012405933167, 21.889264075838188), + Offset(11.53003696527787, 21.889138124802937), + ], + [ + Offset(42.0, 12.0), + Offset(42.22538630246601, 12.517777761542249), + Offset(42.90619853384615, 14.357900907446863), + Offset(43.759884509852945, 18.128995147835514), + Offset(43.66585885175813, 24.44736028078141), + Offset(39.74861752085834, 33.43380529842439), + Offset(32.57188683977151, 39.07136996422343), + Offset(24.376857043988256, 40.600018479197814), + Offset(17.959269400168804, 39.004426856660785), + Offset(13.850567169499653, 36.311009998593796), + Offset(11.374155956344177, 33.58880277176081), + Offset(9.917496515696001, 31.204288894581083), + Offset(9.07498759074148, 29.236785710939074), + Offset(8.597571742452605, 27.666692096657314), + Offset(8.334783321442917, 26.44693980672826), + Offset(8.195874559699876, 25.52824222288586), + Offset(8.126295299747222, 24.866824239052814), + Offset(8.093843447379264, 24.426077640310794), + Offset(8.080338503727083, 24.17611706018137), + Offset(8.076619249177135, 24.092742069165425), + Offset(8.07661186374038, 24.09256727275783), + ], + [ + Offset(42.0, 12.0), + Offset(42.22538630246601, 12.517777761542249), + Offset(42.90619853384615, 14.357900907446863), + Offset(43.759884509852945, 18.128995147835514), + Offset(43.66585885175813, 24.44736028078141), + Offset(39.74861752085834, 33.43380529842439), + Offset(32.57188683977151, 39.07136996422343), + Offset(24.376857043988256, 40.600018479197814), + Offset(17.959269400168804, 39.004426856660785), + Offset(13.850567169499653, 36.311009998593796), + Offset(11.374155956344177, 33.58880277176081), + Offset(9.917496515696001, 31.204288894581083), + Offset(9.07498759074148, 29.236785710939074), + Offset(8.597571742452605, 27.666692096657314), + Offset(8.334783321442917, 26.44693980672826), + Offset(8.195874559699876, 25.52824222288586), + Offset(8.126295299747222, 24.866824239052814), + Offset(8.093843447379264, 24.426077640310794), + Offset(8.080338503727083, 24.17611706018137), + Offset(8.076619249177135, 24.092742069165425), + Offset(8.07661186374038, 24.09256727275783), + ], + ), + _PathCubicTo( + [ + Offset(42.0, 12.0), + Offset(42.22538630246601, 12.517777761542249), + Offset(42.90619853384615, 14.357900907446863), + Offset(43.759884509852945, 18.128995147835514), + Offset(43.66585885175813, 24.44736028078141), + Offset(39.74861752085834, 33.43380529842439), + Offset(32.57188683977151, 39.07136996422343), + Offset(24.376857043988256, 40.600018479197814), + Offset(17.959269400168804, 39.004426856660785), + Offset(13.850567169499653, 36.311009998593796), + Offset(11.374155956344177, 33.58880277176081), + Offset(9.917496515696001, 31.204288894581083), + Offset(9.07498759074148, 29.236785710939074), + Offset(8.597571742452605, 27.666692096657314), + Offset(8.334783321442917, 26.44693980672826), + Offset(8.195874559699876, 25.52824222288586), + Offset(8.126295299747222, 24.866824239052814), + Offset(8.093843447379264, 24.426077640310794), + Offset(8.080338503727083, 24.17611706018137), + Offset(8.076619249177135, 24.092742069165425), + Offset(8.07661186374038, 24.09256727275783), + ], + [ + Offset(6.0, 12.0), + Offset(6.3229312318803075, 11.61579282114921), + Offset(7.523361420980265, 10.332065476778915), + Offset(10.234818160108134, 8.075701885898315), + Offset(15.555284551985588, 5.400098023461183), + Offset(25.267103519984172, 4.663978182144188), + Offset(34.065497532306516, 8.668225867992323), + Offset(39.59155761731576, 16.27703318845691), + Offset(40.72409454498984, 24.108085016590273), + Offset(39.139841854472834, 30.0780814324673), + Offset(36.514293313228855, 34.10942912386185), + Offset(33.744815583253256, 36.6601595585975), + Offset(31.226861893018718, 38.20062678263231), + Offset(29.10189988007002, 39.09038725780428), + Offset(27.3951953205187, 39.57837027981981), + Offset(26.083922435637483, 39.82883505984612), + Offset(25.128742795932077, 39.94653528477588), + Offset(24.487982707377697, 39.99564983955995), + Offset(24.123290412440365, 40.013021521592925), + Offset(24.001457946431486, 40.017121849607435), + Offset(24.001202429333205, 40.017129554079396), + ], + [ + Offset(6.0, 12.0), + Offset(6.3229312318803075, 11.61579282114921), + Offset(7.523361420980265, 10.332065476778915), + Offset(10.234818160108134, 8.075701885898315), + Offset(15.555284551985588, 5.400098023461183), + Offset(25.267103519984172, 4.663978182144188), + Offset(34.065497532306516, 8.668225867992323), + Offset(39.59155761731576, 16.27703318845691), + Offset(40.72409454498984, 24.108085016590273), + Offset(39.139841854472834, 30.0780814324673), + Offset(36.514293313228855, 34.10942912386185), + Offset(33.744815583253256, 36.6601595585975), + Offset(31.226861893018718, 38.20062678263231), + Offset(29.10189988007002, 39.09038725780428), + Offset(27.3951953205187, 39.57837027981981), + Offset(26.083922435637483, 39.82883505984612), + Offset(25.128742795932077, 39.94653528477588), + Offset(24.487982707377697, 39.99564983955995), + Offset(24.123290412440365, 40.013021521592925), + Offset(24.001457946431486, 40.017121849607435), + Offset(24.001202429333205, 40.017129554079396), + ], + ), + _PathCubicTo( + [ + Offset(6.0, 12.0), + Offset(6.3229312318803075, 11.61579282114921), + Offset(7.523361420980265, 10.332065476778915), + Offset(10.234818160108134, 8.075701885898315), + Offset(15.555284551985588, 5.400098023461183), + Offset(25.267103519984172, 4.663978182144188), + Offset(34.065497532306516, 8.668225867992323), + Offset(39.59155761731576, 16.27703318845691), + Offset(40.72409454498984, 24.108085016590273), + Offset(39.139841854472834, 30.0780814324673), + Offset(36.514293313228855, 34.10942912386185), + Offset(33.744815583253256, 36.6601595585975), + Offset(31.226861893018718, 38.20062678263231), + Offset(29.10189988007002, 39.09038725780428), + Offset(27.3951953205187, 39.57837027981981), + Offset(26.083922435637483, 39.82883505984612), + Offset(25.128742795932077, 39.94653528477588), + Offset(24.487982707377697, 39.99564983955995), + Offset(24.123290412440365, 40.013021521592925), + Offset(24.001457946431486, 40.017121849607435), + Offset(24.001202429333205, 40.017129554079396), + ], + [ + Offset(6.0, 16.0), + Offset(6.22247008872931, 15.614531066985863), + Offset(7.071161725356028, 14.306422712267109), + Offset(9.085869786222908, 11.907139949360454), + Offset(13.311519331206826, 8.711520321209331), + Offset(21.69420631520211, 6.462423500762615), + Offset(30.070315707485825, 8.471955170682651), + Offset(36.20036889903345, 14.155750775152455), + Offset(38.53389748002304, 20.760991229943293), + Offset(38.18262670145813, 26.194302454353455), + Offset(36.597113027065134, 30.110286603895844), + Offset(34.63761335066132, 32.761068363650764), + Offset(32.72729018913396, 34.49270082217723), + Offset(31.048691170407302, 35.59610569046216), + Offset(29.66452602881138, 36.28441549318417), + Offset(28.58165531160348, 36.70452225855387), + Offset(27.78289794916673, 36.95396775461755), + Offset(27.24253113386635, 37.09522522131371), + Offset(26.933380541051008, 37.16637551812059), + Offset(26.829846827821875, 37.18865648202253), + Offset(26.829629554079393, 37.188702429333205), + ], + [ + Offset(6.0, 16.0), + Offset(6.22247008872931, 15.614531066985863), + Offset(7.071161725356028, 14.306422712267109), + Offset(9.085869786222908, 11.907139949360454), + Offset(13.311519331206826, 8.711520321209331), + Offset(21.69420631520211, 6.462423500762615), + Offset(30.070315707485825, 8.471955170682651), + Offset(36.20036889903345, 14.155750775152455), + Offset(38.53389748002304, 20.760991229943293), + Offset(38.18262670145813, 26.194302454353455), + Offset(36.597113027065134, 30.110286603895844), + Offset(34.63761335066132, 32.761068363650764), + Offset(32.72729018913396, 34.49270082217723), + Offset(31.048691170407302, 35.59610569046216), + Offset(29.66452602881138, 36.28441549318417), + Offset(28.58165531160348, 36.70452225855387), + Offset(27.78289794916673, 36.95396775461755), + Offset(27.24253113386635, 37.09522522131371), + Offset(26.933380541051008, 37.16637551812059), + Offset(26.829846827821875, 37.18865648202253), + Offset(26.829629554079393, 37.188702429333205), + ], + ), + _PathClose(), + ], + ), + ], + matchTextDirection: true, + ); +} diff --git a/lib/widgets/common/basic/draggable_scrollbar.dart b/lib/widgets/common/basic/draggable_scrollbar.dart index c5acbaa60..fb7bd7b11 100644 --- a/lib/widgets/common/basic/draggable_scrollbar.dart +++ b/lib/widgets/common/basic/draggable_scrollbar.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/material.dart'; /* @@ -9,6 +10,8 @@ import 'package:flutter/material.dart'; - allow any `Widget` as label content - moved out constraints responsibility - various extent & thumb positioning fixes + - null safety + - directionality aware */ /// Build the Scroll Thumb and label using the current configuration @@ -116,7 +119,7 @@ class ScrollLabel extends StatelessWidget { return FadeTransition( opacity: animation, child: Container( - margin: const EdgeInsets.only(right: 12.0), + margin: const EdgeInsetsDirectional.only(end: 12.0), child: Material( elevation: 4.0, color: backgroundColor, @@ -350,8 +353,8 @@ class SlideFadeTransition extends StatelessWidget { builder: (context, child) => animation.value == 0.0 ? Container() : child!, child: SlideTransition( position: Tween( - begin: const Offset(0.3, 0.0), - end: const Offset(0.0, 0.0), + begin: Offset((context.isRtl ? -1 : 1) * .3, 0), + end: Offset.zero, ).animate(animation), child: FadeTransition( opacity: animation, diff --git a/lib/widgets/common/basic/insets.dart b/lib/widgets/common/basic/insets.dart index 2727565e6..4915aea22 100644 --- a/lib/widgets/common/basic/insets.dart +++ b/lib/widgets/common/basic/insets.dart @@ -33,6 +33,8 @@ class SideGestureAreaProtector extends StatelessWidget { Widget build(BuildContext context) { return Positioned.fill( child: Row( + // `systemGestureInsets` are not directional + textDirection: TextDirection.ltr, children: [ SizedBox( width: context.select((mq) => mq.systemGestureInsets.left), diff --git a/lib/widgets/common/basic/markdown_container.dart b/lib/widgets/common/basic/markdown_container.dart index 47e28bc4a..76110e64f 100644 --- a/lib/widgets/common/basic/markdown_container.dart +++ b/lib/widgets/common/basic/markdown_container.dart @@ -4,10 +4,12 @@ import 'package:url_launcher/url_launcher.dart'; class MarkdownContainer extends StatelessWidget { final String data; + final TextDirection? textDirection; const MarkdownContainer({ Key? key, required this.data, + this.textDirection, }) : super(key: key); static const double maxWidth = 460; @@ -34,15 +36,18 @@ class MarkdownContainer extends StatelessWidget { ), ), child: Scrollbar( - child: Markdown( - data: data, - selectable: true, - onTapLink: (text, href, title) async { - if (href != null && await canLaunch(href)) { - await launch(href); - } - }, - shrinkWrap: true, + child: Directionality( + textDirection: textDirection ?? Directionality.of(context), + child: Markdown( + data: data, + selectable: true, + onTapLink: (text, href, title) async { + if (href != null && await canLaunch(href)) { + await launch(href); + } + }, + shrinkWrap: true, + ), ), ), ), diff --git a/lib/widgets/common/basic/menu.dart b/lib/widgets/common/basic/menu.dart index ad96aa9ef..58a5dcc4f 100644 --- a/lib/widgets/common/basic/menu.dart +++ b/lib/widgets/common/basic/menu.dart @@ -18,7 +18,7 @@ class MenuRow extends StatelessWidget { children: [ if (icon != null) Padding( - padding: const EdgeInsets.only(right: 8), + padding: const EdgeInsetsDirectional.only(end: 8), child: icon, ), Expanded(child: Text(text)), diff --git a/lib/widgets/common/extensions/build_context.dart b/lib/widgets/common/extensions/build_context.dart index cf3a515ee..6a4a86af0 100644 --- a/lib/widgets/common/extensions/build_context.dart +++ b/lib/widgets/common/extensions/build_context.dart @@ -5,4 +5,6 @@ extension ExtraContext on BuildContext { String? get currentRouteName => ModalRoute.of(this)?.settings.name; AppLocalizations get l10n => AppLocalizations.of(this)!; + + bool get isRtl => Directionality.of(this) == TextDirection.rtl; } diff --git a/lib/widgets/common/fx/borders.dart b/lib/widgets/common/fx/borders.dart index 830cdd4b5..766cd8671 100644 --- a/lib/widgets/common/fx/borders.dart +++ b/lib/widgets/common/fx/borders.dart @@ -6,12 +6,22 @@ class AvesBorder { static const borderColor = Colors.white30; // directly uses `devicePixelRatio` as it never changes, to avoid visiting ancestors via `MediaQuery` - static double get borderWidth => window.devicePixelRatio > 2 ? 0.5 : 1.0; - static BorderSide get side => BorderSide( + // 1 device pixel for straight lines is fine + static double get straightBorderWidth => 1 / window.devicePixelRatio; + + // 1 device pixel for curves is too thin + static double get curvedBorderWidth => window.devicePixelRatio > 2 ? 0.5 : 1.0; + + static BorderSide get straightSide => BorderSide( color: borderColor, - width: borderWidth, + width: straightBorderWidth, ); - static Border get border => Border.fromBorderSide(side); + static BorderSide get curvedSide => BorderSide( + color: borderColor, + width: curvedBorderWidth, + ); + + static Border get border => Border.fromBorderSide(curvedSide); } diff --git a/lib/widgets/common/grid/header.dart b/lib/widgets/common/grid/header.dart index 9d1b9c356..bcb0e40e0 100644 --- a/lib/widgets/common/grid/header.dart +++ b/lib/widgets/common/grid/header.dart @@ -25,8 +25,6 @@ class SectionHeader extends StatelessWidget { }) : super(key: key); static const leadingDimension = 32.0; - static const leadingPadding = EdgeInsets.only(right: 8, bottom: 4); - static const trailingPadding = EdgeInsets.only(left: 8, bottom: 2); static const padding = EdgeInsets.all(16); static const widgetSpanAlignment = PlaceholderAlignment.middle; @@ -48,7 +46,7 @@ class SectionHeader extends StatelessWidget { sectionKey: sectionKey, browsingBuilder: leading != null ? (context) => Container( - padding: leadingPadding, + padding: const EdgeInsetsDirectional.only(end: 8, bottom: 4), width: leadingDimension, height: leadingDimension, child: leading, @@ -65,7 +63,7 @@ class SectionHeader extends StatelessWidget { WidgetSpan( alignment: widgetSpanAlignment, child: Container( - padding: trailingPadding, + padding: const EdgeInsetsDirectional.only(start: 8, bottom: 2), child: trailing, ), ), @@ -100,7 +98,7 @@ class SectionHeader extends StatelessWidget { final para = RenderParagraph( TextSpan( children: [ - // as of Flutter v1.22.3, `RenderParagraph` fails to lay out `WidgetSpan` offscreen + // as of Flutter v2.8.1, `RenderParagraph` fails to lay out `WidgetSpan` offscreen // so we use a hair space times a magic number to match width TextSpan( // 23 hair spaces match a width of 40.0 diff --git a/lib/widgets/common/grid/overlay.dart b/lib/widgets/common/grid/overlay.dart index dafa4fd7d..c9c111df4 100644 --- a/lib/widgets/common/grid/overlay.dart +++ b/lib/widgets/common/grid/overlay.dart @@ -30,8 +30,9 @@ class GridItemSelectionOverlay extends StatelessWidget { ? OverlayIcon( key: ValueKey(isSelected), icon: isSelected ? AIcons.selected : AIcons.unselected, + margin: EdgeInsets.zero, ) - : const SizedBox.shrink(); + : const SizedBox(); child = AnimatedSwitcher( duration: duration, switchInCurve: Curves.easeOutBack, diff --git a/lib/widgets/common/grid/scaling.dart b/lib/widgets/common/grid/scaling.dart index 58aacf147..15097ba46 100644 --- a/lib/widgets/common/grid/scaling.dart +++ b/lib/widgets/common/grid/scaling.dart @@ -4,6 +4,7 @@ import 'package:aves/model/highlight.dart'; import 'package:aves/model/source/enums.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/common/behaviour/eager_scale_gesture_recognizer.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/grid/theme.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/tile_extent_controller.dart'; @@ -304,7 +305,7 @@ class _ScaleOverlayState extends State<_ScaleOverlay> { gradientCenter = center; break; case TileLayout.list: - gradientCenter = Offset(0, center.dy); + gradientCenter = Offset(context.isRtl ? gridWidth : 0, center.dy); break; } @@ -338,6 +339,7 @@ class GridPainter extends CustomPainter { final double spacing, borderWidth; final Radius borderRadius; final Color color; + final TextDirection textDirection; const GridPainter({ required this.tileLayout, @@ -347,6 +349,7 @@ class GridPainter extends CustomPainter { required this.borderWidth, required this.borderRadius, required this.color, + required this.textDirection, }); @override @@ -375,7 +378,8 @@ class GridPainter extends CustomPainter { break; case TileLayout.list: chipSize = Size.square(tileSize.shortestSide); - chipCenter = Offset(chipSize.width / 2, tileCenter.dy); + final chipCenterToEdge = chipSize.width / 2; + chipCenter = Offset(textDirection == TextDirection.rtl ? size.width - chipCenterToEdge : chipCenterToEdge, tileCenter.dy); deltaColumn = 0; strokeShader = ui.Gradient.linear( tileCenter - Offset(0, chipSize.shortestSide * 3), diff --git a/lib/widgets/common/grid/section_layout.dart b/lib/widgets/common/grid/section_layout.dart index c2d9999bc..c30ac4c7d 100644 --- a/lib/widgets/common/grid/section_layout.dart +++ b/lib/widgets/common/grid/section_layout.dart @@ -133,6 +133,7 @@ abstract class SectionedListLayoutProvider extends StatelessWidget { width: tileWidth, height: tileHeight, spacing: spacing, + textDirection: Directionality.of(context), children: children, ); } @@ -190,6 +191,7 @@ class SectionedListLayout { required this.sectionLayouts, }); + // return tile rectangle in layout space, i.e. x=0 is start Rect? getTileRect(T item) { final MapEntry>? section = sections.entries.firstWhereOrNull((kv) => kv.value.contains(item)); if (section == null) return null; @@ -210,6 +212,7 @@ class SectionedListLayout { SectionLayout? getSectionAt(double offsetY) => sectionLayouts.firstWhereOrNull((sl) => offsetY < sl.maxOffset); + // `position` in layout space, i.e. x=0 is start T? getItemAt(Offset position) { var dy = position.dy; final sectionLayout = getSectionAt(dy); @@ -283,12 +286,14 @@ class SectionLayout extends Equatable { class _GridRow extends MultiChildRenderObjectWidget { final double width, height, spacing; + final TextDirection textDirection; _GridRow({ Key? key, required this.width, required this.height, required this.spacing, + required this.textDirection, required List children, }) : super(key: key, children: children); @@ -298,6 +303,7 @@ class _GridRow extends MultiChildRenderObjectWidget { width: width, height: height, spacing: spacing, + textDirection: textDirection, ); } @@ -306,6 +312,7 @@ class _GridRow extends MultiChildRenderObjectWidget { renderObject.width = width; renderObject.height = height; renderObject.spacing = spacing; + renderObject.textDirection = textDirection; } @override @@ -314,6 +321,7 @@ class _GridRow extends MultiChildRenderObjectWidget { properties.add(DoubleProperty('width', width)); properties.add(DoubleProperty('height', height)); properties.add(DoubleProperty('spacing', spacing)); + properties.add(EnumProperty('textDirection', textDirection)); } } @@ -325,9 +333,11 @@ class _RenderGridRow extends RenderBox with ContainerRenderObjectMixin _textDirection; + TextDirection _textDirection; + + set textDirection(TextDirection value) { + if (_textDirection == value) return; + _textDirection = value; + markNeedsLayout(); + } + @override void setupParentData(RenderBox child) { if (child.parentData is! _GridRowParentData) { @@ -388,12 +407,14 @@ class _RenderGridRow extends RenderBox with ContainerRenderObjectMixin('textDirection', textDirection)); } } diff --git a/lib/widgets/common/grid/selector.dart b/lib/widgets/common/grid/selector.dart index 92604fbf4..05f41d58c 100644 --- a/lib/widgets/common/grid/selector.dart +++ b/lib/widgets/common/grid/selector.dart @@ -3,12 +3,14 @@ import 'dart:math'; import 'package:aves/model/selection.dart'; import 'package:aves/utils/math_utils.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/media_query.dart'; import 'package:aves/widgets/common/grid/section_layout.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; class GridSelectionGestureDetector extends StatefulWidget { + final GlobalKey scrollableKey; final bool selectable; final List items; final ScrollController scrollController; @@ -17,6 +19,7 @@ class GridSelectionGestureDetector extends StatefulWidget { const GridSelectionGestureDetector({ Key? key, + required this.scrollableKey, this.selectable = true, required this.items, required this.scrollController, @@ -42,6 +45,13 @@ class _GridSelectionGestureDetectorState extends State widget.appBarHeightNotifier.value; + double get scrollableWidth { + final scrollableContext = widget.scrollableKey.currentContext!; + final scrollableBox = scrollableContext.findRenderObject() as RenderBox; + // not the same as `MediaQuery.size.width`, because of screen insets/padding + return scrollableBox.size.width; + } + static const double scrollEdgeRatio = .15; static const double scrollMaxPixelPerSecond = 600.0; static const Duration scrollUpdateInterval = Duration(milliseconds: 100); @@ -147,7 +157,7 @@ class _GridSelectionGestureDetectorState extends State>(); - return sectionedListLayout.getItemAt(offset); + return sectionedListLayout.getItemAt(context.isRtl ? Offset(scrollableWidth - offset.dx, offset.dy) : offset); } void _toggleSelectionToIndex(int toIndex) { diff --git a/lib/widgets/common/grid/sliver.dart b/lib/widgets/common/grid/sliver.dart index b8f8299ef..d322a5a4f 100644 --- a/lib/widgets/common/grid/sliver.dart +++ b/lib/widgets/common/grid/sliver.dart @@ -76,18 +76,18 @@ class _RenderSliverKnownExtentBoxAdaptor extends RenderSliverMultiBoxAdaptor { SectionLayout? sectionAtIndex(int index) => sectionLayouts.firstWhereOrNull((section) => section.hasChild(index)); - SectionLayout sectionAtOffset(double scrollOffset) => sectionLayouts.firstWhere((section) => section.hasChildAtOffset(scrollOffset), orElse: () => sectionLayouts.last); + SectionLayout? sectionAtOffset(double scrollOffset) => sectionLayouts.firstWhereOrNull((section) => section.hasChildAtOffset(scrollOffset)) ?? sectionLayouts.lastOrNull; double indexToLayoutOffset(int index) { return (sectionAtIndex(index) ?? sectionLayouts.last).indexToLayoutOffset(index); } int getMinChildIndexForScrollOffset(double scrollOffset) { - return sectionAtOffset(scrollOffset).getMinChildIndexForScrollOffset(scrollOffset); + return sectionAtOffset(scrollOffset)?.getMinChildIndexForScrollOffset(scrollOffset) ?? 0; } int getMaxChildIndexForScrollOffset(double scrollOffset) { - return sectionAtOffset(scrollOffset).getMaxChildIndexForScrollOffset(scrollOffset); + return sectionAtOffset(scrollOffset)?.getMaxChildIndexForScrollOffset(scrollOffset) ?? 0; } double estimateMaxScrollOffset( diff --git a/lib/widgets/common/identity/aves_filter_chip.dart b/lib/widgets/common/identity/aves_filter_chip.dart index 4c790c718..b8504df7d 100644 --- a/lib/widgets/common/identity/aves_filter_chip.dart +++ b/lib/widgets/common/identity/aves_filter_chip.dart @@ -182,8 +182,9 @@ class _AvesFilterChipState extends State { Flexible( child: Text( filter.getLabel(context), - style: const TextStyle( + style: TextStyle( fontSize: AvesFilterChip.fontSize, + decoration: filter.not ? TextDecoration.lineThrough : null, ), softWrap: false, overflow: TextOverflow.fade, @@ -282,20 +283,26 @@ class _AvesFilterChipState extends State { ), ), if (banner != null) - LayoutBuilder(builder: (context, constraints) { - return ClipRRect( - borderRadius: borderRadius, - child: Transform( - transform: Matrix4.identity().scaled((constraints.maxHeight / 90 - .4).clamp(.45, 1.0)), - child: Banner( - message: banner.toUpperCase(), - location: BannerLocation.topStart, - color: Theme.of(context).colorScheme.secondary, - child: const SizedBox(), + LayoutBuilder( + builder: (context, constraints) { + return ClipRRect( + borderRadius: borderRadius, + child: Align( + // align to corner the scaled down banner in RTL + alignment: AlignmentDirectional.topStart, + child: Transform( + transform: Matrix4.identity().scaled((constraints.maxHeight / 90 - .4).clamp(.45, 1.0)), + child: Banner( + message: banner.toUpperCase(), + location: BannerLocation.topStart, + color: Theme.of(context).colorScheme.secondary, + child: const SizedBox(), + ), + ), ), - ), - ); - }), + ); + }, + ), ], ), ); diff --git a/lib/widgets/common/identity/aves_icons.dart b/lib/widgets/common/identity/aves_icons.dart index 4527a50ac..efd6550f7 100644 --- a/lib/widgets/common/identity/aves_icons.dart +++ b/lib/widgets/common/identity/aves_icons.dart @@ -50,8 +50,8 @@ class AnimatedImageIcon extends StatelessWidget { } } -class GeotiffIcon extends StatelessWidget { - const GeotiffIcon({Key? key}) : super(key: key); +class GeoTiffIcon extends StatelessWidget { + const GeoTiffIcon({Key? key}) : super(key: key); @override Widget build(BuildContext context) { @@ -181,12 +181,15 @@ class OverlayIcon extends StatelessWidget { final IconData icon; final String? text; final double iconScale; + final EdgeInsets margin; const OverlayIcon({ Key? key, required this.icon, this.iconScale = 1, this.text, + // default margin for multiple icons in a `Column` + this.margin = const EdgeInsets.only(left: 1, right: 1, bottom: 1), }) : super(key: key); @override @@ -211,8 +214,8 @@ class OverlayIcon extends StatelessWidget { ); return Container( - margin: const EdgeInsets.only(left: 1, right: 1, bottom: 1), - padding: text != null ? EdgeInsets.only(right: size / 4) : null, + margin: margin, + padding: text != null ? EdgeInsetsDirectional.only(end: size / 4) : null, decoration: BoxDecoration( color: const Color(0xBB000000), borderRadius: BorderRadius.all(Radius.circular(size)), diff --git a/lib/widgets/common/identity/scroll_thumb.dart b/lib/widgets/common/identity/scroll_thumb.dart index b79cc1fb7..3b6ccf73f 100644 --- a/lib/widgets/common/identity/scroll_thumb.dart +++ b/lib/widgets/common/identity/scroll_thumb.dart @@ -15,7 +15,7 @@ ScrollThumbBuilder avesScrollThumbBuilder({ borderRadius: BorderRadius.all(Radius.circular(12)), ), height: height, - margin: const EdgeInsets.only(right: .5), + margin: const EdgeInsetsDirectional.only(end: 1), padding: const EdgeInsets.all(2), child: ClipPath( clipper: ArrowClipper(), diff --git a/lib/widgets/common/map/buttons.dart b/lib/widgets/common/map/buttons.dart index dc5156dcd..0a933970d 100644 --- a/lib/widgets/common/map/buttons.dart +++ b/lib/widgets/common/map/buttons.dart @@ -320,7 +320,7 @@ class _OverlayCoordinateFilterChipState extends State<_OverlayCoordinateFilterCh filter: filter, useFilterColor: false, maxWidth: double.infinity, - onTap: (filter) => FilterSelectedNotification(CoordinateFilter(bounds.sw, bounds.ne) ).dispatch(context), + onTap: (filter) => FilterSelectedNotification(CoordinateFilter(bounds.sw, bounds.ne)).dispatch(context), ), ), ); diff --git a/lib/widgets/common/map/controller.dart b/lib/widgets/common/map/controller.dart index d47cd92d1..4bb280bb2 100644 --- a/lib/widgets/common/map/controller.dart +++ b/lib/widgets/common/map/controller.dart @@ -12,6 +12,8 @@ class AvesMapController { Stream get idleUpdates => _events.where((event) => event is MapIdleUpdate).cast(); + Stream get markerLocationChanges => _events.where((event) => event is MapMarkerLocationChangeEvent).cast(); + void dispose() { _streamController.close(); } @@ -19,6 +21,8 @@ class AvesMapController { void moveTo(LatLng latLng) => _streamController.add(MapControllerMoveEvent(latLng)); void notifyIdle(ZoomedBounds bounds) => _streamController.add(MapIdleUpdate(bounds)); + + void notifyMarkerLocationChange() => _streamController.add(MapMarkerLocationChangeEvent()); } class MapControllerMoveEvent { @@ -32,3 +36,5 @@ class MapIdleUpdate { MapIdleUpdate(this.bounds); } + +class MapMarkerLocationChangeEvent {} diff --git a/lib/widgets/common/map/geo_map.dart b/lib/widgets/common/map/geo_map.dart index 790dd17e6..f52cae77e 100644 --- a/lib/widgets/common/map/geo_map.dart +++ b/lib/widgets/common/map/geo_map.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math'; import 'package:aves/model/entry.dart'; @@ -23,17 +24,18 @@ import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart'; import 'package:fluster/fluster.dart'; import 'package:flutter/material.dart'; +import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; class GeoMap extends StatefulWidget { final AvesMapController? controller; final Listenable? collectionListenable; final List entries; - final AvesEntry? initialEntry; + final LatLng? initialCenter; final ValueNotifier isAnimatingNotifier; - final ValueNotifier? dotEntryNotifier; + final ValueNotifier? dotLocationNotifier; final UserZoomChangeCallback? onUserZoomChange; - final VoidCallback? onMapTap; + final void Function(LatLng location)? onMapTap; final MarkerTapCallback? onMarkerTap; final MapOpener? openMapPage; @@ -45,9 +47,9 @@ class GeoMap extends StatefulWidget { this.controller, this.collectionListenable, required this.entries, - this.initialEntry, + this.initialCenter, required this.isAnimatingNotifier, - this.dotEntryNotifier, + this.dotLocationNotifier, this.onUserZoomChange, this.onMapTap, this.onMarkerTap, @@ -59,6 +61,8 @@ class GeoMap extends StatefulWidget { } class _GeoMapState extends State { + final List _subscriptions = []; + // as of google_maps_flutter v2.0.6, Google map initialization is blocking // cf https://github.com/flutter/flutter/issues/28493 // it is especially severe the first time, but still significant afterwards @@ -78,15 +82,7 @@ class _GeoMapState extends State { @override void initState() { super.initState(); - final initialEntry = widget.initialEntry; - final points = (initialEntry != null ? [initialEntry] : entries).map((v) => v.latLng!).toSet(); - final bounds = ZoomedBounds.fromPoints( - points: points.isNotEmpty ? points : {Constants.wonders[Random().nextInt(Constants.wonders.length)]}, - collocationZoom: settings.infoMapZoom, - ); - _boundsNotifier = ValueNotifier(bounds.copyWith( - zoom: max(bounds.zoom, minInitialZoom), - )); + _boundsNotifier = ValueNotifier(_initBounds()); _registerWidget(widget); _onCollectionChanged(); } @@ -106,10 +102,17 @@ class _GeoMapState extends State { void _registerWidget(GeoMap widget) { widget.collectionListenable?.addListener(_onCollectionChanged); + final controller = widget.controller; + if (controller != null) { + _subscriptions.add(controller.markerLocationChanges.listen((event) => _onCollectionChanged())); + } } void _unregisterWidget(GeoMap widget) { widget.collectionListenable?.removeListener(_onCollectionChanged); + _subscriptions + ..forEach((sub) => sub.cancel()) + ..clear(); } @override @@ -119,6 +122,16 @@ class _GeoMapState extends State { if (onTap == null) return; final clusterId = geoEntry.clusterId; + AvesEntry? markerEntry; + if (clusterId != null) { + final uri = geoEntry.childMarkerId; + markerEntry = entries.firstWhereOrNull((v) => v.uri == uri); + } else { + markerEntry = geoEntry.entry; + } + + if (markerEntry == null) return; + Set getClusterEntries() { if (clusterId == null) { return {geoEntry.entry!}; @@ -135,17 +148,8 @@ class _GeoMapState extends State { return points.map((geoEntry) => geoEntry.entry!).toSet(); } - AvesEntry? markerEntry; - if (clusterId != null) { - final uri = geoEntry.childMarkerId; - markerEntry = entries.firstWhereOrNull((v) => v.uri == uri); - } else { - markerEntry = geoEntry.entry; - } - - if (markerEntry != null) { - onTap(markerEntry, getClusterEntries); - } + final clusterAverageLocation = LatLng(geoEntry.latitude!, geoEntry.longitude!); + onTap(clusterAverageLocation, markerEntry, getClusterEntries); } return FutureBuilder( @@ -176,7 +180,7 @@ class _GeoMapState extends State { style: mapStyle, markerClusterBuilder: _buildMarkerClusters, markerWidgetBuilder: _buildMarkerWidget, - dotEntryNotifier: widget.dotEntryNotifier, + dotLocationNotifier: widget.dotLocationNotifier, onUserZoomChange: widget.onUserZoomChange, onMapTap: widget.onMapTap, onMarkerTap: _onMarkerTap, @@ -191,7 +195,7 @@ class _GeoMapState extends State { style: mapStyle, markerClusterBuilder: _buildMarkerClusters, markerWidgetBuilder: _buildMarkerWidget, - dotEntryNotifier: widget.dotEntryNotifier, + dotLocationNotifier: widget.dotLocationNotifier, markerSize: Size( GeoMap.markerImageExtent + ImageMarker.outerBorderWidth * 2, GeoMap.markerImageExtent + ImageMarker.outerBorderWidth * 2 + GeoMap.markerArrowSize.height, @@ -264,6 +268,18 @@ class _GeoMapState extends State { ); } + ZoomedBounds _initBounds() { + final initialCenter = widget.initialCenter; + final points = initialCenter != null ? {initialCenter} : entries.map((v) => v.latLng!).toSet(); + final bounds = ZoomedBounds.fromPoints( + points: points.isNotEmpty ? points : {Constants.wonders[Random().nextInt(Constants.wonders.length)]}, + collocationZoom: settings.infoMapZoom, + ); + return bounds.copyWith( + zoom: max(bounds.zoom, minInitialZoom), + ); + } + void _onCollectionChanged() { _defaultMarkerCluster = _buildFluster(); _slowMarkerCluster = null; @@ -293,6 +309,9 @@ class _GeoMapState extends State { // node size: 64 by default, higher means faster indexing but slower search nodeSize: nodeSize, points: markers, + // use lambda instead of tear-off because of runtime exception when using + // `T Function(BaseCluster, double, double)` for `T Function(BaseCluster?, double?, double?)` + // ignore: unnecessary_lambdas createCluster: (base, lng, lat) => GeoEntry.createCluster(base, lng, lat), ); } @@ -325,4 +344,4 @@ class MarkerKey extends LocalKey with EquatableMixin { typedef MarkerClusterBuilder = Map Function(); typedef MarkerWidgetBuilder = Widget Function(MarkerKey key); typedef UserZoomChangeCallback = void Function(double zoom); -typedef MarkerTapCallback = void Function(AvesEntry markerEntry, Set Function() getClusterEntries); +typedef MarkerTapCallback = void Function(LatLng averageLocation, AvesEntry markerEntry, Set Function() getClusterEntries); diff --git a/lib/widgets/common/map/google/map.dart b/lib/widgets/common/map/google/map.dart index c341be9bc..c00462773 100644 --- a/lib/widgets/common/map/google/map.dart +++ b/lib/widgets/common/map/google/map.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:typed_data'; -import 'package:aves/model/entry.dart'; import 'package:aves/model/entry_images.dart'; import 'package:aves/model/settings/enums.dart'; import 'package:aves/utils/change_notifier.dart'; @@ -27,9 +26,9 @@ class EntryGoogleMap extends StatefulWidget { final EntryMapStyle style; final MarkerClusterBuilder markerClusterBuilder; final MarkerWidgetBuilder markerWidgetBuilder; - final ValueNotifier? dotEntryNotifier; + final ValueNotifier? dotLocationNotifier; final UserZoomChangeCallback? onUserZoomChange; - final VoidCallback? onMapTap; + final void Function(ll.LatLng location)? onMapTap; final void Function(GeoEntry geoEntry)? onMarkerTap; final MapOpener? openMapPage; @@ -43,7 +42,7 @@ class EntryGoogleMap extends StatefulWidget { required this.style, required this.markerClusterBuilder, required this.markerWidgetBuilder, - required this.dotEntryNotifier, + required this.dotLocationNotifier, this.onUserZoomChange, this.onMapTap, this.onMarkerTap, @@ -170,20 +169,22 @@ class _EntryGoogleMapState extends State with WidgetsBindingObse }); final interactive = context.select((v) => v.interactive); - return ValueListenableBuilder( - valueListenable: widget.dotEntryNotifier ?? ValueNotifier(null), - builder: (context, dotEntry, child) { + return ValueListenableBuilder( + valueListenable: widget.dotLocationNotifier ?? ValueNotifier(null), + builder: (context, dotLocation, child) { return GoogleMap( initialCameraPosition: CameraPosition( bearing: -bounds.rotation, - target: _toGoogleLatLng(bounds.center), + target: _toGoogleLatLng(bounds.projectedCenter), zoom: bounds.zoom, ), onMapCreated: (controller) async { _googleMapController = controller; final zoom = await controller.getZoomLevel(); await _updateVisibleRegion(zoom: zoom, rotation: bounds.rotation); - setState(() {}); + if (mounted) { + setState(() {}); + } }, // compass disabled to use provider agnostic controls compassEnabled: false, @@ -203,19 +204,19 @@ class _EntryGoogleMapState extends State with WidgetsBindingObse myLocationButtonEnabled: false, markers: { ...markers, - if (dotEntry != null && _dotMarkerBitmap != null) + if (dotLocation != null && _dotMarkerBitmap != null) Marker( markerId: const MarkerId('dot'), anchor: const Offset(.5, .5), consumeTapEvents: true, icon: BitmapDescriptor.fromBytes(_dotMarkerBitmap!), - position: _toGoogleLatLng(dotEntry.latLng!), + position: _toGoogleLatLng(dotLocation), zIndex: 1, ) }, onCameraMove: (position) => _updateVisibleRegion(zoom: position.zoom, rotation: -position.bearing), onCameraIdle: _onIdle, - onTap: (position) => widget.onMapTap?.call(), + onTap: (position) => widget.onMapTap?.call(_fromGoogleLatLng(position)), ); }, ); @@ -241,8 +242,8 @@ class _EntryGoogleMapState extends State with WidgetsBindingObse final sw = bounds.southwest; final ne = bounds.northeast; boundsNotifier.value = ZoomedBounds( - sw: ll.LatLng(sw.latitude, sw.longitude), - ne: ll.LatLng(ne.latitude, ne.longitude), + sw: _fromGoogleLatLng(sw), + ne: _fromGoogleLatLng(ne), zoom: zoom, rotation: rotation, ); @@ -260,7 +261,7 @@ class _EntryGoogleMapState extends State with WidgetsBindingObse if (controller == null) return; await controller.animateCamera(CameraUpdate.newCameraPosition(CameraPosition( - target: _toGoogleLatLng(bounds.center), + target: _toGoogleLatLng(bounds.projectedCenter), zoom: bounds.zoom, ))); } @@ -281,7 +282,9 @@ class _EntryGoogleMapState extends State with WidgetsBindingObse } // `LatLng` used by `google_maps_flutter` is not the one from `latlong2` package - LatLng _toGoogleLatLng(ll.LatLng latLng) => LatLng(latLng.latitude, latLng.longitude); + LatLng _toGoogleLatLng(ll.LatLng location) => LatLng(location.latitude, location.longitude); + + ll.LatLng _fromGoogleLatLng(LatLng location) => ll.LatLng(location.latitude, location.longitude); MapType _toMapType(EntryMapStyle style) { switch (style) { diff --git a/lib/widgets/common/map/google/marker_generator.dart b/lib/widgets/common/map/google/marker_generator.dart index b296d0575..ded1b979c 100644 --- a/lib/widgets/common/map/google/marker_generator.dart +++ b/lib/widgets/common/map/google/marker_generator.dart @@ -107,9 +107,14 @@ class _MarkerGeneratorItem { state = MarkerGeneratorItemState.rendering; final boundary = _globalKey.currentContext!.findRenderObject() as RenderRepaintBoundary; if (boundary.hasSize && boundary.size != Size.zero) { - final image = await boundary.toImage(pixelRatio: ui.window.devicePixelRatio); - final byteData = await image.toByteData(format: ui.ImageByteFormat.png); - bytes = byteData?.buffer.asUint8List(); + try { + final image = await boundary.toImage(pixelRatio: ui.window.devicePixelRatio); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + bytes = byteData?.buffer.asUint8List(); + } catch (error) { + // happens when widget is offscreen + debugPrint('failed to render image for key=$_globalKey with error=$error'); + } } state = bytes != null ? MarkerGeneratorItemState.done : MarkerGeneratorItemState.waiting; } diff --git a/lib/widgets/common/map/leaflet/map.dart b/lib/widgets/common/map/leaflet/map.dart index cfdcb0eb5..f04a5d464 100644 --- a/lib/widgets/common/map/leaflet/map.dart +++ b/lib/widgets/common/map/leaflet/map.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:aves/model/entry.dart'; import 'package:aves/model/settings/enums.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/durations.dart'; @@ -29,10 +28,10 @@ class EntryLeafletMap extends StatefulWidget { final EntryMapStyle style; final MarkerClusterBuilder markerClusterBuilder; final MarkerWidgetBuilder markerWidgetBuilder; - final ValueNotifier? dotEntryNotifier; + final ValueNotifier? dotLocationNotifier; final Size markerSize, dotMarkerSize; final UserZoomChangeCallback? onUserZoomChange; - final VoidCallback? onMapTap; + final void Function(LatLng location)? onMapTap; final void Function(GeoEntry geoEntry)? onMarkerTap; final MapOpener? openMapPage; @@ -46,7 +45,7 @@ class EntryLeafletMap extends StatefulWidget { required this.style, required this.markerClusterBuilder, required this.markerWidgetBuilder, - required this.dotEntryNotifier, + required this.dotLocationNotifier, required this.markerSize, required this.dotMarkerSize, this.onUserZoomChange, @@ -154,7 +153,7 @@ class _EntryLeafletMapState extends State with TickerProviderSt return FlutterMap( options: MapOptions( - center: bounds.center, + center: bounds.projectedCenter, zoom: bounds.zoom, rotation: bounds.rotation, minZoom: widget.minZoom, @@ -162,7 +161,7 @@ class _EntryLeafletMapState extends State with TickerProviderSt // TODO TLAD [map] as of flutter_map v0.14.0, `doubleTapZoom` does not move when zoom is already maximal // this could be worked around with https://github.com/fleaflet/flutter_map/pull/960 interactiveFlags: interactive ? InteractiveFlag.all : InteractiveFlag.none, - onTap: (tapPosition, point) => widget.onMapTap?.call(), + onTap: (tapPosition, point) => widget.onMapTap?.call(point), controller: _leafletMapController, ), mapController: _leafletMapController, @@ -182,14 +181,14 @@ class _EntryLeafletMapState extends State with TickerProviderSt rotateAlignment: Alignment.bottomCenter, ), ), - ValueListenableBuilder( - valueListenable: widget.dotEntryNotifier ?? ValueNotifier(null), - builder: (context, dotEntry, child) => MarkerLayerWidget( + ValueListenableBuilder( + valueListenable: widget.dotLocationNotifier ?? ValueNotifier(null), + builder: (context, dotLocation, child) => MarkerLayerWidget( options: MarkerLayerOptions( markers: [ - if (dotEntry != null) + if (dotLocation != null) Marker( - point: dotEntry.latLng!, + point: dotLocation, builder: (context) => const DotMarker(), width: dotMarkerSize.width, height: dotMarkerSize.height, diff --git a/lib/widgets/common/map/marker.dart b/lib/widgets/common/map/marker.dart index eca28b008..f8238f888 100644 --- a/lib/widgets/common/map/marker.dart +++ b/lib/widgets/common/map/marker.dart @@ -1,4 +1,5 @@ import 'package:aves/model/entry.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/thumbnail/image.dart'; import 'package:custom_rounded_rectangle_border/custom_rounded_rectangle_border.dart'; import 'package:flutter/material.dart'; @@ -82,18 +83,31 @@ class ImageMarker extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: 0, horizontal: 2), decoration: ShapeDecoration( color: Theme.of(context).colorScheme.secondary, - shape: const CustomRoundedRectangleBorder( - leftSide: borderSide, - rightSide: borderSide, - topSide: borderSide, - bottomSide: borderSide, - topLeftCornerSide: borderSide, - bottomRightCornerSide: borderSide, - borderRadius: BorderRadius.only( - topLeft: innerRadius, - bottomRight: innerRadius, - ), - ), + shape: context.isRtl + ? const CustomRoundedRectangleBorder( + leftSide: borderSide, + rightSide: borderSide, + topSide: borderSide, + bottomSide: borderSide, + topRightCornerSide: borderSide, + bottomLeftCornerSide: borderSide, + borderRadius: BorderRadius.only( + topRight: innerRadius, + bottomLeft: innerRadius, + ), + ) + : const CustomRoundedRectangleBorder( + leftSide: borderSide, + rightSide: borderSide, + topSide: borderSide, + bottomSide: borderSide, + topLeftCornerSide: borderSide, + bottomRightCornerSide: borderSide, + borderRadius: BorderRadius.only( + topLeft: innerRadius, + bottomRight: innerRadius, + ), + ), ), child: Text( '$count', diff --git a/lib/widgets/common/map/zoomed_bounds.dart b/lib/widgets/common/map/zoomed_bounds.dart index db65719c9..b0d375145 100644 --- a/lib/widgets/common/map/zoomed_bounds.dart +++ b/lib/widgets/common/map/zoomed_bounds.dart @@ -3,6 +3,7 @@ import 'dart:math'; import 'package:aves/utils/geo_utils.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; @immutable @@ -13,7 +14,17 @@ class ZoomedBounds extends Equatable { // returns [southwestLng, southwestLat, northeastLng, northeastLat], as expected by Fluster List get boundingBox => [sw.longitude, sw.latitude, ne.longitude, ne.latitude]; - LatLng get center => GeoUtils.getLatLngCenter([sw, ne]); + // Map services (Google Maps, OpenStreetMap) use the spherical Mercator projection (EPSG 3857). + static const _crs = Epsg3857(); + + // The projected center appears visually in the middle of the bounds. + LatLng get projectedCenter { + final swPoint = _crs.latLngToPoint(sw, zoom); + final nePoint = _crs.latLngToPoint(ne, zoom); + // assume no padding around bounds + final projectedCenter = _crs.pointToLatLng((swPoint + nePoint) / 2, zoom); + return projectedCenter ?? GeoUtils.getLatLngCenter([sw, ne]); + } @override List get props => [sw, ne, zoom, rotation]; diff --git a/lib/widgets/common/thumbnail/decorated.dart b/lib/widgets/common/thumbnail/decorated.dart index ebb815eea..bcf87ee42 100644 --- a/lib/widgets/common/thumbnail/decorated.dart +++ b/lib/widgets/common/thumbnail/decorated.dart @@ -13,7 +13,7 @@ class DecoratedThumbnail extends StatelessWidget { final Object? Function()? heroTagger; static final Color borderColor = Colors.grey.shade700; - static final double borderWidth = AvesBorder.borderWidth; + static final double borderWidth = AvesBorder.straightBorderWidth; const DecoratedThumbnail({ Key? key, @@ -27,12 +27,10 @@ class DecoratedThumbnail extends StatelessWidget { @override Widget build(BuildContext context) { - final imageExtent = tileExtent - borderWidth * 2; - final isSvg = entry.isSvg; Widget child = ThumbnailImage( entry: entry, - extent: imageExtent, + extent: tileExtent, cancellableNotifier: cancellableNotifier, heroTag: heroTagger?.call(), ); @@ -42,13 +40,19 @@ class DecoratedThumbnail extends StatelessWidget { children: [ child, if (!isSvg) ThumbnailEntryOverlay(entry: entry), - if (selectable) GridItemSelectionOverlay(item: entry), + if (selectable) + GridItemSelectionOverlay( + item: entry, + padding: const EdgeInsets.all(2), + ), if (highlightable) ThumbnailHighlightOverlay(entry: entry), ], ); return Container( - decoration: BoxDecoration( + // `decoration` with sub logical pixel width yields scintillating borders + // so we use `foregroundDecoration` instead + foregroundDecoration: BoxDecoration( border: Border.fromBorderSide(BorderSide( color: borderColor, width: borderWidth, diff --git a/lib/widgets/common/thumbnail/overlay.dart b/lib/widgets/common/thumbnail/overlay.dart index 388604013..28b8251b5 100644 --- a/lib/widgets/common/thumbnail/overlay.dart +++ b/lib/widgets/common/thumbnail/overlay.dart @@ -28,7 +28,7 @@ class ThumbnailEntryOverlay extends StatelessWidget { const AnimatedImageIcon() else ...[ if (entry.isRaw && context.select((t) => t.showRaw)) const RawIcon(), - if (entry.isGeotiff) const GeotiffIcon(), + if (entry.isGeotiff) const GeoTiffIcon(), if (entry.is360) const SphericalImageIcon(), ], if (entry.isMultiPage) ...[ @@ -36,7 +36,7 @@ class ThumbnailEntryOverlay extends StatelessWidget { if (!entry.isMotionPhoto) MultiPageIcon(entry: entry), ], ]; - if (children.isEmpty) return const SizedBox.shrink(); + if (children.isEmpty) return const SizedBox(); if (children.length == 1) return children.first; return Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/widgets/debug/android_env.dart b/lib/widgets/debug/android_env.dart deleted file mode 100644 index 338e9c7a8..000000000 --- a/lib/widgets/debug/android_env.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'dart:collection'; - -import 'package:aves/services/android_debug_service.dart'; -import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; -import 'package:aves/widgets/viewer/info/common.dart'; -import 'package:flutter/material.dart'; - -class DebugAndroidEnvironmentSection extends StatefulWidget { - const DebugAndroidEnvironmentSection({Key? key}) : super(key: key); - - @override - _DebugAndroidEnvironmentSectionState createState() => _DebugAndroidEnvironmentSectionState(); -} - -class _DebugAndroidEnvironmentSectionState extends State with AutomaticKeepAliveClientMixin { - late Future _loader; - - @override - void initState() { - super.initState(); - _loader = AndroidDebugService.getEnv(); - } - - @override - Widget build(BuildContext context) { - super.build(context); - - return AvesExpansionTile( - title: 'Android Environment', - children: [ - Padding( - padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8), - child: FutureBuilder( - future: _loader, - builder: (context, snapshot) { - if (snapshot.hasError) return Text(snapshot.error.toString()); - if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); - final data = SplayTreeMap.of(snapshot.data!.map((k, v) => MapEntry(k.toString(), v?.toString() ?? 'null'))); - return InfoRowGroup(info: data); - }, - ), - ), - ], - ); - } - - @override - bool get wantKeepAlive => true; -} diff --git a/lib/widgets/debug/app_debug_action.dart b/lib/widgets/debug/app_debug_action.dart new file mode 100644 index 000000000..53583901d --- /dev/null +++ b/lib/widgets/debug/app_debug_action.dart @@ -0,0 +1,6 @@ +enum AppDebugAction { + prepScreenshotThumbnails, + prepScreenshotStats, + mediaStoreScanDir, + greenScreen, +} diff --git a/lib/widgets/debug/app_debug_page.dart b/lib/widgets/debug/app_debug_page.dart index 1b4a3ec08..3ad653c3f 100644 --- a/lib/widgets/debug/app_debug_page.dart +++ b/lib/widgets/debug/app_debug_page.dart @@ -1,14 +1,23 @@ +import 'dart:async'; + import 'package:aves/model/entry.dart'; +import 'package:aves/model/favourites.dart'; +import 'package:aves/model/filters/path.dart'; +import 'package:aves/model/filters/tag.dart'; +import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/services/analysis_service.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/widgets/common/basic/menu.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/debug/android_apps.dart'; import 'package:aves/widgets/debug/android_codecs.dart'; import 'package:aves/widgets/debug/android_dirs.dart'; -import 'package:aves/widgets/debug/android_env.dart'; +import 'package:aves/widgets/debug/app_debug_action.dart'; import 'package:aves/widgets/debug/cache.dart'; import 'package:aves/widgets/debug/database.dart'; +import 'package:aves/widgets/debug/media_store_scan_dialog.dart'; import 'package:aves/widgets/debug/overlay.dart'; import 'package:aves/widgets/debug/report.dart'; import 'package:aves/widgets/debug/settings.dart'; @@ -37,26 +46,49 @@ class _AppDebugPageState extends State { @override Widget build(BuildContext context) { return MediaQueryDataProvider( - child: Scaffold( - appBar: AppBar( - title: const Text('Debug'), - ), - body: SafeArea( - child: ListView( - padding: const EdgeInsets.all(8), - children: [ - _buildGeneralTabView(), - const DebugAndroidAppSection(), - const DebugAndroidCodecSection(), - const DebugAndroidDirSection(), - const DebugAndroidEnvironmentSection(), - const DebugCacheSection(), - const DebugAppDatabaseSection(), - const DebugErrorReportingSection(), - const DebugSettingsSection(), - const DebugStorageSection(), + child: Directionality( + textDirection: TextDirection.ltr, + child: Scaffold( + appBar: AppBar( + title: const Text('Debug'), + actions: [ + MenuIconTheme( + child: PopupMenuButton( + // key is expected by test driver + key: const Key('appbar-menu-button'), + itemBuilder: (context) => AppDebugAction.values + .map((v) => PopupMenuItem( + // key is expected by test driver + key: Key('menu-${v.name}'), + value: v, + child: MenuRow(text: v.name), + )) + .toList(), + onSelected: (action) async { + // wait for the popup menu to hide before proceeding with the action + await Future.delayed(Durations.popupMenuAnimation * timeDilation); + unawaited(_onActionSelected(action)); + }, + ), + ), ], ), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(8), + children: [ + _buildGeneralTabView(), + const DebugAndroidAppSection(), + const DebugAndroidCodecSection(), + const DebugAndroidDirSection(), + const DebugCacheSection(), + const DebugAppDatabaseSection(), + const DebugErrorReportingSection(), + const DebugSettingsSection(), + const DebugStorageSection(), + ], + ), + ), ), ), ); @@ -127,4 +159,45 @@ class _AppDebugPageState extends State { ], ); } + + Future _onActionSelected(AppDebugAction action) async { + switch (action) { + case AppDebugAction.prepScreenshotThumbnails: + final source = context.read(); + source.changeFilterVisibility(settings.hiddenFilters, true); + source.changeFilterVisibility({ + TagFilter('aves-thumbnail', not: true), + }, false); + await favourites.clear(); + await favourites.add(source.visibleEntries); + break; + case AppDebugAction.prepScreenshotStats: + final source = context.read(); + source.changeFilterVisibility(settings.hiddenFilters, true); + source.changeFilterVisibility({ + PathFilter('/storage/emulated/0/Pictures/Dev'), + }, false); + break; + case AppDebugAction.mediaStoreScanDir: + // scan files copied from test assets + // we do it via the app instead of broadcasting via ADB + // because `MEDIA_SCANNER_SCAN_FILE` intent got deprecated in API 29 + await showDialog( + context: context, + builder: (context) => const MediaStoreScanDirDialog(), + ); + break; + case AppDebugAction.greenScreen: + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const Scaffold( + backgroundColor: Colors.green, + body: SizedBox(), + ), + ), + ); + break; + } + } } diff --git a/lib/widgets/debug/media_store_scan_dialog.dart b/lib/widgets/debug/media_store_scan_dialog.dart new file mode 100644 index 000000000..f211c652e --- /dev/null +++ b/lib/widgets/debug/media_store_scan_dialog.dart @@ -0,0 +1,51 @@ +import 'dart:io'; + +import 'package:aves/ref/mime_types.dart'; +import 'package:aves/services/common/services.dart'; +import 'package:aves/widgets/dialogs/aves_dialog.dart'; +import 'package:flutter/material.dart'; + +class MediaStoreScanDirDialog extends StatefulWidget { + const MediaStoreScanDirDialog({Key? key}) : super(key: key); + + @override + _MediaStoreScanDirDialogState createState() => _MediaStoreScanDirDialogState(); +} + +class _MediaStoreScanDirDialogState extends State { + final TextEditingController _pathController = TextEditingController(); + bool _processing = false; + + @override + void dispose() { + _pathController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AvesDialog( + content: _processing ? const CircularProgressIndicator() : TextField(controller: _pathController), + actions: [ + TextButton( + onPressed: _processing + ? null + : () async { + final dir = _pathController.text; + if (dir.isNotEmpty) { + setState(() => _processing = true); + await Future.forEach(Directory(dir).listSync(recursive: true), (file) async { + if (file is File) { + final mimeType = MimeTypes.forExtension(pContext.extension(file.path)); + await mediaStoreService.scanFile(file.path, mimeType!); + } + }); + } + Navigator.pop(context); + }, + child: const Text('Scan'), + ) + ], + ); + } +} diff --git a/lib/widgets/debug/settings.dart b/lib/widgets/debug/settings.dart index b3461bea8..de9cf5806 100644 --- a/lib/widgets/debug/settings.dart +++ b/lib/widgets/debug/settings.dart @@ -66,7 +66,6 @@ class DebugSettingsSection extends StatelessWidget { 'pinnedFilters': toMultiline(settings.pinnedFilters), 'hiddenFilters': toMultiline(settings.hiddenFilters), 'searchHistory': toMultiline(settings.searchHistory), - 'lastVersionCheckDate': '${settings.lastVersionCheckDate}', 'locale': '${settings.locale}', 'systemLocales': '${WidgetsBinding.instance!.window.locales}', }, diff --git a/lib/widgets/dialogs/aves_selection_dialog.dart b/lib/widgets/dialogs/aves_selection_dialog.dart index ff5295e3f..e6f70dda0 100644 --- a/lib/widgets/dialogs/aves_selection_dialog.dart +++ b/lib/widgets/dialogs/aves_selection_dialog.dart @@ -57,7 +57,7 @@ class _AvesSelectionDialogState extends State> { if (needConfirmation) TextButton( onPressed: () => Navigator.pop(context, _selectedValue), - child: Text(confirmationButtonLabel!), + child: Text(confirmationButtonLabel), ), ], ); diff --git a/lib/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart b/lib/widgets/dialogs/entry_editors/edit_date_dialog.dart similarity index 96% rename from lib/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart rename to lib/widgets/dialogs/entry_editors/edit_date_dialog.dart index 306b2f572..95145b337 100644 --- a/lib/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/edit_date_dialog.dart @@ -1,6 +1,7 @@ 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/theme/durations.dart'; import 'package:aves/theme/format.dart'; import 'package:aves/theme/icons.dart'; @@ -95,7 +96,7 @@ class _EditEntryDateDialogState extends State { if (_action == DateEditAction.setCustom) _buildSetCustomContent(context), if (_action == DateEditAction.copyField) _buildCopyFieldContent(context), if (_action == DateEditAction.shift) _buildShiftContent(context), - (_action == DateEditAction.shift || _action == DateEditAction.remove)? _buildDestinationFields(context): const SizedBox(height: 8), + (_action == DateEditAction.shift || _action == DateEditAction.remove) ? _buildDestinationFields(context) : const SizedBox(height: 8), ], ), ), @@ -131,7 +132,7 @@ class _EditEntryDateDialogState extends State { final use24hour = context.select((v) => v.alwaysUse24HourFormat); return Padding( - padding: const EdgeInsets.only(left: 16, right: 8), + padding: const EdgeInsetsDirectional.only(start: 16, end: 8), child: Row( children: [ Expanded(child: Text(formatDateTime(_setDateTime, locale, use24hour))), @@ -177,6 +178,8 @@ class _EditEntryDateDialogState extends State { const textStyle = TextStyle(fontSize: 34); return Center( child: Table( + // even when ambient direction is RTL, time is displayed in LTR + textDirection: TextDirection.ltr, children: [ TableRow( children: [ @@ -302,10 +305,12 @@ class _EditEntryDateDialogState extends State { return 'Exif original date'; case MetadataField.exifDateDigitized: return 'Exif digitized date'; - case MetadataField.exifGpsDate: + case MetadataField.exifGpsDatestamp: return 'Exif GPS date'; case MetadataField.xmpCreateDate: return 'XMP xmp:CreateDate'; + default: + return field.name; } } @@ -314,7 +319,7 @@ class _EditEntryDateDialogState extends State { context: context, initialDate: _setDateTime, firstDate: DateTime(0), - lastDate: DateTime.now(), + lastDate: DateTime(2100), confirmText: context.l10n.nextButtonLabel, ); if (_date == null) return; diff --git a/lib/widgets/dialogs/entry_editors/edit_location_dialog.dart b/lib/widgets/dialogs/entry_editors/edit_location_dialog.dart new file mode 100644 index 000000000..3f3e67c95 --- /dev/null +++ b/lib/widgets/dialogs/entry_editors/edit_location_dialog.dart @@ -0,0 +1,230 @@ +import 'package:aves/model/entry.dart'; +import 'package:aves/model/source/collection_lens.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/utils/constants.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:aves/widgets/dialogs/aves_dialog.dart'; +import 'package:aves/widgets/dialogs/location_pick_dialog.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:latlong2/latlong.dart'; + +class EditEntryLocationDialog extends StatefulWidget { + final AvesEntry entry; + final CollectionLens? collection; + + const EditEntryLocationDialog({ + Key? key, + required this.entry, + this.collection, + }) : super(key: key); + + @override + _EditEntryLocationDialogState createState() => _EditEntryLocationDialogState(); +} + +class _EditEntryLocationDialogState extends State { + _LocationAction _action = _LocationAction.set; + final TextEditingController _latitudeController = TextEditingController(), _longitudeController = TextEditingController(); + final FocusNode _latitudeFocusNode = FocusNode(), _longitudeFocusNode = FocusNode(); + final ValueNotifier _isValidNotifier = ValueNotifier(false); + + NumberFormat get coordinateFormatter => NumberFormat('0.000000', context.l10n.localeName); + + @override + void initState() { + super.initState(); + _latitudeFocusNode.addListener(_onLatLngFocusChange); + _longitudeFocusNode.addListener(_onLatLngFocusChange); + WidgetsBinding.instance!.addPostFrameCallback((_) => _setLocation(context, widget.entry.latLng)); + } + + @override + void dispose() { + _latitudeFocusNode.removeListener(_onLatLngFocusChange); + _longitudeFocusNode.removeListener(_onLatLngFocusChange); + _latitudeController.dispose(); + _longitudeController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MediaQueryDataProvider( + child: TooltipTheme( + data: TooltipTheme.of(context).copyWith( + preferBelow: false, + ), + child: Builder(builder: (context) { + final l10n = context.l10n; + + return AvesDialog( + title: l10n.editEntryLocationDialogTitle, + scrollableContent: [ + RadioListTile<_LocationAction>( + value: _LocationAction.set, + groupValue: _action, + onChanged: (v) => setState(() { + _action = v!; + _validate(); + }), + title: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + children: [ + TextField( + controller: _latitudeController, + focusNode: _latitudeFocusNode, + decoration: InputDecoration( + labelText: context.l10n.editEntryLocationDialogLatitude, + hintText: coordinateFormatter.format(Constants.pointNemo.latitude), + ), + onChanged: (_) => _validate(), + ), + TextField( + controller: _longitudeController, + focusNode: _longitudeFocusNode, + decoration: InputDecoration( + labelText: context.l10n.editEntryLocationDialogLongitude, + hintText: coordinateFormatter.format(Constants.pointNemo.longitude), + ), + onChanged: (_) => _validate(), + ), + ], + ), + ), + const SizedBox(width: 8), + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: IconButton( + icon: const Icon(AIcons.map), + onPressed: _pickLocation, + tooltip: l10n.editEntryLocationDialogChooseOnMapTooltip, + ), + ), + ], + ), + contentPadding: const EdgeInsetsDirectional.only(start: 16, end: 8), + ), + RadioListTile<_LocationAction>( + value: _LocationAction.remove, + groupValue: _action, + onChanged: (v) => setState(() { + _action = v!; + _latitudeFocusNode.unfocus(); + _longitudeFocusNode.unfocus(); + _validate(); + }), + title: Text(l10n.actionRemove), + ), + ], + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), + ), + ValueListenableBuilder( + valueListenable: _isValidNotifier, + builder: (context, isValid, child) { + return TextButton( + onPressed: isValid ? () => _submit(context) : null, + child: Text(context.l10n.applyButtonLabel), + ); + }, + ), + ], + ); + }), + ), + ); + } + + void _onLatLngFocusChange() { + if (_latitudeFocusNode.hasFocus || _longitudeFocusNode.hasFocus) { + setState(() { + _action = _LocationAction.set; + _validate(); + }); + } + } + + void _setLocation(BuildContext context, LatLng? latLng) { + _latitudeController.text = latLng != null ? coordinateFormatter.format(latLng.latitude) : ''; + _longitudeController.text = latLng != null ? coordinateFormatter.format(latLng.longitude) : ''; + setState(() { + _action = _LocationAction.set; + _validate(); + }); + } + + Future _pickLocation() async { + final latLng = await Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: LocationPickDialog.routeName), + builder: (context) { + final baseCollection = widget.collection; + final mapCollection = baseCollection != null + ? CollectionLens( + source: baseCollection.source, + filters: baseCollection.filters, + fixedSelection: baseCollection.sortedEntries.where((entry) => entry.hasGps).toList(), + ) + : null; + return LocationPickDialog( + collection: mapCollection, + initialLocation: _parseLatLng(), + ); + }, + fullscreenDialog: true, + ), + ); + if (latLng != null) { + _setLocation(context, latLng); + } + } + + LatLng? _parseLatLng() { + double? tryParse(String text) { + try { + return double.tryParse(text) ?? (coordinateFormatter.parse(text).toDouble()); + } catch (e) { + // ignore + return null; + } + } + + final lat = tryParse(_latitudeController.text); + final lng = tryParse(_longitudeController.text); + if (lat == null || lng == null) return null; + if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return null; + return LatLng(lat, lng); + } + + Future _validate() async { + switch (_action) { + case _LocationAction.set: + _isValidNotifier.value = _parseLatLng() != null; + break; + case _LocationAction.remove: + _isValidNotifier.value = true; + break; + } + } + + void _submit(BuildContext context) { + switch (_action) { + case _LocationAction.set: + Navigator.pop(context, _parseLatLng()); + break; + case _LocationAction.remove: + Navigator.pop(context, LatLng(0, 0)); + break; + } + } +} + +enum _LocationAction { set, remove } diff --git a/lib/widgets/dialogs/entry_editors/edit_entry_rating_dialog.dart b/lib/widgets/dialogs/entry_editors/edit_rating_dialog.dart similarity index 100% rename from lib/widgets/dialogs/entry_editors/edit_entry_rating_dialog.dart rename to lib/widgets/dialogs/entry_editors/edit_rating_dialog.dart diff --git a/lib/widgets/dialogs/entry_editors/edit_entry_tags_dialog.dart b/lib/widgets/dialogs/entry_editors/edit_tags_dialog.dart similarity index 99% rename from lib/widgets/dialogs/entry_editors/edit_entry_tags_dialog.dart rename to lib/widgets/dialogs/entry_editors/edit_tags_dialog.dart index d3f7ea85e..49dbe5af8 100644 --- a/lib/widgets/dialogs/entry_editors/edit_entry_tags_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/edit_tags_dialog.dart @@ -77,8 +77,8 @@ class _TagEditorPageState extends State { builder: (context, value, child) { final upQuery = value.text.trim().toUpperCase(); bool containQuery(String s) => s.toUpperCase().contains(upQuery); - final recentFilters = _recentTags.where(containQuery).map((v) => TagFilter(v)).toList(); - final topTagFilters = _topTags.where(containQuery).map((v) => TagFilter(v)).toList(); + final recentFilters = _recentTags.where(containQuery).map(TagFilter.new).toList(); + final topTagFilters = _topTags.where(containQuery).map(TagFilter.new).toList(); return ListView( children: [ Padding( diff --git a/lib/widgets/dialogs/entry_editors/remove_entry_metadata_dialog.dart b/lib/widgets/dialogs/entry_editors/remove_metadata_dialog.dart similarity index 100% rename from lib/widgets/dialogs/entry_editors/remove_entry_metadata_dialog.dart rename to lib/widgets/dialogs/entry_editors/remove_metadata_dialog.dart diff --git a/lib/widgets/dialogs/entry_editors/rename_entry_dialog.dart b/lib/widgets/dialogs/entry_editors/rename_dialog.dart similarity index 85% rename from lib/widgets/dialogs/entry_editors/rename_entry_dialog.dart rename to lib/widgets/dialogs/entry_editors/rename_dialog.dart index 2213bf598..f50b32e13 100644 --- a/lib/widgets/dialogs/entry_editors/rename_entry_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/rename_dialog.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:aves/model/entry.dart'; import 'package:aves/services/common/services.dart'; +import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/material.dart'; @@ -40,12 +41,17 @@ class _RenameEntryDialogState extends State { @override Widget build(BuildContext context) { + final isRtl = context.isRtl; + final extensionSuffixText = '${Constants.fsi}${entry.extension}${Constants.pdi}'; return AvesDialog( content: TextField( controller: _nameController, decoration: InputDecoration( labelText: context.l10n.renameEntryDialogLabel, - suffixText: entry.extension, + // decoration prefix and suffix follow directionality + // but the file extension should always be on the right + prefixText: isRtl ? extensionSuffixText : null, + suffixText: isRtl ? null : extensionSuffixText, ), autofocus: true, onChanged: (_) => _validate(), @@ -64,7 +70,7 @@ class _RenameEntryDialogState extends State { child: Text(context.l10n.applyButtonLabel), ); }, - ) + ), ], ); } diff --git a/lib/widgets/dialogs/export_entry_dialog.dart b/lib/widgets/dialogs/export_entry_dialog.dart index 9e528f0fe..1fc67ee6e 100644 --- a/lib/widgets/dialogs/export_entry_dialog.dart +++ b/lib/widgets/dialogs/export_entry_dialog.dart @@ -1,5 +1,6 @@ import 'package:aves/model/entry.dart'; import 'package:aves/ref/mime_types.dart'; +import 'package:aves/services/media/media_file_service.dart'; import 'package:aves/utils/mime_utils.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/material.dart'; @@ -19,6 +20,8 @@ class ExportEntryDialog extends StatefulWidget { } class _ExportEntryDialogState extends State { + final TextEditingController _widthController = TextEditingController(), _heightController = TextEditingController(); + final ValueNotifier _isValidNotifier = ValueNotifier(false); String _mimeType = MimeTypes.jpeg; AvesEntry get entry => widget.entry; @@ -30,27 +33,80 @@ class _ExportEntryDialogState extends State { MimeTypes.webp, ]; + @override + void initState() { + super.initState(); + _widthController.text = '${entry.isRotated ? entry.height : entry.width}'; + _heightController.text = '${entry.isRotated ? entry.width : entry.height}'; + _validate(); + } + + @override + void dispose() { + _widthController.dispose(); + _heightController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { + final l10n = context.l10n; return AvesDialog( - content: Row( + content: Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(context.l10n.exportEntryDialogFormat), - const SizedBox(width: AvesDialog.controlCaptionPadding), - DropdownButton( - items: imageExportFormats.map((mimeType) { - return DropdownMenuItem( - value: mimeType, - child: Text(MimeUtils.displayType(mimeType)), - ); - }).toList(), - value: _mimeType, - onChanged: (selected) { - if (selected != null) { - setState(() => _mimeType = selected); - } - }, + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(l10n.exportEntryDialogFormat), + const SizedBox(width: AvesDialog.controlCaptionPadding), + DropdownButton( + items: imageExportFormats.map((mimeType) { + return DropdownMenuItem( + value: mimeType, + child: Text(MimeUtils.displayType(mimeType)), + ); + }).toList(), + value: _mimeType, + onChanged: (selected) { + if (selected != null) { + setState(() => _mimeType = selected); + } + }, + ), + ], + ), + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Expanded( + child: TextField( + controller: _widthController, + decoration: InputDecoration(labelText: l10n.exportEntryDialogWidth), + keyboardType: TextInputType.number, + onChanged: (value) { + final width = int.tryParse(value); + _heightController.text = width != null ? '${(width / entry.displayAspectRatio).round()}' : ''; + _validate(); + }, + ), + ), + const Text(AvesEntry.resolutionSeparator), + Expanded( + child: TextField( + controller: _heightController, + decoration: InputDecoration(labelText: l10n.exportEntryDialogHeight), + keyboardType: TextInputType.number, + onChanged: (value) { + final height = int.tryParse(value); + _widthController.text = height != null ? '${(height * entry.displayAspectRatio).round()}' : ''; + _validate(); + }, + ), + ), + ], ), ], ), @@ -59,11 +115,35 @@ class _ExportEntryDialogState extends State { onPressed: () => Navigator.pop(context), child: Text(MaterialLocalizations.of(context).cancelButtonLabel), ), - TextButton( - onPressed: () => Navigator.pop(context, _mimeType), - child: Text(context.l10n.applyButtonLabel), - ) + ValueListenableBuilder( + valueListenable: _isValidNotifier, + builder: (context, isValid, child) { + return TextButton( + onPressed: isValid + ? () { + final width = int.tryParse(_widthController.text); + final height = int.tryParse(_heightController.text); + final options = (width != null && height != null) + ? EntryExportOptions( + mimeType: _mimeType, + width: width, + height: height, + ) + : null; + Navigator.pop(context, options); + } + : null, + child: Text(l10n.applyButtonLabel), + ); + }, + ), ], ); } + + Future _validate() async { + final width = int.tryParse(_widthController.text); + final height = int.tryParse(_heightController.text); + _isValidNotifier.value = (width ?? 0) > 0 && (height ?? 0) > 0; + } } diff --git a/lib/widgets/dialogs/filter_editors/create_album_dialog.dart b/lib/widgets/dialogs/filter_editors/create_album_dialog.dart index 0c8305314..dba5b396e 100644 --- a/lib/widgets/dialogs/filter_editors/create_album_dialog.dart +++ b/lib/widgets/dialogs/filter_editors/create_album_dialog.dart @@ -23,13 +23,13 @@ class _CreateAlbumDialogState extends State { final ValueNotifier _existsNotifier = ValueNotifier(false); final ValueNotifier _isValidNotifier = ValueNotifier(false); late Set _allVolumes; - late StorageVolume _primaryVolume, _selectedVolume; + late StorageVolume? _primaryVolume, _selectedVolume; @override void initState() { super.initState(); _allVolumes = androidFileUtils.storageVolumes; - _primaryVolume = _allVolumes.firstWhere((volume) => volume.isPrimary, orElse: () => _allVolumes.first); + _primaryVolume = _allVolumes.firstWhereOrNull((volume) => volume.isPrimary) ?? _allVolumes.firstOrNull; _selectedVolume = _primaryVolume; _nameFieldFocusNode.addListener(_onFocus); } @@ -144,8 +144,9 @@ class _CreateAlbumDialogState extends State { } String _buildAlbumPath(String name) { - if (name.isEmpty) return ''; - return pContext.join(_selectedVolume.path, 'Pictures', name); + final selectedVolume = _selectedVolume; + if (selectedVolume == null || name.isEmpty) return ''; + return pContext.join(selectedVolume.path, 'Pictures', name); } Future _validate() async { diff --git a/lib/widgets/dialogs/item_pick_dialog.dart b/lib/widgets/dialogs/item_pick_dialog.dart index d12c7a1df..c72252e22 100644 --- a/lib/widgets/dialogs/item_pick_dialog.dart +++ b/lib/widgets/dialogs/item_pick_dialog.dart @@ -8,9 +8,9 @@ import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/providers/query_provider.dart'; import 'package:aves/widgets/common/providers/selection_provider.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:collection/collection.dart'; class ItemPickDialog extends StatefulWidget { static const routeName = '/item_pick'; diff --git a/lib/widgets/dialogs/location_pick_dialog.dart b/lib/widgets/dialogs/location_pick_dialog.dart new file mode 100644 index 000000000..ecdd98f5d --- /dev/null +++ b/lib/widgets/dialogs/location_pick_dialog.dart @@ -0,0 +1,335 @@ +import 'dart:async'; + +import 'package:aves/model/settings/coordinate_format.dart'; +import 'package:aves/model/settings/map_style.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/source/collection_lens.dart'; +import 'package:aves/services/common/services.dart'; +import 'package:aves/services/geocoding_service.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/utils/constants.dart'; +import 'package:aves/utils/debouncer.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/identity/buttons.dart'; +import 'package:aves/widgets/common/map/controller.dart'; +import 'package:aves/widgets/common/map/geo_map.dart'; +import 'package:aves/widgets/common/map/marker.dart'; +import 'package:aves/widgets/common/map/theme.dart'; +import 'package:aves/widgets/common/map/zoomed_bounds.dart'; +import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:decorated_icon/decorated_icon.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:provider/provider.dart'; + +class LocationPickDialog extends StatelessWidget { + static const routeName = '/location_pick'; + + final CollectionLens? collection; + final LatLng? initialLocation; + + const LocationPickDialog({ + Key? key, + required this.collection, + required this.initialLocation, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return MediaQueryDataProvider( + child: Scaffold( + body: SafeArea( + left: false, + top: false, + right: false, + bottom: true, + child: _Content( + collection: collection, + initialLocation: initialLocation, + ), + ), + ), + ); + } +} + +class _Content extends StatefulWidget { + final CollectionLens? collection; + final LatLng? initialLocation; + + const _Content({ + Key? key, + required this.collection, + required this.initialLocation, + }) : super(key: key); + + @override + _ContentState createState() => _ContentState(); +} + +class _ContentState extends State<_Content> with SingleTickerProviderStateMixin { + final List _subscriptions = []; + final AvesMapController _mapController = AvesMapController(); + late final ValueNotifier _isPageAnimatingNotifier; + final ValueNotifier _dotLocationNotifier = ValueNotifier(null), _infoLocationNotifier = ValueNotifier(null); + final Debouncer _infoDebouncer = Debouncer(delay: Durations.mapInfoDebounceDelay); + + CollectionLens? get openingCollection => widget.collection; + + @override + void initState() { + super.initState(); + + if (settings.infoMapStyle.isGoogleMaps) { + _isPageAnimatingNotifier = ValueNotifier(true); + Future.delayed(Durations.pageTransitionAnimation * timeDilation).then((_) { + if (!mounted) return; + _isPageAnimatingNotifier.value = false; + }); + } else { + _isPageAnimatingNotifier = ValueNotifier(false); + } + + _dotLocationNotifier.addListener(_updateLocationInfo); + + _subscriptions.add(_mapController.idleUpdates.listen((event) => _onIdle(event.bounds))); + } + + @override + void dispose() { + _subscriptions + ..forEach((sub) => sub.cancel()) + ..clear(); + _dotLocationNotifier.removeListener(_updateLocationInfo); + _mapController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Expanded(child: _buildMap()), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + const Divider(height: 0), + SafeArea( + top: false, + bottom: false, + child: _LocationInfo(locationNotifier: _infoLocationNotifier), + ), + const SizedBox(height: 8), + AvesOutlinedButton( + label: context.l10n.locationPickerUseThisLocationButton, + onPressed: () => Navigator.pop(context, _dotLocationNotifier.value), + ), + ], + ), + ], + ); + } + + Widget _buildMap() { + return MapTheme( + interactive: true, + showCoordinateFilter: false, + navigationButton: MapNavigationButton.back, + child: GeoMap( + controller: _mapController, + collectionListenable: openingCollection, + entries: openingCollection?.sortedEntries ?? [], + initialCenter: widget.initialLocation, + isAnimatingNotifier: _isPageAnimatingNotifier, + dotLocationNotifier: _dotLocationNotifier, + onMapTap: _setLocation, + onMarkerTap: (averageLocation, markerEntry, getClusterEntries) { + _setLocation(averageLocation); + }, + ), + ); + } + + void _setLocation(LatLng location) { + _dotLocationNotifier.value = location; + _mapController.moveTo(location); + } + + void _onIdle(ZoomedBounds bounds) { + _dotLocationNotifier.value = bounds.projectedCenter; + } + + void _updateLocationInfo() { + final selectedLocation = _dotLocationNotifier.value; + if (_infoLocationNotifier.value == null || selectedLocation == null) { + _infoLocationNotifier.value = selectedLocation; + } else { + _infoDebouncer(() => _infoLocationNotifier.value = selectedLocation); + } + } +} + +class _LocationInfo extends StatelessWidget { + final ValueNotifier locationNotifier; + + static const double iconPadding = 8.0; + static const double iconSize = 16.0; + static const double _interRowPadding = 2.0; + + const _LocationInfo({ + Key? key, + required this.locationNotifier, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final orientation = context.select((v) => v.orientation); + + return ValueListenableBuilder( + valueListenable: locationNotifier, + builder: (context, location, child) { + final content = orientation == Orientation.portrait + ? [ + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _AddressRow(location: location), + const SizedBox(height: _interRowPadding), + _CoordinateRow(location: location), + ], + ), + ), + ] + : [ + _CoordinateRow(location: location), + Expanded( + child: _AddressRow(location: location), + ), + ]; + + return Opacity( + opacity: location != null ? 1 : 0, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: iconPadding), + const DotMarker(), + ...content, + ], + ), + ); + }, + ); + } +} + +class _AddressRow extends StatefulWidget { + final LatLng? location; + + const _AddressRow({ + Key? key, + required this.location, + }) : super(key: key); + + @override + _AddressRowState createState() => _AddressRowState(); +} + +class _AddressRowState extends State<_AddressRow> { + final ValueNotifier _addressLineNotifier = ValueNotifier(null); + + @override + void initState() { + super.initState(); + _updateAddress(); + } + + @override + void didUpdateWidget(covariant _AddressRow oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.location != widget.location) { + _updateAddress(); + } + } + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: _LocationInfo.iconPadding), + const DecoratedIcon(AIcons.location, size: _LocationInfo.iconSize), + const SizedBox(width: _LocationInfo.iconPadding), + Expanded( + child: Container( + alignment: AlignmentDirectional.centerStart, + // addresses can include non-latin scripts with inconsistent line height, + // which is especially an issue for relayout/painting of heavy Google map, + // so we give extra height to give breathing room to the text and stabilize layout + height: Theme.of(context).textTheme.bodyText2!.fontSize! * context.select((mq) => mq.textScaleFactor) * 2, + child: ValueListenableBuilder( + valueListenable: _addressLineNotifier, + builder: (context, addressLine, child) { + return Text( + addressLine ?? Constants.overlayUnknown, + strutStyle: Constants.overflowStrutStyle, + softWrap: false, + overflow: TextOverflow.fade, + maxLines: 1, + ); + }, + ), + ), + ), + ], + ); + } + + Future _updateAddress() async { + final location = widget.location; + final addressLine = await _getAddressLine(location); + if (mounted && location == widget.location) { + _addressLineNotifier.value = addressLine; + } + } + + Future _getAddressLine(LatLng? location) async { + if (location != null && await availability.canLocatePlaces) { + final addresses = await GeocodingService.getAddress(location, settings.appliedLocale); + if (addresses.isNotEmpty) { + final address = addresses.first; + return address.addressLine; + } + } + return null; + } +} + +class _CoordinateRow extends StatelessWidget { + final LatLng? location; + + const _CoordinateRow({ + Key? key, + required this.location, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + const SizedBox(width: _LocationInfo.iconPadding), + const DecoratedIcon(AIcons.geoBounds, size: _LocationInfo.iconSize), + const SizedBox(width: _LocationInfo.iconPadding), + Text( + location != null ? settings.coordinateFormat.format(context.l10n, location!) : Constants.overlayUnknown, + strutStyle: Constants.overflowStrutStyle, + ), + ], + ); + } +} diff --git a/lib/widgets/dialogs/tile_view_dialog.dart b/lib/widgets/dialogs/tile_view_dialog.dart index 60484d1cc..de3a26288 100644 --- a/lib/widgets/dialogs/tile_view_dialog.dart +++ b/lib/widgets/dialogs/tile_view_dialog.dart @@ -131,9 +131,8 @@ class _TileViewDialogState extends State> with // crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Material( - borderRadius: const BorderRadius.only( - topLeft: AvesDialog.cornerRadius, - topRight: AvesDialog.cornerRadius, + borderRadius: const BorderRadius.vertical( + top: AvesDialog.cornerRadius, ), clipBehavior: Clip.antiAlias, child: TabBar( diff --git a/lib/widgets/drawer/app_drawer.dart b/lib/widgets/drawer/app_drawer.dart index d5d8ca9cb..3604fa3e0 100644 --- a/lib/widgets/drawer/app_drawer.dart +++ b/lib/widgets/drawer/app_drawer.dart @@ -5,7 +5,6 @@ import 'package:aves/model/source/album.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/location.dart'; import 'package:aves/model/source/tag.dart'; -import 'package:aves/services/common/services.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/android_file_utils.dart'; @@ -25,12 +24,9 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -class AppDrawer extends StatefulWidget { +class AppDrawer extends StatelessWidget { const AppDrawer({Key? key}) : super(key: key); - @override - _AppDrawerState createState() => _AppDrawerState(); - static List getDefaultAlbums(BuildContext context) { final source = context.read(); final specialAlbums = source.rawAlbums.where((album) { @@ -40,26 +36,14 @@ class AppDrawer extends StatefulWidget { ..sort(source.compareAlbumsByName); return specialAlbums; } -} - -class _AppDrawerState extends State { - late Future _newVersionLoader; - - CollectionSource get source => context.read(); - - @override - void initState() { - super.initState(); - _newVersionLoader = availability.isNewVersionAvailable; - } @override Widget build(BuildContext context) { final drawerItems = [ _buildHeader(context), ..._buildTypeLinks(), - _buildAlbumLinks(), - ..._buildPageLinks(), + _buildAlbumLinks(context), + ..._buildPageLinks(context), if (!kReleaseMode) ...[ const Divider(), debugTile, @@ -74,6 +58,8 @@ class _AppDrawerState extends State { builder: (context, mqPaddingBottom, child) { final iconTheme = IconTheme.of(context); return SingleChildScrollView( + // key is expected by test driver + key: const Key('drawer-scrollview'), padding: EdgeInsets.only(bottom: mqPaddingBottom), child: IconTheme( data: iconTheme.copyWith( @@ -146,38 +132,7 @@ class _AppDrawerState extends State { key: const Key('drawer-about-button'), onPressed: () => goTo(AboutPage.routeName, (_) => const AboutPage()), icon: const Icon(AIcons.info), - label: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(context.l10n.aboutPageTitle), - FutureBuilder( - future: _newVersionLoader, - builder: (context, snapshot) { - final newVersion = snapshot.data == true; - final badgeSize = 8.0 * MediaQuery.textScaleFactorOf(context); - return AnimatedOpacity( - duration: Durations.newsBadgeAnimation, - opacity: newVersion ? 1 : 0, - child: Padding( - padding: const EdgeInsetsDirectional.only(start: 2), - child: DecoratedBox( - decoration: BoxDecoration( - border: const Border.fromBorderSide(BorderSide(color: Colors.white70)), - borderRadius: BorderRadius.all(Radius.circular(badgeSize)), - ), - child: Icon( - Icons.circle, - size: badgeSize, - color: Colors.red, - ), - ), - ), - ); - }, - ), - ], - ), + label: Text(context.l10n.aboutPageTitle), ), OutlinedButton.icon( // key is expected by test driver @@ -201,6 +156,8 @@ class _AppDrawerState extends State { return typeBookmarks .where((filter) => !hiddenFilters.contains(filter)) .map((filter) => CollectionNavTile( + // key is expected by test driver + key: Key('drawer-type-${filter?.key}'), leading: DrawerFilterIcon(filter: filter), title: DrawerFilterTitle(filter: filter), filter: filter, @@ -208,7 +165,8 @@ class _AppDrawerState extends State { .toList(); } - Widget _buildAlbumLinks() { + Widget _buildAlbumLinks(BuildContext context) { + final source = context.read(); return StreamBuilder( stream: source.eventBus.on(), builder: (context, snapshot) { @@ -223,10 +181,11 @@ class _AppDrawerState extends State { }); } - List _buildPageLinks() { + List _buildPageLinks(BuildContext context) { final pageBookmarks = settings.drawerPageBookmarks; if (pageBookmarks.isEmpty) return []; + final source = context.read(); return [ const Divider(), ...pageBookmarks.map((route) { @@ -257,6 +216,8 @@ class _AppDrawerState extends State { } return PageNavTile( + // key is expected by test driver + key: Key('drawer-page-$route'), trailing: trailing, routeName: route, pageBuilder: pageBuilder ?? (_) => const SizedBox(), @@ -266,6 +227,8 @@ class _AppDrawerState extends State { } Widget get debugTile => PageNavTile( + // key is expected by test driver + key: const Key('drawer-debug'), topLevel: false, routeName: AppDebugPage.routeName, pageBuilder: (_) => const AppDebugPage(), diff --git a/lib/widgets/filter_grids/common/app_bar.dart b/lib/widgets/filter_grids/common/app_bar.dart index d5e3c4659..463fb19e5 100644 --- a/lib/widgets/filter_grids/common/app_bar.dart +++ b/lib/widgets/filter_grids/common/app_bar.dart @@ -4,6 +4,7 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/selection.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/theme/durations.dart'; +import 'package:aves/widgets/common/animated_icons_fix.dart'; import 'package:aves/widgets/common/app_bar_subtitle.dart'; import 'package:aves/widgets/common/app_bar_title.dart'; import 'package:aves/widgets/common/basic/menu.dart'; @@ -97,8 +98,9 @@ class _FilterGridAppBarState extends State extends StatelessWidget { children: [ if (pinned) AnimatedPadding( - padding: EdgeInsets.only(right: padding), + padding: EdgeInsetsDirectional.only(end: padding), duration: Durations.chipDecorationAnimation, child: Icon( AIcons.pin, @@ -178,7 +178,7 @@ class CoveredFilterChip extends StatelessWidget { ), if (filter is AlbumFilter && androidFileUtils.isOnRemovableStorage(filter.album)) AnimatedPadding( - padding: EdgeInsets.only(right: padding), + padding: EdgeInsetsDirectional.only(end: padding), duration: Durations.chipDecorationAnimation, child: Icon( AIcons.removableStorage, diff --git a/lib/widgets/filter_grids/common/filter_grid_page.dart b/lib/widgets/filter_grids/common/filter_grid_page.dart index 767a723e1..e9e098ddc 100644 --- a/lib/widgets/filter_grids/common/filter_grid_page.dart +++ b/lib/widgets/filter_grids/common/filter_grid_page.dart @@ -384,6 +384,7 @@ class _FilterSectionedContentState extends State<_Fi final isMainMode = context.select, bool>((vn) => vn.value == AppMode.main); final selector = GridSelectionGestureDetector>( + scrollableKey: scrollableKey, selectable: isMainMode && widget.selectable, items: visibleSections.values.expand((v) => v).toList(), scrollController: scrollController, @@ -445,6 +446,7 @@ class _FilterScaler extends StatelessWidget { borderWidth: AvesFilterChip.outlineWidth, borderRadius: CoveredFilterChip.radius(tileSize.shortestSide), color: Colors.grey.shade700, + textDirection: Directionality.of(context), ), child: child, ), diff --git a/lib/widgets/filter_grids/common/filter_nav_page.dart b/lib/widgets/filter_grids/common/filter_nav_page.dart index cdfff8149..5a289f21b 100644 --- a/lib/widgets/filter_grids/common/filter_nav_page.dart +++ b/lib/widgets/filter_grids/common/filter_nav_page.dart @@ -1,6 +1,7 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; +import 'package:aves/utils/time_utils.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/common/providers/selection_provider.dart'; import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart'; @@ -63,7 +64,7 @@ class FilterNavigationPage extends StatelessWidget { } static int compareFiltersByDate(FilterGridItem a, FilterGridItem b) { - final c = (b.entry?.bestDate ?? DateTime.fromMillisecondsSinceEpoch(0)).compareTo(a.entry?.bestDate ?? DateTime.fromMillisecondsSinceEpoch(0)); + final c = (b.entry?.bestDate ?? epoch).compareTo(a.entry?.bestDate ?? epoch); return c != 0 ? c : a.filter.compareTo(b.filter); } diff --git a/lib/widgets/filter_grids/common/filter_tile.dart b/lib/widgets/filter_grids/common/filter_tile.dart index 3877871e3..a3e01625e 100644 --- a/lib/widgets/filter_grids/common/filter_tile.dart +++ b/lib/widgets/filter_grids/common/filter_tile.dart @@ -6,6 +6,7 @@ import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; import 'package:aves/widgets/collection/collection_page.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/grid/scaling.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/filter_grids/common/covered_filter_chip.dart'; @@ -178,8 +179,11 @@ class FilterTile extends StatelessWidget { ], ); if (onTap != null) { + // larger than the chip corner radius, so ink effects will be effectively clipped from the leading chip corners + const radius = Radius.circular(123); child = InkWell( - borderRadius: const BorderRadius.only(topLeft: Radius.circular(123), bottomLeft: Radius.circular(123)), + // as of Flutter v2.8.1, `InkWell` does not use `BorderRadiusGeometry` + borderRadius: context.isRtl ? const BorderRadius.only(topRight: radius, bottomRight: radius) : const BorderRadius.only(topLeft: radius, bottomLeft: radius), onTap: onTap, child: child, ); diff --git a/lib/widgets/filter_grids/common/list_details.dart b/lib/widgets/filter_grids/common/list_details.dart index 1b3bd9ecc..f638d7847 100644 --- a/lib/widgets/filter_grids/common/list_details.dart +++ b/lib/widgets/filter_grids/common/list_details.dart @@ -37,7 +37,7 @@ class FilterListDetails extends StatelessWidget { return Container( padding: FilterListDetailsTheme.contentPadding, foregroundDecoration: BoxDecoration( - border: Border(top: AvesBorder.side), + border: Border(top: AvesBorder.straightSide), ), margin: FilterListDetailsTheme.contentMargin, child: Column( @@ -51,10 +51,10 @@ class FilterListDetails extends StatelessWidget { WidgetSpan( alignment: PlaceholderAlignment.middle, child: Padding( - padding: const EdgeInsets.only(right: FilterListDetailsTheme.titleIconPadding), + padding: const EdgeInsetsDirectional.only(end: FilterListDetailsTheme.titleIconPadding), child: IconTheme( data: IconThemeData(color: detailsTheme.titleStyle.color), - child: leading!, + child: leading, ), ), ), @@ -127,7 +127,7 @@ class FilterListDetails extends StatelessWidget { children: leadingIcons .mapIndexed((i, child) => i > 0 ? Padding( - padding: const EdgeInsets.only(left: 8), + padding: const EdgeInsetsDirectional.only(start: 8), child: child, ) : child) diff --git a/lib/widgets/filter_grids/tags_page.dart b/lib/widgets/filter_grids/tags_page.dart index 65114973c..92220bbc8 100644 --- a/lib/widgets/filter_grids/tags_page.dart +++ b/lib/widgets/filter_grids/tags_page.dart @@ -53,7 +53,7 @@ class TagListPage extends StatelessWidget { } List> _getGridItems(CollectionSource source) { - final filters = source.sortedTags.map((tag) => TagFilter(tag)).toSet(); + final filters = source.sortedTags.map(TagFilter.new).toSet(); return FilterNavigationPage.sort(settings.tagSortFactor, source, filters); } diff --git a/lib/widgets/map/map_page.dart b/lib/widgets/map/map_page.dart index f81b578c0..171eafe28 100644 --- a/lib/widgets/map/map_page.dart +++ b/lib/widgets/map/map_page.dart @@ -27,6 +27,7 @@ import 'package:aves/widgets/viewer/info/notifications.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; +import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; class MapPage extends StatelessWidget { @@ -54,7 +55,7 @@ class MapPage extends StatelessWidget { top: false, right: false, bottom: true, - child: MapPageContent( + child: _Content( collection: collection, initialEntry: initialEntry, ), @@ -65,26 +66,27 @@ class MapPage extends StatelessWidget { } } -class MapPageContent extends StatefulWidget { +class _Content extends StatefulWidget { final CollectionLens collection; final AvesEntry? initialEntry; - const MapPageContent({ + const _Content({ Key? key, required this.collection, this.initialEntry, }) : super(key: key); @override - _MapPageContentState createState() => _MapPageContentState(); + _ContentState createState() => _ContentState(); } -class _MapPageContentState extends State with SingleTickerProviderStateMixin { +class _ContentState extends State<_Content> with SingleTickerProviderStateMixin { final List _subscriptions = []; final AvesMapController _mapController = AvesMapController(); late final ValueNotifier _isPageAnimatingNotifier; final ValueNotifier _selectedIndexNotifier = ValueNotifier(0); final ValueNotifier _regionCollectionNotifier = ValueNotifier(null); + final ValueNotifier _dotLocationNotifier = ValueNotifier(null); final ValueNotifier _dotEntryNotifier = ValueNotifier(null), _infoEntryNotifier = ValueNotifier(null); final Debouncer _infoDebouncer = Debouncer(delay: Durations.mapInfoDebounceDelay); final ValueNotifier _overlayVisible = ValueNotifier(true); @@ -223,11 +225,11 @@ class _MapPageContentState extends State with SingleTickerProvid controller: _mapController, collectionListenable: openingCollection, entries: openingCollection.sortedEntries, - initialEntry: widget.initialEntry, + initialCenter: widget.initialEntry?.latLng, isAnimatingNotifier: _isPageAnimatingNotifier, - dotEntryNotifier: _dotEntryNotifier, - onMapTap: _toggleOverlay, - onMarkerTap: (markerEntry, getClusterEntries) async { + dotLocationNotifier: _dotLocationNotifier, + onMapTap: (_) => _toggleOverlay(), + onMarkerTap: (averageLocation, markerEntry, getClusterEntries) async { final index = regionCollection?.sortedEntries.indexOf(markerEntry); if (index != null && _selectedIndexNotifier.value != index) { _selectedIndexNotifier.value = index; @@ -337,7 +339,10 @@ class _MapPageContentState extends State with SingleTickerProvid void _onThumbnailIndexChange() => _onEntrySelected(_getRegionEntry(_selectedIndexNotifier.value)); - void _onEntrySelected(AvesEntry? selectedEntry) => _dotEntryNotifier.value = selectedEntry; + void _onEntrySelected(AvesEntry? selectedEntry) { + _dotLocationNotifier.value = selectedEntry?.latLng; + _dotEntryNotifier.value = selectedEntry; + } void _updateInfoEntry() { final selectedEntry = _dotEntryNotifier.value; diff --git a/lib/widgets/search/search_delegate.dart b/lib/widgets/search/search_delegate.dart index 5d224993f..e426f5799 100644 --- a/lib/widgets/search/search_delegate.dart +++ b/lib/widgets/search/search_delegate.dart @@ -16,6 +16,7 @@ import 'package:aves/model/source/tag.dart'; import 'package:aves/ref/mime_types.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/collection/collection_page.dart'; +import 'package:aves/widgets/common/animated_icons_fix.dart'; import 'package:aves/widgets/common/expandable_filter_row.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; @@ -60,8 +61,9 @@ class CollectionSearchDelegate { // so the leading may mistakenly switch to the close button return canPop ? IconButton( - icon: AnimatedIcon( - icon: AnimatedIcons.menu_arrow, + // TODO TLAD [rtl] replace to regular `AnimatedIcon` when this is fixed: https://github.com/flutter/flutter/issues/60521 + icon: AnimatedIconFixIssue60521( + icon: AnimatedIconsFixIssue60521.menu_arrow, progress: transitionAnimation, ), onPressed: () => _goBack(context), @@ -169,7 +171,7 @@ class CollectionSearchDelegate { StreamBuilder( stream: source.eventBus.on(), builder: (context, snapshot) { - final filters = source.sortedTags.where(containQuery).map((s) => TagFilter(s)); + final filters = source.sortedTags.where(containQuery).map(TagFilter.new); final noFilter = TagFilter(''); return _buildFilterRow( context: context, @@ -183,7 +185,7 @@ class CollectionSearchDelegate { _buildFilterRow( context: context, title: context.l10n.searchSectionRating, - filters: [0, 5, 4, 3, 2, 1, -1].map((rating) => RatingFilter(rating)).where((f) => containQuery(f.getLabel(context))).toList(), + filters: [0, 5, 4, 3, 2, 1, -1].map(RatingFilter.new).where((f) => containQuery(f.getLabel(context))).toList(), ), ], ); diff --git a/lib/widgets/settings/app_export/items.dart b/lib/widgets/settings/app_export/items.dart new file mode 100644 index 000000000..3a94614b4 --- /dev/null +++ b/lib/widgets/settings/app_export/items.dart @@ -0,0 +1,46 @@ +import 'package:aves/model/covers.dart'; +import 'package:aves/model/favourites.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:flutter/widgets.dart'; + +enum AppExportItem { covers, favourites, settings } + +extension ExtraAppExportItem on AppExportItem { + String getText(BuildContext context) { + switch (this) { + case AppExportItem.covers: + return context.l10n.appExportCovers; + case AppExportItem.favourites: + return context.l10n.appExportFavourites; + case AppExportItem.settings: + return context.l10n.appExportSettings; + } + } + + dynamic export(CollectionSource source) { + switch (this) { + case AppExportItem.covers: + return covers.export(source); + case AppExportItem.favourites: + return favourites.export(source); + case AppExportItem.settings: + return settings.export(); + } + } + + Future import(dynamic jsonMap, CollectionSource source) async { + switch (this) { + case AppExportItem.covers: + covers.import(jsonMap, source); + break; + case AppExportItem.favourites: + favourites.import(jsonMap, source); + break; + case AppExportItem.settings: + await settings.import(jsonMap); + break; + } + } +} diff --git a/lib/widgets/settings/app_export/selection_dialog.dart b/lib/widgets/settings/app_export/selection_dialog.dart new file mode 100644 index 000000000..a02d0a311 --- /dev/null +++ b/lib/widgets/settings/app_export/selection_dialog.dart @@ -0,0 +1,68 @@ +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/dialogs/aves_dialog.dart'; +import 'package:aves/widgets/settings/app_export/items.dart'; +import 'package:flutter/material.dart'; + +class AppExportItemSelectionDialog extends StatefulWidget { + final String title; + final Set? selectableItems, initialSelection; + + const AppExportItemSelectionDialog({ + Key? key, + required this.title, + this.selectableItems, + this.initialSelection, + }) : super(key: key); + + @override + _AppExportItemSelectionDialogState createState() => _AppExportItemSelectionDialogState(); +} + +class _AppExportItemSelectionDialogState extends State { + final Set _selectableItems = {}, _selectedItems = {}; + + @override + void initState() { + super.initState(); + _selectableItems.addAll(widget.selectableItems ?? AppExportItem.values); + _selectedItems.addAll(widget.initialSelection ?? _selectableItems); + } + + @override + Widget build(BuildContext context) { + return AvesDialog( + title: widget.title, + scrollableContent: AppExportItem.values.map((v) { + return SwitchListTile( + value: _selectedItems.contains(v), + onChanged: _selectableItems.contains(v) + ? (selected) { + if (selected == true) { + _selectedItems.add(v); + } else { + _selectedItems.remove(v); + } + setState(() {}); + } + : null, + title: Text( + v.getText(context), + softWrap: false, + overflow: TextOverflow.fade, + maxLines: 1, + ), + ); + }).toList(), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), + ), + TextButton( + onPressed: _selectedItems.isEmpty ? null : () => Navigator.pop(context, _selectedItems), + child: Text(context.l10n.applyButtonLabel), + ), + ], + ); + } +} diff --git a/lib/widgets/settings/common/quick_actions/editor_page.dart b/lib/widgets/settings/common/quick_actions/editor_page.dart index 1b1dc7f74..e3d87797a 100644 --- a/lib/widgets/settings/common/quick_actions/editor_page.dart +++ b/lib/widgets/settings/common/quick_actions/editor_page.dart @@ -174,14 +174,14 @@ class _QuickActionEditorBodyState extends State((s) => s.unitSystem); return AvesExpansionTile( + // key is expected by test driver + key: const Key('section-language'), // use a fixed value instead of the title to identify this expansion tile // so that the tile state is kept when the language is modified value: 'language', diff --git a/lib/widgets/settings/language/locale.dart b/lib/widgets/settings/language/locale.dart index 631890ad2..1279b1b24 100644 --- a/lib/widgets/settings/language/locale.dart +++ b/lib/widgets/settings/language/locale.dart @@ -5,6 +5,7 @@ import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; +import 'package:aves/widgets/settings/language/locales.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; @@ -18,6 +19,8 @@ class LocaleTile extends StatelessWidget { @override Widget build(BuildContext context) { return ListTile( + // key is expected by test driver + key: const Key('tile-language'), title: Text(context.l10n.settingsLanguage), subtitle: Selector( selector: (context, s) => settings.locale, @@ -46,21 +49,7 @@ class LocaleTile extends StatelessWidget { String _getLocaleName(Locale locale) { // the package `flutter_localized_locales` has the answer for all locales // but it comes with 3 MB of assets - switch (locale.languageCode) { - case 'de': - return 'Deutsch'; - case 'en': - return 'English'; - case 'es': - return 'Español (México)'; - case 'fr': - return 'Français'; - case 'ko': - return '한국어'; - case 'ru': - return 'Русский'; - } - return locale.toString(); + return SupportedLocales.languagesByLanguageCode[locale.languageCode] ?? locale.toString(); } LinkedHashMap _getLocaleOptions(BuildContext context) { diff --git a/lib/widgets/settings/language/locales.dart b/lib/widgets/settings/language/locales.dart new file mode 100644 index 000000000..87fc6ad33 --- /dev/null +++ b/lib/widgets/settings/language/locales.dart @@ -0,0 +1,13 @@ +// this class is kept minimal, without import +// so it can be reused in driver tests +class SupportedLocales { + static const languagesByLanguageCode = { + 'de': 'Deutsch', + 'en': 'English', + 'es': 'Español (México)', + 'fr': 'Français', + 'ko': '한국어', + 'pt': 'Português (Brasil)', + 'ru': 'Русский', + }; +} diff --git a/lib/widgets/settings/navigation/drawer_tab_albums.dart b/lib/widgets/settings/navigation/drawer_tab_albums.dart index a0dbad668..d76bd2aa4 100644 --- a/lib/widgets/settings/navigation/drawer_tab_albums.dart +++ b/lib/widgets/settings/navigation/drawer_tab_albums.dart @@ -67,7 +67,7 @@ class _DrawerAlbumTabState extends State { if (album == null) return; setState(() => widget.items.add(album)); }, - ) + ), ], ); } diff --git a/lib/widgets/settings/privacy/file_picker/crumb_line.dart b/lib/widgets/settings/privacy/file_picker/crumb_line.dart index 7889ce085..83da70732 100644 --- a/lib/widgets/settings/privacy/file_picker/crumb_line.dart +++ b/lib/widgets/settings/privacy/file_picker/crumb_line.dart @@ -1,7 +1,7 @@ +import 'package:aves/services/common/services.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:flutter/material.dart'; -import 'package:path/path.dart' as p; class CrumbLine extends StatefulWidget { final VolumeRelativeDirectory directory; @@ -42,7 +42,7 @@ class _CrumbLineState extends State { Widget build(BuildContext context) { List parts = [ directory.getVolumeDescription(context), - ...p.split(directory.relativeDir), + ...pContext.split(directory.relativeDir), ]; final crumbStyle = Theme.of(context).textTheme.bodyText2; final crumbColor = crumbStyle!.color!.withOpacity(.4); @@ -76,7 +76,7 @@ class _CrumbLineState extends State { } return GestureDetector( onTap: () { - final path = p.joinAll([ + final path = pContext.joinAll([ directory.volumePath, ...parts.skip(1).take(index), ]); diff --git a/lib/widgets/settings/privacy/file_picker/file_picker.dart b/lib/widgets/settings/privacy/file_picker/file_picker.dart index 7e7afcd71..f8695b482 100644 --- a/lib/widgets/settings/privacy/file_picker/file_picker.dart +++ b/lib/widgets/settings/privacy/file_picker/file_picker.dart @@ -1,9 +1,11 @@ import 'dart:io'; import 'package:aves/model/settings/settings.dart'; +import 'package:aves/services/common/services.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/android_file_utils.dart'; +import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/basic/menu.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/buttons.dart'; @@ -14,7 +16,6 @@ import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; -import 'package:path/path.dart' as p; class FilePicker extends StatefulWidget { static const routeName = '/file_picker'; @@ -31,13 +32,15 @@ class _FilePickerState extends State { Set get volumes => androidFileUtils.storageVolumes; - String get currentDirectoryPath => p.join(_directory.volumePath, _directory.relativeDir); + String get currentDirectoryPath => pContext.join(_directory.volumePath, _directory.relativeDir); @override void initState() { super.initState(); - final primaryVolume = volumes.firstWhere((v) => v.isPrimary); - _goTo(primaryVolume.path); + final primaryVolume = volumes.firstWhereOrNull((v) => v.isPrimary); + if (primaryVolume != null) { + _goTo(primaryVolume.path); + } } @override @@ -48,7 +51,7 @@ class _FilePickerState extends State { if (showHidden) { return true; } else { - final isHidden = p.split(v.path).last.startsWith('.'); + final isHidden = pContext.split(v.path).last.startsWith('.'); return !isHidden; } }).toList(); @@ -57,7 +60,7 @@ class _FilePickerState extends State { if (_directory.relativeDir.isEmpty) { return SynchronousFuture(true); } - final parent = p.dirname(currentDirectoryPath); + final parent = pContext.dirname(currentDirectoryPath); _goTo(parent); setState(() {}); return SynchronousFuture(false); @@ -143,7 +146,7 @@ class _FilePickerState extends State { if (_directory.relativeDir.isEmpty) { return _directory.getVolumeDescription(context); } - return p.split(_directory.relativeDir).last; + return pContext.split(_directory.relativeDir).last; } Widget _buildDrawer(BuildContext context) { @@ -179,7 +182,7 @@ class _FilePickerState extends State { Widget _buildContentLine(BuildContext context, FileSystemEntity content) { return ListTile( leading: const Icon(AIcons.folder), - title: Text(p.split(content.path).last), + title: Text('${Constants.fsi}${pContext.split(content.path).last}${Constants.pdi}'), onTap: () { _goTo(content.path); setState(() {}); @@ -197,7 +200,7 @@ class _FilePickerState extends State { contents.add(entity); } }, onDone: () { - _contents = contents..sort((a, b) => compareAsciiUpperCase(p.split(a.path).last, p.split(b.path).last)); + _contents = contents..sort((a, b) => compareAsciiUpperCase(pContext.split(a.path).last, pContext.split(b.path).last)); setState(() {}); }); } diff --git a/lib/widgets/settings/settings_page.dart b/lib/widgets/settings/settings_page.dart index e0cef8ed7..b55e3f81a 100644 --- a/lib/widgets/settings/settings_page.dart +++ b/lib/widgets/settings/settings_page.dart @@ -2,7 +2,7 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:aves/model/actions/settings_actions.dart'; -import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/source/collection_source.dart'; import 'package:aves/ref/mime_types.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/theme/durations.dart'; @@ -12,12 +12,15 @@ import 'package:aves/widgets/common/basic/menu.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/settings/accessibility/accessibility.dart'; +import 'package:aves/widgets/settings/app_export/items.dart'; +import 'package:aves/widgets/settings/app_export/selection_dialog.dart'; import 'package:aves/widgets/settings/language/language.dart'; import 'package:aves/widgets/settings/navigation/navigation.dart'; import 'package:aves/widgets/settings/privacy/privacy.dart'; import 'package:aves/widgets/settings/thumbnails/thumbnails.dart'; import 'package:aves/widgets/settings/video/video.dart'; import 'package:aves/widgets/settings/viewer/viewer.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; @@ -106,13 +109,32 @@ class _SettingsPageState extends State with FeedbackMixin { ); } + static const String exportVersionKey = 'version'; + static const int exportVersion = 1; + void _onActionSelected(SettingsAction action) async { + final source = context.read(); switch (action) { case SettingsAction.export: + final toExport = await showDialog>( + context: context, + builder: (context) => AppExportItemSelectionDialog( + title: context.l10n.settingsActionExport, + ), + ); + if (toExport == null || toExport.isEmpty) return; + + final allMap = Map.fromEntries(toExport.map((v) { + final jsonMap = v.export(source); + return jsonMap != null ? MapEntry(v.name, jsonMap) : null; + }).whereNotNull()); + allMap[exportVersionKey] = exportVersion; + final allJsonString = jsonEncode(allMap); + final success = await storageService.createFile( 'aves-settings-${DateFormat('yyyyMMdd_HHmmss').format(DateTime.now())}.json', MimeTypes.json, - Uint8List.fromList(utf8.encode(settings.toJson())), + Uint8List.fromList(utf8.encode(allJsonString)), ); if (success != null) { if (success) { @@ -128,10 +150,44 @@ class _SettingsPageState extends State with FeedbackMixin { final bytes = await storageService.openFile(); if (bytes.isNotEmpty) { try { - await settings.fromJson(utf8.decode(bytes)); + final allJsonString = utf8.decode(bytes); + final allJsonMap = jsonDecode(allJsonString); + + final version = allJsonMap[exportVersionKey]; + final importable = {}; + if (version == null) { + // backwards compatibility before versioning + importable[AppExportItem.settings] = allJsonMap; + } else { + if (allJsonMap is! Map) { + debugPrint('failed to import app json=$allJsonMap'); + showFeedback(context, context.l10n.genericFailureFeedback); + return; + } + allJsonMap.keys.where((v) => v != exportVersionKey).forEach((k) { + try { + importable[AppExportItem.values.byName(k)] = allJsonMap[k]; + } catch (error, stack) { + debugPrint('failed to identify import app item=$k with error=$error\n$stack'); + } + }); + } + + final toImport = await showDialog>( + context: context, + builder: (context) => AppExportItemSelectionDialog( + title: context.l10n.settingsActionImport, + selectableItems: importable.keys.toSet(), + ), + ); + if (toImport == null || toImport.isEmpty) return; + + await Future.forEach(toImport, (item) async { + return item.import(importable[item], source); + }); showFeedback(context, context.l10n.genericSuccessFeedback); } catch (error) { - debugPrint('failed to import settings, error=$error'); + debugPrint('failed to import app json, error=$error'); showFeedback(context, context.l10n.genericFailureFeedback); } } diff --git a/lib/widgets/stats/filter_table.dart b/lib/widgets/stats/filter_table.dart index 325b518c0..ea05acb8a 100644 --- a/lib/widgets/stats/filter_table.dart +++ b/lib/widgets/stats/filter_table.dart @@ -1,9 +1,10 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/utils/color_utils.dart'; import 'package:aves/utils/constants.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; +import 'package:intl/intl.dart' as intl; import 'package:percent_indicator/linear_percent_indicator.dart'; class FilterTable extends StatelessWidget { @@ -40,6 +41,7 @@ class FilterTable extends StatelessWidget { final textScaleFactor = MediaQuery.textScaleFactorOf(context); final lineHeight = 16 * textScaleFactor; + final isRtl = context.isRtl; return Padding( padding: const EdgeInsetsDirectional.only(start: AvesFilterChip.outlineWidth / 2 + 6, end: 8), @@ -73,9 +75,10 @@ class FilterTable extends StatelessWidget { backgroundColor: Colors.white24, progressColor: stringToColor(label), animation: true, + isRTL: isRtl, padding: EdgeInsets.symmetric(horizontal: lineHeight), center: Text( - NumberFormat.percentPattern().format(percent), + intl.NumberFormat.percentPattern().format(percent), style: const TextStyle(shadows: Constants.embossShadows), ), ), diff --git a/lib/widgets/stats/stats_page.dart b/lib/widgets/stats/stats_page.dart index 6ece99212..c94663d50 100644 --- a/lib/widgets/stats/stats_page.dart +++ b/lib/widgets/stats/stats_page.dart @@ -15,6 +15,7 @@ import 'package:aves/utils/color_utils.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/utils/mime_utils.dart'; import 'package:aves/widgets/collection/collection_page.dart'; +import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/empty.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; @@ -23,7 +24,7 @@ import 'package:charts_flutter/flutter.dart' as charts; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; +import 'package:intl/intl.dart' as intl; import 'package:percent_indicator/linear_percent_indicator.dart'; import 'package:provider/provider.dart'; @@ -99,18 +100,22 @@ class StatsPage extends StatelessWidget { padding: const EdgeInsets.all(16), child: Column( children: [ - LinearPercentIndicator( - percent: withGpsPercent, - lineHeight: lineHeight, - backgroundColor: Colors.white24, - progressColor: Theme.of(context).colorScheme.secondary, - animation: animate, - leading: const Icon(AIcons.location), - // right padding to match leading, so that inside label is aligned with outside label below - padding: EdgeInsets.symmetric(horizontal: lineHeight) + const EdgeInsets.only(right: 24), - center: Text( - NumberFormat.percentPattern().format(withGpsPercent), - style: const TextStyle(shadows: Constants.embossShadows), + Padding( + // end padding to match leading, so that inside label is aligned with outside label below + padding: const EdgeInsetsDirectional.only(end: 24), + child: LinearPercentIndicator( + percent: withGpsPercent, + lineHeight: lineHeight, + backgroundColor: Colors.white24, + progressColor: Theme.of(context).colorScheme.secondary, + animation: animate, + isRTL: context.isRtl, + leading: const Icon(AIcons.location), + padding: EdgeInsets.symmetric(horizontal: lineHeight), + center: Text( + intl.NumberFormat.percentPattern().format(withGpsPercent), + style: const TextStyle(shadows: Constants.embossShadows), + ), ), ), const SizedBox(height: 8), @@ -128,8 +133,8 @@ class StatsPage extends StatelessWidget { locationIndicator, ..._buildFilterSection(context, context.l10n.statsTopCountries, entryCountPerCountry, (v) => LocationFilter(LocationLevel.country, v)), ..._buildFilterSection(context, context.l10n.statsTopPlaces, entryCountPerPlace, (v) => LocationFilter(LocationLevel.place, v)), - ..._buildFilterSection(context, context.l10n.statsTopTags, entryCountPerTag, (v) => TagFilter(v)), - if (showRatings) ..._buildFilterSection(context, context.l10n.searchSectionRating, entryCountPerRating, (v) => RatingFilter(v), sortByCount: false, maxRowCount: null), + ..._buildFilterSection(context, context.l10n.statsTopTags, entryCountPerTag, TagFilter.new), + if (showRatings) ..._buildFilterSection(context, context.l10n.searchSectionRating, entryCountPerRating, RatingFilter.new, sortByCount: false, maxRowCount: null), ], ); } @@ -138,8 +143,11 @@ class StatsPage extends StatelessWidget { appBar: AppBar( title: Text(context.l10n.statsPageTitle), ), - body: SafeArea( - child: child, + body: GestureAreaProtectorStack( + child: SafeArea( + bottom: false, + child: child, + ), ), ), ); @@ -213,23 +221,25 @@ class StatsPage extends StatelessWidget { children: seriesData .map((d) => GestureDetector( onTap: () => _onFilterSelection(context, MimeFilter(d.mimeType)), - child: Text.rich( - TextSpan( - children: [ - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: Padding( - padding: const EdgeInsetsDirectional.only(end: 8), - child: Icon(AIcons.disc, color: d.color), - ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(AIcons.disc, color: d.color), + const SizedBox(width: 8), + Flexible( + child: Text( + d.displayText, + overflow: TextOverflow.fade, + softWrap: false, + maxLines: 1, ), - TextSpan(text: '${d.displayText} '), - TextSpan(text: '${d.entryCount}', style: const TextStyle(color: Colors.white70)), - ], - ), - overflow: TextOverflow.fade, - softWrap: false, - maxLines: 1, + ), + const SizedBox(width: 8), + Text( + '${d.entryCount}', + style: const TextStyle(color: Colors.white70), + ), + ], ), )) .toList(), diff --git a/lib/widgets/viewer/action/entry_action_delegate.dart b/lib/widgets/viewer/action/entry_action_delegate.dart index 3c6cff31f..f0e1aab86 100644 --- a/lib/widgets/viewer/action/entry_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_action_delegate.dart @@ -14,6 +14,7 @@ import 'package:aves/model/source/collection_source.dart'; import 'package:aves/services/common/image_op_events.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/services/media/enums.dart'; +import 'package:aves/services/media/media_file_service.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; @@ -22,7 +23,7 @@ import 'package:aves/widgets/common/action_mixins/size_aware.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; -import 'package:aves/widgets/dialogs/entry_editors/rename_entry_dialog.dart'; +import 'package:aves/widgets/dialogs/entry_editors/rename_dialog.dart'; import 'package:aves/widgets/dialogs/export_entry_dialog.dart'; import 'package:aves/widgets/filter_grids/album_pick.dart'; import 'package:aves/widgets/viewer/action/printer.dart'; @@ -203,11 +204,11 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix if (!await checkFreeSpaceForMove(context, {entry}, destinationAlbum, MoveType.export)) return; - final mimeType = await showDialog( + final options = await showDialog( context: context, builder: (context) => ExportEntryDialog(entry: entry), ); - if (mimeType == null) return; + if (options == null) return; final selection = {}; if (entry.isMultiPage) { @@ -231,7 +232,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix // TODO TLAD [SVG] export separately from raster images (sending bytes, like frame captures) opStream: mediaFileService.export( selection, - mimeType: mimeType, + options: options, destinationAlbum: destinationAlbum, nameConflictStrategy: NameConflictStrategy.rename, ), diff --git a/lib/widgets/viewer/action/entry_info_action_delegate.dart b/lib/widgets/viewer/action/entry_info_action_delegate.dart index 1c20cba58..77007ede6 100644 --- a/lib/widgets/viewer/action/entry_info_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_info_action_delegate.dart @@ -4,6 +4,7 @@ import 'package:aves/model/actions/entry_info_actions.dart'; import 'package:aves/model/actions/events.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/entry_metadata_edition.dart'; +import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/widgets/common/action_mixins/entry_editor.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; @@ -14,17 +15,19 @@ import 'package:flutter/material.dart'; class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEditorMixin, SingleEntryEditorMixin { @override final AvesEntry entry; + final CollectionLens? collection; final StreamController> _eventStreamController = StreamController>.broadcast(); Stream> get eventStream => _eventStreamController.stream; - EntryInfoActionDelegate(this.entry); + EntryInfoActionDelegate(this.entry, this.collection); bool isVisible(EntryInfoAction action) { switch (action) { // general case EntryInfoAction.editDate: + case EntryInfoAction.editLocation: case EntryInfoAction.editRating: case EntryInfoAction.editTags: case EntryInfoAction.removeMetadata: @@ -40,6 +43,8 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi // general case EntryInfoAction.editDate: return entry.canEditDate; + case EntryInfoAction.editLocation: + return entry.canEditLocation; case EntryInfoAction.editRating: return entry.canEditRating; case EntryInfoAction.editTags: @@ -59,6 +64,9 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi case EntryInfoAction.editDate: await _editDate(context); break; + case EntryInfoAction.editLocation: + await _editLocation(context); + break; case EntryInfoAction.editRating: await _editRating(context); break; @@ -83,6 +91,13 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi await edit(context, () => entry.editDate(modifier)); } + Future _editLocation(BuildContext context) async { + final location = await selectLocation(context, {entry}, collection); + if (location == null) return; + + await edit(context, () => entry.editLocation(location)); + } + Future _editRating(BuildContext context) async { final rating = await selectRating(context, {entry}); if (rating == null) return; diff --git a/lib/widgets/viewer/action/single_entry_editor.dart b/lib/widgets/viewer/action/single_entry_editor.dart index f47d31314..5cdfdadc4 100644 --- a/lib/widgets/viewer/action/single_entry_editor.dart +++ b/lib/widgets/viewer/action/single_entry_editor.dart @@ -32,7 +32,20 @@ mixin SingleEntryEditorMixin on FeedbackMixin, PermissionAwareMixin { try { if (success) { if (isMainMode && source != null) { + Set obsoleteTags = entry.tags; + String? obsoleteCountryCode = entry.addressDetails?.countryCode; + await source.refreshEntry(entry, dataTypes); + + // invalidate filters derived from values before edition + // this invalidation must happen after the source is refreshed, + // otherwise filter chips may eagerly rebuild in between with the old state + if (obsoleteCountryCode != null) { + source.invalidateCountryFilterSummary(countryCodes: {obsoleteCountryCode}); + } + if (obsoleteTags.isNotEmpty) { + source.invalidateTagFilterSummary(tags: obsoleteTags); + } } else { await entry.refresh(background: false, persist: false, dataTypes: dataTypes, geocoderLocale: settings.appliedLocale); } diff --git a/lib/widgets/viewer/debug/debug_page.dart b/lib/widgets/viewer/debug/debug_page.dart index 68c814511..f916b96d5 100644 --- a/lib/widgets/viewer/debug/debug_page.dart +++ b/lib/widgets/viewer/debug/debug_page.dart @@ -28,18 +28,21 @@ class ViewerDebugPage extends StatelessWidget { Tuple2(const Tab(icon: Icon(AIcons.android)), MetadataTab(entry: entry)), Tuple2(const Tab(icon: Icon(AIcons.image)), _buildThumbnailsTabView()), ]; - return DefaultTabController( - length: tabs.length, - child: Scaffold( - appBar: AppBar( - title: const Text('Debug'), - bottom: TabBar( - tabs: tabs.map((t) => t.item1).toList(), + return Directionality( + textDirection: TextDirection.ltr, + child: DefaultTabController( + length: tabs.length, + child: Scaffold( + appBar: AppBar( + title: const Text('Debug'), + bottom: TabBar( + tabs: tabs.map((t) => t.item1).toList(), + ), ), - ), - body: SafeArea( - child: TabBarView( - children: tabs.map((t) => t.item2).toList(), + body: SafeArea( + child: TabBarView( + children: tabs.map((t) => t.item2).toList(), + ), ), ), ), @@ -50,7 +53,11 @@ class ViewerDebugPage extends StatelessWidget { String toDateValue(int? time, {int factor = 1}) { var value = '$time'; if (time != null && time > 0) { - value += ' (${DateTime.fromMillisecondsSinceEpoch(time * factor)})'; + try { + value += ' (${DateTime.fromMillisecondsSinceEpoch(time * factor)})'; + } catch (e) { + value += ' (invalid DateTime})'; + } } return value; } diff --git a/lib/widgets/viewer/debug/metadata.dart b/lib/widgets/viewer/debug/metadata.dart index abe90ed89..780f7c1a8 100644 --- a/lib/widgets/viewer/debug/metadata.dart +++ b/lib/widgets/viewer/debug/metadata.dart @@ -57,7 +57,11 @@ class _MetadataTabState extends State { if (secondTimestampKeys.contains(key)) { v *= 1000; } - value += ' (${DateTime.fromMillisecondsSinceEpoch(v)})'; + try { + value += ' (${DateTime.fromMillisecondsSinceEpoch(v)})'; + } catch (e) { + value += ' (invalid DateTime})'; + } } if (key == 'xmp' && v != null && v is Uint8List) { value = String.fromCharCodes(v); diff --git a/lib/widgets/viewer/info/basic_section.dart b/lib/widgets/viewer/info/basic_section.dart index 70556e422..a750d91b7 100644 --- a/lib/widgets/viewer/info/basic_section.dart +++ b/lib/widgets/viewer/info/basic_section.dart @@ -96,7 +96,7 @@ class BasicSection extends StatelessWidget { if (entry.isVideo && !entry.is360) MimeFilter.video, if (album != null) AlbumFilter(album, collection?.source.getAlbumDisplayName(context, album)), if (entry.rating != 0) RatingFilter(entry.rating), - ...tags.map((tag) => TagFilter(tag)), + ...tags.map(TagFilter.new), }; return AnimatedBuilder( animation: favourites, diff --git a/lib/widgets/viewer/info/common.dart b/lib/widgets/viewer/info/common.dart index 3f26747e6..bfb4461c5 100644 --- a/lib/widgets/viewer/info/common.dart +++ b/lib/widgets/viewer/info/common.dart @@ -1,5 +1,6 @@ import 'dart:math'; +import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -79,7 +80,6 @@ class _InfoRowGroupState extends State { // compute the size of keys and space in order to align values final textScaleFactor = MediaQuery.textScaleFactorOf(context); final keySizes = Map.fromEntries(keyValues.keys.map((key) => MapEntry(key, _getSpanWidth(TextSpan(text: key, style: InfoRowGroup.keyStyle), textScaleFactor)))); - final baseSpaceWidth = _getSpanWidth(TextSpan(text: '\u200A' * 100, style: InfoRowGroup.baseStyle), textScaleFactor); final lastKey = keyValues.keys.last; return LayoutBuilder( @@ -88,56 +88,51 @@ class _InfoRowGroupState extends State { final maxBaseValueX = constraints.maxWidth / 3; final baseValueX = keySizes.values.where((size) => size < maxBaseValueX).fold(0.0, max); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SelectableText.rich( - TextSpan( - children: keyValues.entries.expand( - (kv) { - final key = kv.key; - String value; - TextStyle? style; - GestureRecognizer? recognizer; + return SelectableText.rich( + TextSpan( + children: keyValues.entries.expand( + (kv) { + final key = kv.key; + String value; + TextStyle? style; + GestureRecognizer? recognizer; - if (linkHandlers?.containsKey(key) == true) { - final handler = linkHandlers![key]!; - value = handler.linkText(context); - // open link on tap - recognizer = TapGestureRecognizer()..onTap = () => handler.onTap(context); - style = InfoRowGroup.linkStyle; - } else { - value = kv.value; - // long values are clipped, and made expandable by tapping them - final showPreviewOnly = maxValueLength > 0 && value.length > maxValueLength && !_expandedKeys.contains(key); - if (showPreviewOnly) { - value = '${value.substring(0, maxValueLength)}…'; - // show full value on tap - recognizer = TapGestureRecognizer()..onTap = () => setState(() => _expandedKeys.add(key)); - } - } + if (linkHandlers?.containsKey(key) == true) { + final handler = linkHandlers![key]!; + value = handler.linkText(context); + // open link on tap + recognizer = TapGestureRecognizer()..onTap = () => handler.onTap(context); + style = InfoRowGroup.linkStyle; + } else { + value = kv.value; + // long values are clipped, and made expandable by tapping them + final showPreviewOnly = maxValueLength > 0 && value.length > maxValueLength && !_expandedKeys.contains(key); + if (showPreviewOnly) { + value = '${value.substring(0, maxValueLength)}…'; + // show full value on tap + recognizer = TapGestureRecognizer()..onTap = () => setState(() => _expandedKeys.add(key)); + } + } - if (key != lastKey) { - value = '$value\n'; - } + if (key != lastKey) { + value = '$value\n'; + } - // as of Flutter v2.5.3, `SelectableText` cannot contain `WidgetSpan` - // so we add padding using multiple hair spaces instead - // TODO TLAD 2021/10/26 other `InlineSpan` now possible thanks to https://github.com/flutter/flutter/pull/92295 - final thisSpaceSize = max(0.0, (baseValueX - keySizes[key]!)) + InfoRowGroup.keyValuePadding; - final spaceCount = (100 * thisSpaceSize / baseSpaceWidth).round(); + final thisSpaceSize = max(0.0, (baseValueX - keySizes[key]!)) + InfoRowGroup.keyValuePadding; - return [ - TextSpan(text: key, style: InfoRowGroup.keyStyle), - TextSpan(text: '\u200A' * spaceCount), - TextSpan(text: value, style: style, recognizer: recognizer), - ]; - }, - ).toList(), - ), - style: InfoRowGroup.baseStyle, - ), - ], + // each text span embeds and pops a Bidi isolate, + // so that layout of the spans follows the directionality of the locale + // (e.g. keys on the right for RTL locale, whatever the key intrinsic directionality) + // and each span respects the directionality of its inner text only + return [ + TextSpan(text: '${Constants.fsi}$key${Constants.pdi}', style: InfoRowGroup.keyStyle), + WidgetSpan(child: SizedBox(width: thisSpaceSize)), + TextSpan(text: '${Constants.fsi}$value${Constants.pdi}', style: style, recognizer: recognizer), + ]; + }, + ).toList(), + ), + style: InfoRowGroup.baseStyle, ); }, ); diff --git a/lib/widgets/viewer/info/info_page.dart b/lib/widgets/viewer/info/info_page.dart index ad14c8ee6..2f370f5f3 100644 --- a/lib/widgets/viewer/info/info_page.dart +++ b/lib/widgets/viewer/info/info_page.dart @@ -181,7 +181,7 @@ class _InfoPageContentState extends State<_InfoPageContent> { } void _registerWidget(_InfoPageContent widget) { - _actionDelegate = EntryInfoActionDelegate(widget.entry); + _actionDelegate = EntryInfoActionDelegate(widget.entry, collection); _subscriptions.add(_actionDelegate.eventStream.listen(_onActionDelegateEvent)); } diff --git a/lib/widgets/viewer/info/info_search.dart b/lib/widgets/viewer/info/info_search.dart index 6dbb9200a..d40680cd4 100644 --- a/lib/widgets/viewer/info/info_search.dart +++ b/lib/widgets/viewer/info/info_search.dart @@ -1,5 +1,6 @@ import 'package:aves/model/entry.dart'; import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/animated_icons_fix.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/empty.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; @@ -26,8 +27,9 @@ class InfoSearchDelegate extends SearchDelegate { @override Widget buildLeading(BuildContext context) { return IconButton( - icon: AnimatedIcon( - icon: AnimatedIcons.menu_arrow, + // TODO TLAD [rtl] replace to regular `AnimatedIcon` when this is fixed: https://github.com/flutter/flutter/issues/60521 + icon: AnimatedIconFixIssue60521( + icon: AnimatedIconsFixIssue60521.menu_arrow, progress: transitionAnimation, ), onPressed: () => Navigator.pop(context), diff --git a/lib/widgets/viewer/info/location_section.dart b/lib/widgets/viewer/info/location_section.dart index fa78e6da9..d216417ff 100644 --- a/lib/widgets/viewer/info/location_section.dart +++ b/lib/widgets/viewer/info/location_section.dart @@ -7,6 +7,7 @@ import 'package:aves/services/common/services.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; +import 'package:aves/widgets/common/map/controller.dart'; import 'package:aves/widgets/common/map/geo_map.dart'; import 'package:aves/widgets/common/map/theme.dart'; import 'package:aves/widgets/map/map_page.dart'; @@ -34,6 +35,8 @@ class LocationSection extends StatefulWidget { } class _LocationSectionState extends State { + final AvesMapController _mapController = AvesMapController(); + CollectionLens? get collection => widget.collection; AvesEntry get entry => widget.entry; @@ -58,28 +61,17 @@ class _LocationSectionState extends State { } void _registerWidget(LocationSection widget) { - widget.entry.metadataChangeNotifier.addListener(_handleChange); - widget.entry.addressChangeNotifier.addListener(_handleChange); + widget.entry.metadataChangeNotifier.addListener(_onMetadataChange); } void _unregisterWidget(LocationSection widget) { - widget.entry.metadataChangeNotifier.removeListener(_handleChange); - widget.entry.addressChangeNotifier.removeListener(_handleChange); + widget.entry.metadataChangeNotifier.removeListener(_onMetadataChange); } @override Widget build(BuildContext context) { if (!entry.hasGps) return const SizedBox(); - final filters = []; - if (entry.hasAddress) { - final address = entry.addressDetails!; - final country = address.countryName; - if (country != null && country.isNotEmpty) filters.add(LocationFilter(LocationLevel.country, '$country${LocationFilter.locationSeparator}${address.countryCode}')); - final place = address.place; - if (place != null && place.isNotEmpty) filters.add(LocationFilter(LocationLevel.place, place)); - } - return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -91,28 +83,48 @@ class _LocationSectionState extends State { visualDensity: VisualDensity.compact, mapHeight: 200, child: GeoMap( + controller: _mapController, entries: [entry], isAnimatingNotifier: widget.isScrollingNotifier, onUserZoomChange: (zoom) => settings.infoMapZoom = zoom, - onMarkerTap: collection != null ? (_, __) => _openMapPage(context) : null, + onMarkerTap: collection != null ? (_, __, ___) => _openMapPage(context) : null, openMapPage: collection != null ? _openMapPage : null, ), ), - _AddressInfoGroup(entry: entry), - if (filters.isNotEmpty) - Padding( - padding: const EdgeInsets.symmetric(horizontal: AvesFilterChip.outlineWidth / 2) + const EdgeInsets.only(top: 8), - child: Wrap( - spacing: 8, - runSpacing: 8, - children: filters - .map((filter) => AvesFilterChip( - filter: filter, - onTap: widget.onFilter, - )) - .toList(), - ), - ), + AnimatedBuilder( + animation: entry.addressChangeNotifier, + builder: (context, child) { + final filters = []; + if (entry.hasAddress) { + final address = entry.addressDetails!; + final country = address.countryName; + if (country != null && country.isNotEmpty) filters.add(LocationFilter(LocationLevel.country, '$country${LocationFilter.locationSeparator}${address.countryCode}')); + final place = address.place; + if (place != null && place.isNotEmpty) filters.add(LocationFilter(LocationLevel.place, place)); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _AddressInfoGroup(entry: entry), + if (filters.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: AvesFilterChip.outlineWidth / 2) + const EdgeInsets.only(top: 8), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: filters + .map((filter) => AvesFilterChip( + filter: filter, + onTap: widget.onFilter, + )) + .toList(), + ), + ), + ], + ); + }, + ), ], ); } @@ -136,7 +148,15 @@ class _LocationSectionState extends State { ); } - void _handleChange() => setState(() {}); + void _onMetadataChange() { + setState(() {}); + + final location = entry.latLng; + if (location != null) { + _mapController.notifyMarkerLocationChange(); + _mapController.moveTo(location); + } + } } class _AddressInfoGroup extends StatefulWidget { diff --git a/lib/widgets/viewer/info/metadata/xmp_ns/crs.dart b/lib/widgets/viewer/info/metadata/xmp_ns/crs.dart index 2d7f47fe0..509ea70ec 100644 --- a/lib/widgets/viewer/info/metadata/xmp_ns/crs.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/crs.dart @@ -11,6 +11,7 @@ class XmpCrsNamespace extends XmpNamespace { static final pbcPattern = RegExp(ns + r':PaintBasedCorrections\[(\d+)\]/(.*)'); static final retouchAreasPattern = RegExp(ns + r':RetouchAreas\[(\d+)\]/(.*)'); static final lookPattern = RegExp(ns + r':Look/(.*)'); + static final rmmiPattern = RegExp(ns + r':RangeMaskMapInfo/' + ns + r':RangeMaskMapInfo/(.*)'); final cgbc = >{}; final gbc = >{}; @@ -18,12 +19,14 @@ class XmpCrsNamespace extends XmpNamespace { final pbc = >{}; final retouchAreas = >{}; final look = {}; + final rmmi = {}; XmpCrsNamespace(Map rawProps) : super(ns, rawProps); @override bool extractData(XmpProp prop) { - final hasStructs = extractStruct(prop, lookPattern, look); + var hasStructs = extractStruct(prop, lookPattern, look); + hasStructs |= extractStruct(prop, rmmiPattern, rmmi); var hasIndexedStructs = extractIndexedStruct(prop, cgbcPattern, cgbc); hasIndexedStructs |= extractIndexedStruct(prop, gbcPattern, gbc); hasIndexedStructs |= extractIndexedStruct(prop, mgbcPattern, mgbc); @@ -59,6 +62,11 @@ class XmpCrsNamespace extends XmpNamespace { title: 'Paint Based Corrections', structByIndex: pbc, ), + if (rmmi.isNotEmpty) + XmpStructCard( + title: 'Range Mask Map Info', + struct: rmmi, + ), if (retouchAreas.isNotEmpty) XmpStructArrayCard( title: 'Retouch Areas', diff --git a/lib/widgets/viewer/info/owner.dart b/lib/widgets/viewer/info/owner.dart index 354a2629b..07449ec14 100644 --- a/lib/widgets/viewer/info/owner.dart +++ b/lib/widgets/viewer/info/owner.dart @@ -58,10 +58,7 @@ class _OwnerPropState extends State { future: _appNameLoader, builder: (context, snapshot) { final appName = androidFileUtils.getCurrentAppName(ownerPackage) ?? ownerPackage; - // as of Flutter v2.5.3, `SelectableText` cannot contain `WidgetSpan` - // so we use a basic `Text` instead - // TODO TLAD 2021/10/26 other `InlineSpan` now possible thanks to https://github.com/flutter/flutter/pull/92295 - return Text.rich( + return SelectableText.rich( TextSpan( children: [ TextSpan( diff --git a/lib/widgets/viewer/overlay/bottom/common.dart b/lib/widgets/viewer/overlay/bottom/common.dart index 511a52a2f..f30eda557 100644 --- a/lib/widgets/viewer/overlay/bottom/common.dart +++ b/lib/widgets/viewer/overlay/bottom/common.dart @@ -324,7 +324,17 @@ class _LocationRow extends AnimatedWidget { @override Widget build(BuildContext context) { - final location = entry.hasAddress ? entry.shortAddress : settings.coordinateFormat.format(context.l10n, entry.latLng!); + late final String location; + if (entry.hasAddress) { + location = entry.shortAddress; + } else { + final latLng = entry.latLng; + if (latLng != null) { + location = settings.coordinateFormat.format(context.l10n, latLng); + } else { + location = ''; + } + } return Row( children: [ const DecoratedIcon(AIcons.location, shadows: Constants.embossShadows, size: _iconSize), @@ -358,7 +368,7 @@ class _PositionTitleRow extends StatelessWidget { [ if (collectionPosition != null) collectionPosition, if (pagePosition != null) pagePosition, - if (title != null) title, + if (title != null) '${Constants.fsi}$title${Constants.pdi}', ].join(separator), strutStyle: Constants.overflowStrutStyle); @@ -430,7 +440,7 @@ class _ShootingRow extends StatelessWidget { final apertureText = aperture != null ? 'ƒ/${NumberFormat('0.0', locale).format(aperture)}' : Constants.overlayUnknown; final focalLength = details.focalLength; - final focalLengthText = focalLength != null ? '${NumberFormat('0.#', locale).format(focalLength)} mm' : Constants.overlayUnknown; + final focalLengthText = focalLength != null ? context.l10n.focalLength(NumberFormat('0.#', locale).format(focalLength)) : Constants.overlayUnknown; final iso = details.iso; final isoText = iso != null ? 'ISO$iso' : Constants.overlayUnknown; diff --git a/lib/widgets/viewer/overlay/bottom/video.dart b/lib/widgets/viewer/overlay/bottom/video.dart index 8a4208b8d..7e453e924 100644 --- a/lib/widgets/viewer/overlay/bottom/video.dart +++ b/lib/widgets/viewer/overlay/bottom/video.dart @@ -175,17 +175,21 @@ class _VideoControlOverlayState extends State with SingleTi ), ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(4)), - child: StreamBuilder( - stream: positionStream, - builder: (context, snapshot) { - // do not use stream snapshot because it is obsolete when switching between videos - var progress = controller?.progress ?? 0.0; - if (!progress.isFinite) progress = 0.0; - return LinearProgressIndicator( - value: progress, - backgroundColor: Colors.grey.shade700, - ); - }), + child: Directionality( + // force directionality for `LinearProgressIndicator` + textDirection: TextDirection.ltr, + child: StreamBuilder( + stream: positionStream, + builder: (context, snapshot) { + // do not use stream snapshot because it is obsolete when switching between videos + var progress = controller?.progress ?? 0.0; + if (!progress.isFinite) progress = 0.0; + return LinearProgressIndicator( + value: progress, + backgroundColor: Colors.grey.shade700, + ); + }), + ), ), const Text( // fake text below to match the height of the text above and center the whole thing diff --git a/lib/widgets/viewer/overlay/common.dart b/lib/widgets/viewer/overlay/common.dart index 044933971..7caf6446d 100644 --- a/lib/widgets/viewer/overlay/common.dart +++ b/lib/widgets/viewer/overlay/common.dart @@ -38,7 +38,7 @@ class OverlayButton extends StatelessWidget { } // icon (24) + icon padding (8) + button padding (16) + border (1 or 2) - static double getSize(BuildContext context) => 48.0 + AvesBorder.borderWidth * 2; + static double getSize(BuildContext context) => 48.0 + AvesBorder.curvedBorderWidth * 2; } class OverlayTextButton extends StatelessWidget { @@ -71,7 +71,7 @@ class OverlayTextButton extends StatelessWidget { foregroundColor: MaterialStateProperty.all(Colors.white), overlayColor: MaterialStateProperty.all(Colors.white.withOpacity(0.12)), minimumSize: _minSize, - side: MaterialStateProperty.all(AvesBorder.side), + side: MaterialStateProperty.all(AvesBorder.curvedSide), shape: MaterialStateProperty.all(const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(_borderRadius)), )), diff --git a/lib/widgets/viewer/video/fijkplayer.dart b/lib/widgets/viewer/video/fijkplayer.dart index 61300cc58..f04f0f164 100644 --- a/lib/widgets/viewer/video/fijkplayer.dart +++ b/lib/widgets/viewer/video/fijkplayer.dart @@ -162,6 +162,7 @@ class IjkPlayerAvesVideoController extends AvesVideoController { // `enable-accurate-seek`: enable accurate seek // default: 0, in [0, 1] + // ignore: dead_code options.setPlayerOption('enable-accurate-seek', accurateSeekEnabled ? 1 : 0); // `min-frames`: minimal frames to stop pre-reading diff --git a/lib/widgets/viewer/visual/subtitle/style.dart b/lib/widgets/viewer/visual/subtitle/style.dart index 0519cfe17..a0b6a1d2d 100644 --- a/lib/widgets/viewer/visual/subtitle/style.dart +++ b/lib/widgets/viewer/visual/subtitle/style.dart @@ -84,8 +84,8 @@ class SubtitleStyle extends Equatable with Diagnosticable { @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); - properties.add(DiagnosticsProperty('hAlign', hAlign)); - properties.add(DiagnosticsProperty('vAlign', vAlign)); + properties.add(EnumProperty('hAlign', hAlign)); + properties.add(EnumProperty('vAlign', vAlign)); properties.add(ColorProperty('borderColor', borderColor)); properties.add(DoubleProperty('borderWidth', borderWidth)); properties.add(DoubleProperty('edgeBlur', edgeBlur)); diff --git a/lib/widgets/welcome_page.dart b/lib/widgets/welcome_page.dart index 0ef8a2028..d21f9c743 100644 --- a/lib/widgets/welcome_page.dart +++ b/lib/widgets/welcome_page.dart @@ -25,11 +25,14 @@ class _WelcomePageState extends State { bool _hasAcceptedTerms = false; late Future _termsLoader; + static const termsPath = 'assets/terms.md'; + static const termsDirection = TextDirection.ltr; + @override void initState() { super.initState(); settings.setContextualDefaults(); - _termsLoader = rootBundle.loadString('assets/terms.md'); + _termsLoader = rootBundle.loadString(termsPath); WidgetsBinding.instance!.addPostFrameCallback((_) => _initWelcomeSettings()); } @@ -68,7 +71,12 @@ class _WelcomePageState extends State { children: [ ..._buildHeader(context, isPortrait: isPortrait), if (isPortrait) ...[ - Flexible(child: MarkdownContainer(data: terms)), + Flexible( + child: MarkdownContainer( + data: terms, + textDirection: termsDirection, + ), + ), const SizedBox(height: 16), ..._buildControls(context), ] else @@ -76,16 +84,19 @@ class _WelcomePageState extends State { child: Row( children: [ Flexible( - child: Padding( - padding: const EdgeInsets.only(bottom: 8), - child: MarkdownContainer(data: terms), - )), + child: Padding( + padding: const EdgeInsets.only(bottom: 8), + child: MarkdownContainer( + data: terms, + textDirection: termsDirection, + ), + ), + ), Flexible( child: ListView( - // shrinkWrap: true, children: _buildControls(context), ), - ) + ), ], ), ) diff --git a/pubspec.lock b/pubspec.lock index c65a3bfd8..52bb31a05 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -119,14 +119,14 @@ packages: name: connectivity_plus url: "https://pub.dartlang.org" source: hosted - version: "2.1.0" + version: "2.2.0" connectivity_plus_linux: dependency: transitive description: name: connectivity_plus_linux url: "https://pub.dartlang.org" source: hosted - version: "1.1.1" + version: "1.2.0" connectivity_plus_macos: dependency: transitive description: @@ -140,14 +140,14 @@ packages: name: connectivity_plus_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "1.1.1" + version: "1.2.0" connectivity_plus_web: dependency: transitive description: name: connectivity_plus_web url: "https://pub.dartlang.org" source: hosted - version: "1.1.0+1" + version: "1.2.0" connectivity_plus_windows: dependency: transitive description: @@ -196,7 +196,7 @@ packages: name: dbus url: "https://pub.dartlang.org" source: hosted - version: "0.6.6" + version: "0.6.8" decorated_icon: dependency: "direct main" description: @@ -210,21 +210,21 @@ packages: name: device_info_plus url: "https://pub.dartlang.org" source: hosted - version: "3.2.0" + version: "3.2.1" device_info_plus_linux: dependency: transitive description: name: device_info_plus_linux url: "https://pub.dartlang.org" source: hosted - version: "2.1.0" + version: "2.1.1" device_info_plus_macos: dependency: transitive description: name: device_info_plus_macos url: "https://pub.dartlang.org" source: hosted - version: "2.2.0" + version: "2.2.1" device_info_plus_platform_interface: dependency: transitive description: @@ -245,7 +245,7 @@ packages: name: device_info_plus_windows url: "https://pub.dartlang.org" source: hosted - version: "2.1.0" + version: "2.1.1" equatable: dependency: "direct main" description: @@ -305,7 +305,7 @@ packages: name: firebase_core url: "https://pub.dartlang.org" source: hosted - version: "1.10.6" + version: "1.11.0" firebase_core_platform_interface: dependency: transitive description: @@ -326,14 +326,14 @@ packages: name: firebase_crashlytics url: "https://pub.dartlang.org" source: hosted - version: "2.4.4" + version: "2.4.5" firebase_crashlytics_platform_interface: dependency: transitive description: name: firebase_crashlytics_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "3.1.11" + version: "3.1.12" flex_color_picker: dependency: "direct main" description: @@ -441,13 +441,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "7.2.0" - github: - dependency: "direct main" - description: - name: github - url: "https://pub.dartlang.org" - source: hosted - version: "8.3.0" glob: dependency: transitive description: @@ -475,7 +468,7 @@ packages: name: google_maps_flutter_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "2.1.3" + version: "2.1.5" highlight: dependency: transitive description: @@ -510,7 +503,7 @@ packages: name: image url: "https://pub.dartlang.org" source: hosted - version: "3.1.0" + version: "3.1.1" intl: dependency: "direct main" description: @@ -532,13 +525,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.6.3" - json_annotation: - dependency: transitive - description: - name: json_annotation - url: "https://pub.dartlang.org" - source: hosted - version: "4.4.0" latlong2: dependency: "direct main" description: @@ -727,21 +713,21 @@ packages: name: path_provider_linux url: "https://pub.dartlang.org" source: hosted - version: "2.1.4" + version: "2.1.5" path_provider_platform_interface: dependency: transitive description: name: path_provider_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "2.0.1" + version: "2.0.3" path_provider_windows: dependency: transitive description: name: path_provider_windows url: "https://pub.dartlang.org" source: hosted - version: "2.0.4" + version: "2.0.5" pdf: dependency: "direct main" description: @@ -797,7 +783,7 @@ packages: name: plugin_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "2.0.2" + version: "2.1.2" pool: dependency: transitive description: @@ -839,7 +825,7 @@ packages: name: provider url: "https://pub.dartlang.org" source: hosted - version: "6.0.1" + version: "6.0.2" pub_semver: dependency: transitive description: @@ -867,56 +853,56 @@ packages: name: screen_brightness url: "https://pub.dartlang.org" source: hosted - version: "0.1.2+2" + version: "0.1.3" screen_brightness_android: dependency: transitive description: name: screen_brightness_android url: "https://pub.dartlang.org" source: hosted - version: "0.0.1" + version: "0.0.3" screen_brightness_ios: dependency: transitive description: name: screen_brightness_ios url: "https://pub.dartlang.org" source: hosted - version: "0.0.2" + version: "0.0.4" screen_brightness_platform_interface: dependency: transitive description: name: screen_brightness_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "0.0.2" + version: "0.0.3" shared_preferences: dependency: "direct main" description: name: shared_preferences url: "https://pub.dartlang.org" source: hosted - version: "2.0.11" + version: "2.0.12" shared_preferences_android: dependency: transitive description: name: shared_preferences_android url: "https://pub.dartlang.org" source: hosted - version: "2.0.9" + version: "2.0.10" shared_preferences_ios: dependency: transitive description: name: shared_preferences_ios url: "https://pub.dartlang.org" source: hosted - version: "2.0.8" + version: "2.0.9" shared_preferences_linux: dependency: transitive description: name: shared_preferences_linux url: "https://pub.dartlang.org" source: hosted - version: "2.0.3" + version: "2.0.4" shared_preferences_macos: dependency: transitive description: @@ -937,14 +923,14 @@ packages: name: shared_preferences_web url: "https://pub.dartlang.org" source: hosted - version: "2.0.2" + version: "2.0.3" shared_preferences_windows: dependency: transitive description: name: shared_preferences_windows url: "https://pub.dartlang.org" source: hosted - version: "2.0.3" + version: "2.0.4" shelf: dependency: transitive description: @@ -1005,14 +991,14 @@ packages: name: sqflite url: "https://pub.dartlang.org" source: hosted - version: "2.0.1" + version: "2.0.2" sqflite_common: dependency: transitive description: name: sqflite_common url: "https://pub.dartlang.org" source: hosted - version: "2.1.0" + version: "2.2.0" stack_trace: dependency: transitive description: @@ -1039,7 +1025,7 @@ packages: description: path: "." ref: HEAD - resolved-ref: fba50f0e380d8cbd6a5bbda32f97a9c5e4d033e2 + resolved-ref: cd5ccd925d0348218aaf156f0b9dc4f8caaec7cc url: "git://github.com/deckerst/aves_streams_channel.git" source: git version: "0.3.0" @@ -1126,21 +1112,21 @@ packages: name: url_launcher url: "https://pub.dartlang.org" source: hosted - version: "6.0.17" + version: "6.0.18" url_launcher_android: dependency: transitive description: name: url_launcher_android url: "https://pub.dartlang.org" source: hosted - version: "6.0.13" + version: "6.0.14" url_launcher_ios: dependency: transitive description: name: url_launcher_ios url: "https://pub.dartlang.org" source: hosted - version: "6.0.13" + version: "6.0.14" url_launcher_linux: dependency: transitive description: @@ -1161,14 +1147,14 @@ packages: name: url_launcher_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "2.0.4" + version: "2.0.5" url_launcher_web: dependency: transitive description: name: url_launcher_web url: "https://pub.dartlang.org" source: hosted - version: "2.0.5" + version: "2.0.6" url_launcher_windows: dependency: transitive description: @@ -1183,13 +1169,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.1" - version: - dependency: "direct main" - description: - name: version - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" vm_service: dependency: transitive description: @@ -1231,7 +1210,7 @@ packages: name: win32 url: "https://pub.dartlang.org" source: hosted - version: "2.3.1" + version: "2.3.3" wkt_parser: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 49396db87..4130e3c27 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,11 +1,16 @@ name: aves description: A visual media gallery and metadata explorer app. repository: https://github.com/deckerst/aves -version: 1.5.10+64 +# version bump checklist: +# - pubspec version +# - github changelog: /CHANGELOG.md +# - play changelog: /whatsnew/whatsnew-en-US +# - izzy changelog: /fastlane/metadata/android/en-US/changelogs/1XXX.txt +version: 1.5.11+65 publish_to: none environment: - sdk: '>=2.14.0 <3.0.0' + sdk: '>=2.15.0 <3.0.0' # use `scripts/apply_flavor_{flavor}.sh` to set the right dependencies for the flavor dependencies: @@ -41,7 +46,6 @@ dependencies: flutter_markdown: flutter_staggered_animations: get_it: - github: google_api_availability: google_maps_flutter: intl: @@ -66,7 +70,6 @@ dependencies: transparent_image: tuple: url_launcher: - version: xml: dev_dependencies: @@ -104,7 +107,7 @@ flutter: # language files: # - /lib/l10n/app_{language}.arb # - /android/app/src/main/res/values-{language}/strings.xml -# - edit locale name resolution for language setting +# - edit locale name in /lib/widgets/settings/language/locales.dart # generate `AppLocalizations` # % flutter gen-l10n @@ -112,11 +115,11 @@ flutter: ################################################################################ # Test driver -# run (any device): -# % flutter drive --flavor play -t test_driver/driver_play.dart --profile +# capture shaders (profile mode, real device only): +# % flutter drive --flavor play -t test_driver/driver_shaders.dart --profile --cache-sksl --write-sksl-on-exit shaders.sksl.json -# capture shaders in profile mode (real device only): -# % flutter drive --flavor play -t test_driver/driver_play.dart --profile --cache-sksl --write-sksl-on-exit shaders.sksl.json +# generate screenshots (profile mode, specific collection): +# % flutter drive --flavor play -t test_driver/driver_screenshots.dart --profile ################################################################################ # Adaptations diff --git a/scripts/screenshot_post_process.sh b/scripts/screenshot_post_process.sh new file mode 100755 index 000000000..45586f5de --- /dev/null +++ b/scripts/screenshot_post_process.sh @@ -0,0 +1,69 @@ +#!/bin/bash +# process raw screenshots from test driver to generate: +# - scaled down versions for IzzyOnDroid +# - framed versions for Google Play + +# expects: +# - ImageMagick 6 +# - raw screenshots sized at 1080x2280 in `/screenshots/raw` + +DEVICE_OVERLAY_LTR=~/code/aves_extra/screenshots/device_overlay_s10e_ltr.png +DEVICE_FRAME=~/code/aves_extra/screenshots/device_frame_s10e.png +# FRAME_SIZE: dimensions of DEVICE_FRAME +FRAME_SIZE=1142x2410 +# FRAME_OFFSET: offset for content in DEVICE_FRAME +FRAME_OFFSET=31x53 +# PLAY_SIZE: contain FRAME_SIZE in 9:16 +PLAY_SIZE=1356x2410 + +cd screenshots || exit + +# add Android system overlay +for source in raw/*/*; do + if [[ -f "$source" ]]; then + target=${source/raw/overlay} + echo "$source -> $target" + mkdir -p "$(dirname "$target")" + convert "$source" $DEVICE_OVERLAY_LTR -composite "$target" + fi +done + +# izzy: scale down + fastlane folder structure +for source in overlay/*/*; do + if [[ -f "$source" ]]; then + target=$(echo "$source" | sed -e 's/overlay\/\(.*\)\//izzy\/\1\/images\/phoneScreenshots\//g') + echo "$source -> $target" + mkdir -p "$(dirname "$target")" + convert -resize 350x "$source" "$target" + fi +done + +# play: add device frame +for source in overlay/*/*; do + if [[ -f "$source" ]]; then + target=${source/overlay/framed} + echo "$source -> $target" + mkdir -p "$(dirname "$target")" + convert "$source" -background transparent -splice $FRAME_OFFSET -extent $FRAME_SIZE $DEVICE_FRAME -composite "$target" + fi +done + +# play: fix aspect ratio +for source in framed/*/*; do + if [[ -f "$source" ]]; then + target=${source/framed/play} + echo "$source -> $target" + mkdir -p "$(dirname "$target")" + convert "$source" -gravity center -background transparent -extent $PLAY_SIZE "$target" + fi +done + +# readme: scale down +for source in framed/en/*; do + if [[ -f "$source" ]]; then + target=${source/framed/readme} + echo "$source -> $target" + mkdir -p "$(dirname "$target")" + convert -resize 250x "$source" "$target" + fi +done diff --git a/test/model/collection_source_test.dart b/test/model/collection_source_test.dart index 914c82ead..b641e886a 100644 --- a/test/model/collection_source_test.dart +++ b/test/model/collection_source_test.dart @@ -52,17 +52,17 @@ void main() { setUp(() async { // specify Posix style path context for consistent behaviour when running tests on Windows getIt.registerLazySingleton(() => p.Context(style: p.Style.posix)); - getIt.registerLazySingleton(() => FakeAvesAvailability()); - getIt.registerLazySingleton(() => FakeMetadataDb()); + getIt.registerLazySingleton(FakeAvesAvailability.new); + getIt.registerLazySingleton(FakeMetadataDb.new); - getIt.registerLazySingleton(() => FakeAndroidAppService()); - getIt.registerLazySingleton(() => FakeDeviceService()); - getIt.registerLazySingleton(() => FakeMediaFileService()); - getIt.registerLazySingleton(() => FakeMediaStoreService()); - getIt.registerLazySingleton(() => FakeMetadataFetchService()); - getIt.registerLazySingleton(() => FakeReportService()); - getIt.registerLazySingleton(() => FakeStorageService()); - getIt.registerLazySingleton(() => FakeWindowService()); + getIt.registerLazySingleton(FakeAndroidAppService.new); + getIt.registerLazySingleton(FakeDeviceService.new); + getIt.registerLazySingleton(FakeMediaFileService.new); + getIt.registerLazySingleton(FakeMediaStoreService.new); + getIt.registerLazySingleton(FakeMetadataFetchService.new); + getIt.registerLazySingleton(FakeReportService.new); + getIt.registerLazySingleton(FakeStorageService.new); + getIt.registerLazySingleton(FakeWindowService.new); await settings.init(monitorPlatformSettings: false); settings.canUseAnalysisService = false; diff --git a/test/model/video/metadata_test.dart b/test/model/video/metadata_test.dart index 469e0c574..0b8474f38 100644 --- a/test/model/video/metadata_test.dart +++ b/test/model/video/metadata_test.dart @@ -8,5 +8,6 @@ void main() { expect(VideoMetadataFormatter.parseVideoDate('2011-05-08T03:46+09:00'), DateTime(2011, 5, 7, 18, 46).add(localOffset).millisecondsSinceEpoch); expect(VideoMetadataFormatter.parseVideoDate('UTC 2021-05-30 19:14:21'), DateTime(2021, 5, 30, 19, 14, 21).millisecondsSinceEpoch); expect(VideoMetadataFormatter.parseVideoDate('2021/10/31 21:23:17'), DateTime(2021, 10, 31, 21, 23, 17).millisecondsSinceEpoch); + expect(VideoMetadataFormatter.parseVideoDate('2021-09-10T7:14:49 pmZ'), DateTime(2021, 9, 10, 19, 14, 49).millisecondsSinceEpoch); }); } diff --git a/test/utils/android_file_utils.dart b/test/utils/android_file_utils.dart index 9d05a79c8..581acb9ab 100644 --- a/test/utils/android_file_utils.dart +++ b/test/utils/android_file_utils.dart @@ -11,7 +11,7 @@ void main() { // specify Posix style path context for consistent behaviour when running tests on Windows getIt.registerLazySingleton(() => p.Context(style: p.Style.posix)); - getIt.registerLazySingleton(() => FakeStorageService()); + getIt.registerLazySingleton(FakeStorageService.new); await androidFileUtils.init(); }); diff --git a/test_driver/assets/aves_logo.svg b/test_driver/assets/shaders/aves_logo.svg similarity index 100% rename from test_driver/assets/aves_logo.svg rename to test_driver/assets/shaders/aves_logo.svg diff --git a/test_driver/assets/ipse.jpg b/test_driver/assets/shaders/ipse.jpg similarity index 100% rename from test_driver/assets/ipse.jpg rename to test_driver/assets/shaders/ipse.jpg diff --git a/test_driver/common_test.dart b/test_driver/common_test.dart new file mode 100644 index 000000000..704c0b3fd --- /dev/null +++ b/test_driver/common_test.dart @@ -0,0 +1,23 @@ +/* + This file is imported by driver test files. + It should not import, directly or indirectly, + `dart:ui`, `flutter/widgets.dart', etc. + */ + +const adbRoot = '/sdcard'; +const androidRoot = '/storage/emulated/0'; + +const shadersSourceDir = 'test_driver/assets/shaders/'; +const shadersTargetDirAdb = '$adbRoot/Pictures/Aves Test Driver/'; +const shadersTargetDirAndroid = '$androidRoot/Pictures/Aves Test Driver'; + +// Cover items should be: +// - dated in the future, +// - geotagged for each country to cover. +// Viewer items should be: +// - larger than screen, +// - located, +// - tagged (one tag only, so filter chips fit on one line). +const screenshotsSourceDir = 'test_driver/assets/screenshots/'; +const screenshotsTargetDirAdb = '$adbRoot/Pictures/TD/Aves/'; +const screenshotsTargetDirAndroid = '$androidRoot/Pictures/TD/Aves'; diff --git a/test_driver/constants.dart b/test_driver/constants.dart deleted file mode 100644 index 7454317a9..000000000 --- a/test_driver/constants.dart +++ /dev/null @@ -1,3 +0,0 @@ -const sourcePicturesDir = 'test_driver/assets/'; -const targetPicturesDir = '/sdcard/Pictures/Aves Test Driver/'; -const targetPicturesDirEmulated = '/storage/emulated/0/Pictures/Aves Test Driver'; diff --git a/test_driver/driver_play.dart b/test_driver/driver_play.dart deleted file mode 100644 index e4a357117..000000000 --- a/test_driver/driver_play.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'dart:ui'; - -import 'package:aves/main_play.dart' as app; -import 'package:aves/model/settings/enums.dart'; -import 'package:aves/model/settings/settings.dart'; -import 'package:aves/services/media/media_store_service.dart'; -import 'package:flutter_driver/driver_extension.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:path/path.dart' as p; - -import 'constants.dart'; - -void main() { - enableFlutterDriverExtension(); - - // scan files copied from test assets - // we do it via the app instead of broadcasting via ADB - // because `MEDIA_SCANNER_SCAN_FILE` intent got deprecated in API 29 - PlatformMediaStoreService() - ..scanFile(p.join(targetPicturesDir, 'aves_logo.svg'), 'image/svg+xml') - ..scanFile(p.join(targetPicturesDir, 'ipse.jpg'), 'image/jpeg'); - - // something like `configure().then((_) => app.main());` does not behave as expected - // and starts the app without waiting for `configure` to complete - configureAndLaunch(); -} - -Future configureAndLaunch() async { - await settings.init(monitorPlatformSettings: false); - settings - ..keepScreenOn = KeepScreenOn.always - ..hasAcceptedTerms = false - ..isErrorReportingAllowed = false - ..locale = const Locale('en') - ..homePage = HomePageSetting.collection - ..infoMapStyle = EntryMapStyle.googleNormal - ..imageBackground = EntryBackground.checkered; - - app.main(); -} diff --git a/test_driver/driver_screenshots.dart b/test_driver/driver_screenshots.dart new file mode 100644 index 000000000..652746749 --- /dev/null +++ b/test_driver/driver_screenshots.dart @@ -0,0 +1,45 @@ +import 'package:aves/main_play.dart' as app; +import 'package:aves/model/settings/defaults.dart'; +import 'package:aves/model/settings/enums.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/source/enums.dart'; +import 'package:aves/widgets/filter_grids/countries_page.dart'; +import 'package:flutter_driver/driver_extension.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() => configureAndLaunch(); + +Future configureAndLaunch() async { + enableFlutterDriverExtension(); + await settings.init(monitorPlatformSettings: false); + settings + // app + ..hasAcceptedTerms = true + ..isInstalledAppAccessAllowed = true + ..isErrorReportingAllowed = false + ..keepScreenOn = KeepScreenOn.always + ..homePage = HomePageSetting.collection + ..setTileExtent(CountryListPage.routeName, 112) + ..setTileLayout(CountryListPage.routeName, TileLayout.grid) + // collection + ..collectionSectionFactor = EntryGroupFactor.month + ..collectionSortFactor = EntrySortFactor.date + ..collectionBrowsingQuickActions = SettingsDefaults.collectionBrowsingQuickActions + ..showThumbnailFavourite = false + ..showThumbnailLocation = false + ..hiddenFilters = {} + // viewer + ..viewerQuickActions = SettingsDefaults.viewerQuickActions + ..showOverlayOnOpening = true + ..showOverlayMinimap = false + ..showOverlayInfo = true + ..showOverlayShootingDetails = false + ..enableOverlayBlurEffect = true + ..viewerUseCutout = true + // info + ..infoMapStyle = EntryMapStyle.stamenWatercolor + ..infoMapZoom = 13 + ..coordinateFormat = CoordinateFormat.dms + ..unitSystem = UnitSystem.metric; + app.main(); +} diff --git a/test_driver/driver_screenshots_test.dart b/test_driver/driver_screenshots_test.dart new file mode 100644 index 000000000..f07bbbd79 --- /dev/null +++ b/test_driver/driver_screenshots_test.dart @@ -0,0 +1,192 @@ +// ignore_for_file: avoid_print +import 'dart:async'; +import 'dart:io'; + +import 'package:aves/widgets/debug/app_debug_action.dart'; +import 'package:aves/widgets/settings/language/locales.dart'; +import 'package:flutter_driver/flutter_driver.dart'; +import 'package:test/test.dart'; + +import 'common_test.dart'; +import 'utils/adb_utils.dart'; +import 'utils/driver_extension.dart'; + +late FlutterDriver driver; +String _languageCode = ''; + +final languageCodes = SupportedLocales.languagesByLanguageCode.keys; +const outputDirectory = 'screenshots/raw'; + +void main() { + group('[Aves app]', () { + setUpAll(() async { + await Future.forEach(languageCodes, (languageCode) => Directory('$outputDirectory/$languageCode').create(recursive: true)); + + await copyContent(screenshotsSourceDir, screenshotsTargetDirAdb); + await Future.forEach( + [ + 'deckers.thibault.aves.debug', + 'deckers.thibault.aves.profile', + ], + (package) => grantPermissions(package, [ + 'android.permission.READ_EXTERNAL_STORAGE', + 'android.permission.ACCESS_MEDIA_LOCATION', + ])); + driver = await FlutterDriver.connect(); + }); + + tearDownAll(() async { + await removeDirectory(screenshotsTargetDirAdb); + unawaited(driver.close()); + }); + + test('scan media dir', () => driver.scanMediaDir(screenshotsTargetDirAndroid)); + + languageCodes.forEach((languageCode) { + setLanguage(languageCode); + configureCollectionVisibility(AppDebugAction.prepScreenshotThumbnails); + collection(); + configureCollectionVisibility(AppDebugAction.prepScreenshotStats); + viewer(); + info(); + stats(); + countries(); + }); + }, timeout: const Timeout(Duration(seconds: 30))); +} + +Future _search(String query, String chipKey) async { + await driver.tapKeyAndWait('menu-searchCollection'); + await driver.tap(find.byType('TextField')); + await driver.enterText(query); + final chip = find.byValueKey(chipKey); + await driver.waitFor(chip); + await driver.tap(chip); + await driver.waitUntilNoTransientCallbacks(); +} + +Future _takeScreenshot(FlutterDriver driver, String name) async { + final pixels = await driver.screenshot(); + final file = File('$outputDirectory/$_languageCode/$name.png'); + await file.writeAsBytes(pixels); + print('* saved screenshot to ${file.path}'); +} + +void setLanguage(String languageCode) { + test('set language', () async { + await driver.tapKeyAndWait('appbar-leading-button'); + await driver.tapKeyAndWait('drawer-settings-button'); + await driver.tapKeyAndWait('section-language'); + await driver.tapKeyAndWait('tile-language'); + await driver.tapKeyAndWait(languageCode); + _languageCode = languageCode; + + await pressDeviceBackButton(); + await driver.waitUntilNoTransientCallbacks(); + }); +} + +void configureCollectionVisibility(AppDebugAction action) { + test('configure collection visibility', () async { + await driver.tapKeyAndWait('appbar-leading-button'); + final verticalPageView = find.byValueKey('drawer-scrollview'); + await driver.scroll(verticalPageView, 0, -600, const Duration(milliseconds: 400)); + await driver.tapKeyAndWait('drawer-debug'); + + await driver.tapKeyAndWait('appbar-menu-button'); + await driver.tapKeyAndWait('menu-${action.name}'); + + await pressDeviceBackButton(); + await driver.waitUntilNoTransientCallbacks(); + }); +} + +void collection() { + test('1. Collection', () async { + await driver.tapKeyAndWait('appbar-leading-button'); + await driver.tapKeyAndWait('drawer-type-favourite'); + await _search('birds', 'tag-birds'); + await _search('South Korea', 'tag-South Korea'); + + await _takeScreenshot(driver, '1'); + }); +} + +void viewer() { + test('2. Viewer', () async { + await driver.tapKeyAndWait('appbar-leading-button'); + await driver.tapKeyAndWait('drawer-type-null'); + + await _search('viewer', 'album-$screenshotsTargetDirAndroid/viewer'); + + // delay to avoid flaky descendant resolution + await Future.delayed(const Duration(seconds: 2)); + await driver.tap(find.descendant( + of: find.byValueKey('collection-grid'), + matching: find.byType('MetaData'), + firstMatchOnly: true, + )); + await driver.waitUntilNoTransientCallbacks(); + await Future.delayed(const Duration(seconds: 2)); + + final imageView = find.byValueKey('image_view'); + await driver.doubleTap(imageView); + await Future.delayed(const Duration(seconds: 1)); + + await _takeScreenshot(driver, '2'); + }); +} + +void info() { + test('3. Info (basic), 4. Info (metadata)', () async { + final verticalPageView = find.byValueKey('vertical-pageview'); + + await driver.scroll(verticalPageView, 0, -600, const Duration(milliseconds: 400)); + // tiles may take time to load + await Future.delayed(const Duration(seconds: 5)); + + await _takeScreenshot(driver, '3'); + + await driver.scroll(verticalPageView, 0, -680, const Duration(milliseconds: 600)); + await Future.delayed(const Duration(seconds: 1)); + + final gpsTile = find.descendant( + of: find.byValueKey('tilecard-GPS'), + matching: find.byType('ListTile'), + ); + await driver.tap(gpsTile); + await driver.waitUntilNoTransientCallbacks(); + + await _takeScreenshot(driver, '4'); + + await pressDeviceBackButton(); + await driver.waitUntilNoTransientCallbacks(); + + await pressDeviceBackButton(); + await driver.waitUntilNoTransientCallbacks(); + }); +} + +void stats() { + test('5. Stats', () async { + await driver.tapKeyAndWait('appbar-leading-button'); + await driver.tapKeyAndWait('drawer-type-null'); + + await driver.tapKeyAndWait('appbar-menu-button'); + await driver.tapKeyAndWait('menu-stats'); + + await _takeScreenshot(driver, '5'); + + await pressDeviceBackButton(); + await driver.waitUntilNoTransientCallbacks(); + }); +} + +void countries() { + test('6. Countries', () async { + await driver.tapKeyAndWait('appbar-leading-button'); + await driver.tapKeyAndWait('drawer-page-/countries'); + + await _takeScreenshot(driver, '6'); + }); +} diff --git a/test_driver/driver_shaders.dart b/test_driver/driver_shaders.dart new file mode 100644 index 000000000..e8fe57ad6 --- /dev/null +++ b/test_driver/driver_shaders.dart @@ -0,0 +1,27 @@ +import 'dart:ui'; + +import 'package:aves/main_play.dart' as app; +import 'package:aves/model/settings/enums.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:flutter_driver/driver_extension.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() => configureAndLaunch(); + +Future configureAndLaunch() async { + enableFlutterDriverExtension(); + await settings.init(monitorPlatformSettings: false); + settings + // app + ..hasAcceptedTerms = false + ..isInstalledAppAccessAllowed = true + ..isErrorReportingAllowed = false + ..locale = const Locale('en') + ..keepScreenOn = KeepScreenOn.always + ..homePage = HomePageSetting.collection + // viewer + ..imageBackground = EntryBackground.checkered + // info + ..infoMapStyle = EntryMapStyle.googleNormal; + app.main(); +} diff --git a/test_driver/driver_play_test.dart b/test_driver/driver_shaders_test.dart similarity index 92% rename from test_driver/driver_play_test.dart rename to test_driver/driver_shaders_test.dart index 2ea5f80f5..710221e1c 100644 --- a/test_driver/driver_play_test.dart +++ b/test_driver/driver_shaders_test.dart @@ -6,36 +6,34 @@ import 'package:flutter_driver/flutter_driver.dart'; import 'package:path/path.dart' as p; import 'package:test/test.dart'; -import 'constants.dart'; +import 'common_test.dart'; import 'utils/adb_utils.dart'; import 'utils/driver_extension.dart'; late FlutterDriver driver; -extension ExtraFlutterDriver on FlutterDriver { - Future tapKeyAndWait(String key) async { - await driver.tap(find.byValueKey(key)); - await driver.waitUntilNoTransientCallbacks(); - } -} - void main() { group('[Aves app]', () { setUpAll(() async { - await copyContent(sourcePicturesDir, targetPicturesDir); - await grantPermissions('deckers.thibault.aves.debug', [ - 'android.permission.READ_EXTERNAL_STORAGE', - 'android.permission.WRITE_EXTERNAL_STORAGE', - 'android.permission.ACCESS_MEDIA_LOCATION', - ]); + await copyContent(shadersSourceDir, shadersTargetDirAdb); + await Future.forEach( + [ + 'deckers.thibault.aves.debug', + 'deckers.thibault.aves.profile', + ], + (package) => grantPermissions(package, [ + 'android.permission.READ_EXTERNAL_STORAGE', + 'android.permission.ACCESS_MEDIA_LOCATION', + ])); driver = await FlutterDriver.connect(); }); tearDownAll(() async { - await removeDirectory(targetPicturesDir); + await removeDirectory(shadersTargetDirAdb); unawaited(driver.close()); }); + test('scan media dir', () => driver.scanMediaDir(shadersTargetDirAndroid)); agreeToTerms(); visitAbout(); visitSettings(); @@ -162,7 +160,7 @@ void searchAlbum() { test('[collection] search album', () async { await driver.tapKeyAndWait('menu-searchCollection'); - const albumPath = targetPicturesDirEmulated; + const albumPath = shadersTargetDirAndroid; final albumDisplayName = p.split(albumPath).last; await driver.tap(find.byType('TextField')); await driver.enterText(albumDisplayName); diff --git a/test_driver/utils/driver_extension.dart b/test_driver/utils/driver_extension.dart index 3d70e7569..6b6c18b6b 100644 --- a/test_driver/utils/driver_extension.dart +++ b/test_driver/utils/driver_extension.dart @@ -1,5 +1,8 @@ +import 'package:aves/widgets/debug/app_debug_action.dart'; import 'package:flutter_driver/flutter_driver.dart'; +import 'adb_utils.dart'; + extension ExtraFlutterDriver on FlutterDriver { static const doubleTapDelay = Duration(milliseconds: 100); // in [kDoubleTapMinTime = 40 ms, kDoubleTapTimeout = 300 ms] @@ -8,4 +11,26 @@ extension ExtraFlutterDriver on FlutterDriver { await Future.delayed(doubleTapDelay); await tap(finder, timeout: timeout); } + + Future tapKeyAndWait(String key) async { + await tap(find.byValueKey(key)); + await waitUntilNoTransientCallbacks(); + } + + Future scanMediaDir(String dir) async { + await tapKeyAndWait('appbar-leading-button'); + await tapKeyAndWait('drawer-debug'); + + await tapKeyAndWait('appbar-menu-button'); + await tapKeyAndWait('menu-${AppDebugAction.mediaStoreScanDir.name}'); + + await tap(find.byType('TextField')); + await enterText(dir); + + await tap(find.byType('TextButton')); + await waitUntilNoTransientCallbacks(); + + await pressDeviceBackButton(); + await waitUntilNoTransientCallbacks(); + } } diff --git a/untranslated.json b/untranslated.json index 3b8f080bb..7a627567e 100644 --- a/untranslated.json +++ b/untranslated.json @@ -1,5 +1,12 @@ { - "ru": [ - "settingsThumbnailShowFavouriteIcon" + "es": [ + "entryInfoActionEditLocation", + "exportEntryDialogWidth", + "exportEntryDialogHeight", + "editEntryLocationDialogTitle", + "editEntryLocationDialogChooseOnMapTooltip", + "editEntryLocationDialogLatitude", + "editEntryLocationDialogLongitude", + "locationPickerUseThisLocationButton" ] } diff --git a/whatsnew/whatsnew-en-US b/whatsnew/whatsnew-en-US index 9394939f5..b12b61c06 100644 --- a/whatsnew/whatsnew-en-US +++ b/whatsnew/whatsnew-en-US @@ -1,6 +1,6 @@ Thanks for using Aves! -In v1.5.10: -- show, search and edit ratings -- add many items to favourites at once -- enjoy the app in Spanish +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 \ No newline at end of file