diff --git a/CHANGELOG.md b/CHANGELOG.md index f76ab7a9a..006314a32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,27 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [v1.5.10] - 2022-01-07 + +### Added + +- Collection: toggle favourites in bulk +- Info: edit ratings of JPG/GIF/PNG/TIFF images via XMP +- Info: edit date of GIF images via XMP +- Info: option to set date from other fields +- Spanish translation (thanks n-berenice) + +### Changed + +- editing an item orientation, rating or tags automatically sets a metadata date (from the file + modified date), if it is missing +- Viewer: when opening an item from another app, it is now possible to scroll to other items in the + album + +### Fixed + +- Exif and IPTC raw profile extraction from PNG in some cases + ## [v1.5.9] - 2021-12-22 ### Added @@ -37,7 +58,8 @@ All notable changes to this project will be documented in this file. ### Changed - Settings: select hidden path directory with a custom file picker instead of the native SAF one -- Viewer: video cover (before playing the video) is now loaded at original resolution and can be zoomed +- Viewer: video cover (before playing the video) is now loaded at original resolution and can be + zoomed ### Fixed @@ -75,7 +97,8 @@ All notable changes to this project will be documented in this file. ### Changed -- use build flavors to match distribution channels: `play` (same as original) and `izzy` (no Crashlytics) +- use build flavors to match distribution channels: `play` (same as original) and `izzy` (no + Crashlytics) - use 12/24 hour format settings from device to display times - Privacy: consent request on first launch for installed app inventory access - use File API to rename and delete items, when possible (primary storage, Android <11) diff --git a/README.md b/README.md index 78f1bc157..c4975eba2 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,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. +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. ### Donations diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DeviceHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DeviceHandler.kt index e9c9fbc26..be8ee35ee 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DeviceHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DeviceHandler.kt @@ -1,6 +1,7 @@ package deckers.thibault.aves.channel.calls import android.content.Context +import android.content.Intent import android.content.res.Resources import android.os.Build import androidx.core.content.pm.ShortcutManagerCompat @@ -18,6 +19,7 @@ class DeviceHandler(private val context: Context) : MethodCallHandler { "getDefaultTimeZone" -> safe(call, result, ::getDefaultTimeZone) "getLocales" -> safe(call, result, ::getLocales) "getPerformanceClass" -> safe(call, result, ::getPerformanceClass) + "isSystemFilePickerEnabled" -> safe(call, result, ::isSystemFilePickerEnabled) else -> result.notImplemented() } } @@ -34,7 +36,6 @@ class DeviceHandler(private val context: Context) : MethodCallHandler { // but using hybrid composition would make it usable on API 19 too, // cf https://github.com/flutter/flutter/issues/23728 "canRenderGoogleMaps" to (sdkInt >= Build.VERSION_CODES.KITKAT_WATCH), - "hasFilePicker" to (sdkInt >= Build.VERSION_CODES.KITKAT), "showPinShortcutFeedback" to (sdkInt >= Build.VERSION_CODES.O), "supportEdgeToEdgeUIMode" to (sdkInt >= Build.VERSION_CODES.Q), ) @@ -82,6 +83,15 @@ class DeviceHandler(private val context: Context) : MethodCallHandler { result.success(Build.VERSION.SDK_INT) } + private fun isSystemFilePickerEnabled(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { + val enabled = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).resolveActivity(context.packageManager) != null + } else { + false + } + result.success(enabled) + } + companion object { const val CHANNEL = "deckers.thibault/aves/device" } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataEditHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataEditHandler.kt index 9040083d5..adc3449f5 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataEditHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataEditHandler.kt @@ -20,8 +20,7 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler { "rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) } "flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) } "editDate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::editDate) } - "setIptc" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::setIptc) } - "setXmp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::setXmp) } + "editMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::editMetadata) } "removeTypes" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::removeTypes) } else -> result.notImplemented() } @@ -99,12 +98,11 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler { }) } - private fun setIptc(call: MethodCall, result: MethodChannel.Result) { - val iptc = call.argument>("iptc") + private fun editMetadata(call: MethodCall, result: MethodChannel.Result) { + val metadata = call.argument("metadata") val entryMap = call.argument("entry") - val postEditScan = call.argument("postEditScan") - if (entryMap == null || postEditScan == null) { - result.error("setIptc-args", "failed because of missing arguments", null) + if (entryMap == null || metadata == null) { + result.error("editMetadata-args", "failed because of missing arguments", null) return } @@ -112,48 +110,19 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler { val path = entryMap["path"] as String? val mimeType = entryMap["mimeType"] as String? if (uri == null || path == null || mimeType == null) { - result.error("setIptc-args", "failed because entry fields are missing", null) + result.error("editMetadata-args", "failed because entry fields are missing", null) return } val provider = getProvider(uri) if (provider == null) { - result.error("setIptc-provider", "failed to find provider for uri=$uri", null) + result.error("editMetadata-provider", "failed to find provider for uri=$uri", null) return } - provider.setIptc(activity, path, uri, mimeType, postEditScan, iptc = iptc, callback = object : ImageOpCallback { + provider.editMetadata(activity, path, uri, mimeType, metadata, callback = object : ImageOpCallback { override fun onSuccess(fields: FieldMap) = result.success(fields) - override fun onFailure(throwable: Throwable) = result.error("setIptc-failure", "failed to set IPTC for mimeType=$mimeType uri=$uri", throwable.message) - }) - } - - private fun setXmp(call: MethodCall, result: MethodChannel.Result) { - val xmp = call.argument("xmp") - val extendedXmp = call.argument("extendedXmp") - val entryMap = call.argument("entry") - if (entryMap == null) { - result.error("setXmp-args", "failed because of missing arguments", null) - return - } - - val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) } - val path = entryMap["path"] as String? - val mimeType = entryMap["mimeType"] as String? - if (uri == null || path == null || mimeType == null) { - result.error("setXmp-args", "failed because entry fields are missing", null) - return - } - - val provider = getProvider(uri) - if (provider == null) { - result.error("setXmp-provider", "failed to find provider for uri=$uri", null) - return - } - - provider.setXmp(activity, path, uri, mimeType, coreXmp = xmp, extendedXmp = extendedXmp, callback = object : ImageOpCallback { - override fun onSuccess(fields: FieldMap) = result.success(fields) - override fun onFailure(throwable: Throwable) = result.error("setXmp-failure", "failed to set XMP for mimeType=$mimeType uri=$uri", throwable.message) + override fun onFailure(throwable: Throwable) = result.error("editMetadata-failure", "failed to edit metadata for mimeType=$mimeType uri=$uri", throwable.message) }) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt index 29c52af31..b461e2b9f 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt @@ -19,7 +19,10 @@ import com.drew.lang.KeyValuePair import com.drew.lang.Rational import com.drew.metadata.Tag import com.drew.metadata.avi.AviDirectory -import com.drew.metadata.exif.* +import com.drew.metadata.exif.ExifDirectoryBase +import com.drew.metadata.exif.ExifIFD0Directory +import com.drew.metadata.exif.ExifSubIFDDirectory +import com.drew.metadata.exif.GpsDirectory import com.drew.metadata.file.FileTypeDirectory import com.drew.metadata.gif.GifAnimationDirectory import com.drew.metadata.iptc.IptcDirectory @@ -78,6 +81,7 @@ import java.nio.charset.Charset import java.nio.charset.StandardCharsets import java.text.ParseException import java.util.* +import kotlin.math.roundToInt import kotlin.math.roundToLong class MetadataFetchHandler(private val context: Context) : MethodCallHandler { @@ -92,6 +96,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { "getXmp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getXmp) } "hasContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::hasContentResolverProp) } "getContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverProp) } + "getDate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getDate) } else -> result.notImplemented() } } @@ -113,8 +118,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { try { Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> val metadata = ImageMetadataReader.readMetadata(input) - foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java) - foundXmp = metadata.containsDirectoryOfType(XmpDirectory::class.java) + foundExif = metadata.directories.any { it is ExifDirectoryBase && it.tagCount > 0 } + foundXmp = metadata.directories.any { it is XmpDirectory && it.tagCount > 0 } val uuidDirCount = HashMap() val dirByName = metadata.directories.filter { it.tagCount > 0 @@ -158,25 +163,16 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { // tags val tags = dir.tags - if (mimeType == MimeTypes.TIFF && (dir is ExifIFD0Directory || dir is ExifThumbnailDirectory)) { - fun tagMapper(it: Tag): Pair { - val name = if (it.hasTagName()) { - it.tagName - } else { - TiffTags.getTagName(it.tagType) ?: it.tagName - } - return Pair(name, it.description) - } - - if (dir is ExifIFD0Directory && dir.isGeoTiff()) { + if (dir is ExifDirectoryBase) { + if (dir.isGeoTiff()) { // split GeoTIFF tags in their own directory - val byGeoTiff = tags.groupBy { TiffTags.isGeoTiffTag(it.tagType) } + val byGeoTiff = tags.groupBy { ExifTags.isGeoTiffTag(it.tagType) } metadataMap["GeoTIFF"] = HashMap().apply { - byGeoTiff[true]?.map { tagMapper(it) }?.let { putAll(it) } + byGeoTiff[true]?.map { exifTagMapper(it) }?.let { putAll(it) } } - byGeoTiff[false]?.map { tagMapper(it) }?.let { dirMap.putAll(it) } + byGeoTiff[false]?.map { exifTagMapper(it) }?.let { dirMap.putAll(it) } } else { - dirMap.putAll(tags.map { tagMapper(it) }) + dirMap.putAll(tags.map { exifTagMapper(it) }) } } else if (dir.isPngTextDir()) { metadataMap.remove(thisDirName) @@ -205,10 +201,15 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { val dirs = extractPngProfile(key, valueString) if (dirs?.any() == true) { dirs.forEach { profileDir -> - val profileDirName = profileDir.name + val profileDirName = "${dir.name}/${profileDir.name}" val profileDirMap = metadataMap[profileDirName] ?: HashMap() metadataMap[profileDirName] = profileDirMap - profileDirMap.putAll(profileDir.tags.map { Pair(it.tagName, it.description) }) + val profileTags = profileDir.tags + if (profileDir is ExifDirectoryBase) { + profileDirMap.putAll(profileTags.map { exifTagMapper(it) }) + } else { + profileDirMap.putAll(profileTags.map { Pair(it.tagName, it.description) }) + } } null } else { @@ -357,22 +358,22 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { return dirMap } - // legend: ME=MetadataExtractor, EI=ExifInterface, MMR=MediaMetadataRetriever // set `KEY_DATE_MILLIS` from these fields (by precedence): - // - ME / Exif / DATETIME_ORIGINAL - // - ME / Exif / DATETIME - // - EI / Exif / DATETIME_ORIGINAL - // - EI / Exif / DATETIME - // - ME / XMP / xmp:CreateDate - // - ME / XMP / photoshop:DateCreated - // - ME / PNG / TIME / LAST_MODIFICATION_TIME - // - MMR / METADATA_KEY_DATE + // - Exif / DATETIME_ORIGINAL + // - Exif / DATETIME + // - XMP / xmp:CreateDate + // - XMP / photoshop:DateCreated + // - PNG / TIME / LAST_MODIFICATION_TIME + // - Video / METADATA_KEY_DATE // set `KEY_XMP_TITLE_DESCRIPTION` from these fields (by precedence): - // - ME / XMP / dc:title - // - ME / XMP / dc:description + // - XMP / dc:title + // - XMP / dc:description // set `KEY_XMP_SUBJECTS` from these fields (by precedence): - // - ME / XMP / dc:subject - // - ME / IPTC / keywords + // - XMP / dc:subject + // - IPTC / keywords + // set `KEY_RATING` from these fields (by precedence): + // - XMP / xmp:Rating + // - XMP / MicrosoftPhoto:Rating private fun getCatalogMetadata(call: MethodCall, result: MethodChannel.Result) { val mimeType = call.argument("mimeType") val uri = call.argument("uri")?.let { Uri.parse(it) } @@ -407,7 +408,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { try { Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> val metadata = ImageMetadataReader.readMetadata(input) - foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java) + foundExif = metadata.directories.any { it is ExifDirectoryBase && it.tagCount > 0 } // File type for (dir in metadata.getDirectoriesOfType(FileTypeDirectory::class.java)) { @@ -432,13 +433,13 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { // EXIF for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) { - dir.getSafeDateMillis(ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL) { metadataMap[KEY_DATE_MILLIS] = it } + dir.getSafeDateMillis(ExifDirectoryBase.TAG_DATETIME_ORIGINAL) { metadataMap[KEY_DATE_MILLIS] = it } } for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) { if (!metadataMap.containsKey(KEY_DATE_MILLIS)) { - dir.getSafeDateMillis(ExifIFD0Directory.TAG_DATETIME) { metadataMap[KEY_DATE_MILLIS] = it } + dir.getSafeDateMillis(ExifDirectoryBase.TAG_DATETIME) { metadataMap[KEY_DATE_MILLIS] = it } } - dir.getSafeInt(ExifIFD0Directory.TAG_ORIENTATION) { + dir.getSafeInt(ExifDirectoryBase.TAG_ORIENTATION) { val orientation = it if (isFlippedForExifCode(orientation)) flags = flags or MASK_IS_FLIPPED metadataMap[KEY_ROTATION_DEGREES] = getRotationDegreesForExifCode(orientation) @@ -458,22 +459,31 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) { val xmpMeta = dir.xmpMeta try { - if (xmpMeta.doesPropertyExist(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME)) { - val count = xmpMeta.countArrayItems(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME) - val values = (1 until count + 1).map { xmpMeta.getArrayItem(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME, it).value } + if (xmpMeta.doesPropertyExist(XMP.DC_SCHEMA_NS, XMP.DC_SUBJECT_PROP_NAME)) { + val count = xmpMeta.countArrayItems(XMP.DC_SCHEMA_NS, XMP.DC_SUBJECT_PROP_NAME) + val values = (1 until count + 1).map { xmpMeta.getArrayItem(XMP.DC_SCHEMA_NS, XMP.DC_SUBJECT_PROP_NAME, it).value } metadataMap[KEY_XMP_SUBJECTS] = values.joinToString(XMP_SUBJECTS_SEPARATOR) } - xmpMeta.getSafeLocalizedText(XMP.DC_SCHEMA_NS, XMP.TITLE_PROP_NAME, acceptBlank = false) { metadataMap[KEY_XMP_TITLE_DESCRIPTION] = it } + xmpMeta.getSafeLocalizedText(XMP.DC_SCHEMA_NS, XMP.DC_TITLE_PROP_NAME, acceptBlank = false) { metadataMap[KEY_XMP_TITLE_DESCRIPTION] = it } if (!metadataMap.containsKey(KEY_XMP_TITLE_DESCRIPTION)) { - xmpMeta.getSafeLocalizedText(XMP.DC_SCHEMA_NS, XMP.DESCRIPTION_PROP_NAME, acceptBlank = false) { metadataMap[KEY_XMP_TITLE_DESCRIPTION] = it } + xmpMeta.getSafeLocalizedText(XMP.DC_SCHEMA_NS, XMP.DC_DESCRIPTION_PROP_NAME, acceptBlank = false) { metadataMap[KEY_XMP_TITLE_DESCRIPTION] = it } } if (!metadataMap.containsKey(KEY_DATE_MILLIS)) { - xmpMeta.getSafeDateMillis(XMP.XMP_SCHEMA_NS, XMP.CREATE_DATE_PROP_NAME) { metadataMap[KEY_DATE_MILLIS] = it } + xmpMeta.getSafeDateMillis(XMP.XMP_SCHEMA_NS, XMP.XMP_CREATE_DATE_PROP_NAME) { metadataMap[KEY_DATE_MILLIS] = it } if (!metadataMap.containsKey(KEY_DATE_MILLIS)) { xmpMeta.getSafeDateMillis(XMP.PHOTOSHOP_SCHEMA_NS, XMP.PS_DATE_CREATED_PROP_NAME) { metadataMap[KEY_DATE_MILLIS] = it } } } + xmpMeta.getSafeInt(XMP.XMP_SCHEMA_NS, XMP.XMP_RATING_PROP_NAME) { metadataMap[KEY_RATING] = it } + if (!metadataMap.containsKey(KEY_RATING)) { + xmpMeta.getSafeInt(XMP.MICROSOFTPHOTO_SCHEMA_NS, XMP.MS_RATING_PROP_NAME) { percentRating -> + // values of 1,25,50,75,99% correspond to 1,2,3,4,5 stars + val standardRating = (percentRating / 25f).roundToInt() + 1 + metadataMap[KEY_RATING] = standardRating + } + } + // identification of panorama (aka photo sphere) if (xmpMeta.isPanorama()) { flags = flags or MASK_IS_360 @@ -659,10 +669,10 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { val metadata = ImageMetadataReader.readMetadata(input) for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) { foundExif = true - dir.getSafeRational(ExifSubIFDDirectory.TAG_FNUMBER) { metadataMap[KEY_APERTURE] = it.numerator.toDouble() / it.denominator } - dir.getSafeRational(ExifSubIFDDirectory.TAG_EXPOSURE_TIME, saveExposureTime) - dir.getSafeRational(ExifSubIFDDirectory.TAG_FOCAL_LENGTH) { metadataMap[KEY_FOCAL_LENGTH] = it.numerator.toDouble() / it.denominator } - dir.getSafeInt(ExifSubIFDDirectory.TAG_ISO_EQUIVALENT) { metadataMap[KEY_ISO] = it } + dir.getSafeRational(ExifDirectoryBase.TAG_FNUMBER) { metadataMap[KEY_APERTURE] = it.numerator.toDouble() / it.denominator } + dir.getSafeRational(ExifDirectoryBase.TAG_EXPOSURE_TIME, saveExposureTime) + dir.getSafeRational(ExifDirectoryBase.TAG_FOCAL_LENGTH) { metadataMap[KEY_FOCAL_LENGTH] = it.numerator.toDouble() / it.denominator } + dir.getSafeInt(ExifDirectoryBase.TAG_ISO_EQUIVALENT) { metadataMap[KEY_ISO] = it } } } } catch (e: Exception) { @@ -876,6 +886,57 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { result.success(value?.toString()) } + private fun getDate(call: MethodCall, result: MethodChannel.Result) { + val mimeType = call.argument("mimeType") + val uri = call.argument("uri")?.let { Uri.parse(it) } + val sizeBytes = call.argument("sizeBytes")?.toLong() + val field = call.argument("field") + if (mimeType == null || uri == null || field == null) { + result.error("getDate-args", "failed because of missing arguments", null) + return + } + + var dateMillis: Long? = null + if (canReadWithMetadataExtractor(mimeType)) { + try { + Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> + val metadata = ImageMetadataReader.readMetadata(input) + val tag = when (field) { + ExifInterface.TAG_DATETIME -> ExifDirectoryBase.TAG_DATETIME + ExifInterface.TAG_DATETIME_DIGITIZED -> ExifDirectoryBase.TAG_DATETIME_DIGITIZED + ExifInterface.TAG_DATETIME_ORIGINAL -> ExifDirectoryBase.TAG_DATETIME_ORIGINAL + ExifInterface.TAG_GPS_DATESTAMP -> GpsDirectory.TAG_DATE_STAMP + else -> { + result.error("getDate-field", "unsupported ExifInterface field=$field", null) + return + } + } + + when (tag) { + ExifDirectoryBase.TAG_DATETIME, + ExifDirectoryBase.TAG_DATETIME_DIGITIZED, + ExifDirectoryBase.TAG_DATETIME_ORIGINAL -> { + for (dir in metadata.getDirectoriesOfType(ExifDirectoryBase::class.java)) { + dir.getSafeDateMillis(tag) { dateMillis = it } + } + } + GpsDirectory.TAG_DATE_STAMP -> { + for (dir in metadata.getDirectoriesOfType(GpsDirectory::class.java)) { + dir.gpsDate?.let { dateMillis = it.time } + } + } + } + } + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e) + } catch (e: NoClassDefFoundError) { + Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e) + } + } + + result.success(dateMillis) + } + companion object { private val LOG_TAG = LogUtils.createTag() const val CHANNEL = "deckers.thibault/aves/metadata_fetch" @@ -905,6 +966,15 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { omitXmpMetaElement = false // e.g. ... } + private fun exifTagMapper(it: Tag): Pair { + val name = if (it.hasTagName()) { + it.tagName + } else { + ExifTags.getTagName(it.tagType) ?: it.tagName + } + return Pair(name, it.description) + } + // catalog metadata private const val KEY_MIME_TYPE = "mimeType" private const val KEY_DATE_MILLIS = "dateMillis" @@ -914,6 +984,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { private const val KEY_LONGITUDE = "longitude" private const val KEY_XMP_SUBJECTS = "xmpSubjects" private const val KEY_XMP_TITLE_DESCRIPTION = "xmpTitleDescription" + private const val KEY_RATING = "rating" private const val MASK_IS_ANIMATED = 1 shl 0 private const val MASK_IS_FLIPPED = 1 shl 1 diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt index 8970a02d9..6d134fae4 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt @@ -170,7 +170,7 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any? MainActivity.pendingStorageAccessResultHandlers[MainActivity.OPEN_FILE_REQUEST] = PendingStorageAccessResultHandler(null, ::onGranted, ::onDenied) activity.startActivityForResult(intent, MainActivity.OPEN_FILE_REQUEST) } else { - MainActivity.notifyError("failed to resolve activity for intent=$intent") + MainActivity.notifyError("failed to resolve activity for intent=$intent extras=${intent.extras}") onDenied() } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/TiffTags.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifTags.kt similarity index 94% rename from android/app/src/main/kotlin/deckers/thibault/aves/metadata/TiffTags.kt rename to android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifTags.kt index 2f8a9c23c..4dbe77450 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/TiffTags.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifTags.kt @@ -1,6 +1,7 @@ package deckers.thibault.aves.metadata -object TiffTags { +// Exif tags missing from `metadata-extractor` +object ExifTags { // XPosition // Tag = 286 (011E.H) private const val TAG_X_POSITION = 0x011e @@ -32,6 +33,12 @@ object TiffTags { // SAMPLEFORMAT_COMPLEXIEEEFP 6 // complex ieee floating private const val TAG_SAMPLE_FORMAT = 0x0153 + + // Rating tag used by Windows, value in percent + // Tag = 18249 (4749.H) + // Type = SHORT + private const val TAG_RATING_PERCENT = 0x4749 + /* SGI tags 32995-32999 @@ -125,6 +132,7 @@ object TiffTags { TAG_COLOR_MAP to "Color Map", TAG_EXTRA_SAMPLES to "Extra Samples", TAG_SAMPLE_FORMAT to "Sample Format", + TAG_RATING_PERCENT to "Rating Percent", // SGI TAG_MATTEING to "Matteing", // GeoTIFF 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 5c84e153b..84d5959f2 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 @@ -1,25 +1,35 @@ package deckers.thibault.aves.metadata +import android.util.Log +import com.drew.lang.ByteArrayReader 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 +import deckers.thibault.aves.utils.LogUtils import java.text.SimpleDateFormat import java.util.* object MetadataExtractorHelper { + private val LOG_TAG = LogUtils.createTag() + const val PNG_ITXT_DIR_NAME = "PNG-iTXt" private const val PNG_TEXT_DIR_NAME = "PNG-tEXt" const val PNG_TIME_DIR_NAME = "PNG-tIME" private const val PNG_ZTXT_DIR_NAME = "PNG-zTXt" + private const val PNG_RAW_PROFILE_EXIF = "Raw profile type exif" + private const val PNG_RAW_PROFILE_IPTC = "Raw profile type iptc" val PNG_LAST_MODIFICATION_TIME_FORMAT = SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.ROOT) // Pattern to extract profile name, length, and text data // of raw profiles (EXIF, IPTC, etc.) in PNG `zTXt` chunks // e.g. "iptc [...] 114 [...] 3842494d040400[...]" + // e.g. "exif [...] 134 [...] 4578696600004949[...]" private val PNG_RAW_PROFILE_PATTERN = Regex("^\\n(.*?)\\n\\s*(\\d+)\\n(.*)", RegexOption.DOT_MATCHES_ALL) // extensions @@ -59,14 +69,14 @@ object MetadataExtractorHelper { - If the ModelTransformationTag is included in an IFD, then a ModelPixelScaleTag SHALL NOT be included - If the ModelPixelScaleTag is included in an IFD, then a ModelTiepointTag SHALL also be included. */ - fun ExifIFD0Directory.isGeoTiff(): Boolean { - if (!this.containsTag(TiffTags.TAG_GEO_KEY_DIRECTORY)) return false + fun ExifDirectoryBase.isGeoTiff(): Boolean { + if (!this.containsTag(ExifTags.TAG_GEO_KEY_DIRECTORY)) return false - val modelTiepoint = this.containsTag(TiffTags.TAG_MODEL_TIEPOINT) - val modelTransformation = this.containsTag(TiffTags.TAG_MODEL_TRANSFORMATION) + val modelTiepoint = this.containsTag(ExifTags.TAG_MODEL_TIEPOINT) + val modelTransformation = this.containsTag(ExifTags.TAG_MODEL_TRANSFORMATION) if (!modelTiepoint && !modelTransformation) return false - val modelPixelScale = this.containsTag(TiffTags.TAG_MODEL_PIXEL_SCALE) + val modelPixelScale = this.containsTag(ExifTags.TAG_MODEL_PIXEL_SCALE) if ((modelTransformation && modelPixelScale) || (modelPixelScale && !modelTiepoint)) return false return true @@ -77,22 +87,29 @@ object MetadataExtractorHelper { fun Directory.isPngTextDir(): Boolean = this is PngDirectory && setOf(PNG_ITXT_DIR_NAME, PNG_TEXT_DIR_NAME, PNG_ZTXT_DIR_NAME).contains(this.name) fun extractPngProfile(key: String, valueString: String): Iterable? { - when (key) { - "Raw profile type iptc" -> { - val match = PNG_RAW_PROFILE_PATTERN.matchEntire(valueString) - if (match != null) { - val dataString = match.groupValues[3] - val hexString = dataString.replace(Regex("[\\r\\n]"), "") - val dataBytes = hexStringToByteArray(hexString) - if (dataBytes != null) { - val start = dataBytes.indexOf(Metadata.IPTC_MARKER_BYTE) - if (start != -1) { - val segmentBytes = dataBytes.copyOfRange(fromIndex = start, toIndex = dataBytes.size - start) - val metadata = com.drew.metadata.Metadata() - IptcReader().extract(SequentialByteArrayReader(segmentBytes), metadata, segmentBytes.size.toLong()) - return metadata.directories + if (key == PNG_RAW_PROFILE_EXIF || key == PNG_RAW_PROFILE_IPTC) { + val match = PNG_RAW_PROFILE_PATTERN.matchEntire(valueString) + if (match != null) { + val dataString = match.groupValues[3] + val hexString = dataString.replace(Regex("[\\r\\n]"), "") + val dataBytes = hexString.decodeHex() + if (dataBytes != null) { + val metadata = com.drew.metadata.Metadata() + when (key) { + PNG_RAW_PROFILE_EXIF -> { + if (ExifReader.startsWithJpegExifPreamble(dataBytes)) { + ExifReader().extract(ByteArrayReader(dataBytes), metadata, ExifReader.JPEG_SEGMENT_PREAMBLE.length) + } + } + PNG_RAW_PROFILE_IPTC -> { + val start = dataBytes.indexOf(Metadata.IPTC_MARKER_BYTE) + if (start != -1) { + val segmentBytes = dataBytes.copyOfRange(fromIndex = start, toIndex = dataBytes.size) + IptcReader().extract(SequentialByteArrayReader(segmentBytes), metadata, segmentBytes.size.toLong()) + } } } + return metadata.directories } } } @@ -101,15 +118,18 @@ object MetadataExtractorHelper { // convenience methods - private fun hexStringToByteArray(hexString: String): ByteArray? { - if (hexString.length % 2 != 0) return null + private fun String.decodeHex(): ByteArray? { + if (length % 2 != 0) return null - val dataBytes = ByteArray(hexString.length / 2) - var i = 0 - while (i < hexString.length) { - dataBytes[i / 2] = hexString.substring(i, i + 2).toByte(16) - i += 2 + try { + val byteIterator = chunkedSequence(2) + .map { it.toInt(16).toByte() } + .iterator() + + return ByteArray(length / 2) { byteIterator.next() } + } catch (e: NumberFormatException) { + Log.w(LOG_TAG, "failed to decode hex string=$this", e) } - return dataBytes + return null } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt index 72c38f447..2819915e8 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt @@ -15,6 +15,7 @@ object XMP { // standard namespaces // cf com.adobe.internal.xmp.XMPConst const val DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/" + const val MICROSOFTPHOTO_SCHEMA_NS = "http://ns.microsoft.com/photo/1.0/" const val PHOTOSHOP_SCHEMA_NS = "http://ns.adobe.com/photoshop/1.0/" const val XMP_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/" private const val XMP_GIMG_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/g/img/" @@ -27,11 +28,13 @@ object XMP { const val CONTAINER_SCHEMA_NS = "http://ns.google.com/photos/1.0/container/" private const val CONTAINER_ITEM_SCHEMA_NS = "http://ns.google.com/photos/1.0/container/item/" - const val SUBJECT_PROP_NAME = "dc:subject" - const val TITLE_PROP_NAME = "dc:title" - const val DESCRIPTION_PROP_NAME = "dc:description" + const val DC_DESCRIPTION_PROP_NAME = "dc:description" + const val DC_SUBJECT_PROP_NAME = "dc:subject" + const val DC_TITLE_PROP_NAME = "dc:title" + const val MS_RATING_PROP_NAME = "MicrosoftPhoto:Rating" const val PS_DATE_CREATED_PROP_NAME = "photoshop:DateCreated" - const val CREATE_DATE_PROP_NAME = "xmp:CreateDate" + const val XMP_CREATE_DATE_PROP_NAME = "xmp:CreateDate" + const val XMP_RATING_PROP_NAME = "xmp:Rating" private const val GENERIC_LANG = "" private const val SPECIFIC_LANG = "en-US" 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 1e04ca4ac..9ef5a9112 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 @@ -800,63 +800,47 @@ abstract class ImageProvider { } } - fun setIptc( + fun editMetadata( context: Context, path: String, uri: Uri, mimeType: String, - postEditScan: Boolean, + modifier: FieldMap, callback: ImageOpCallback, - iptc: List? = null, ) { - val newFields = HashMap() + if (modifier.containsKey("iptc")) { + val iptc = (modifier["iptc"] as List<*>?)?.filterIsInstance() + if (!editIptc( + context = context, + path = path, + uri = uri, + mimeType = mimeType, + callback = callback, + iptc = iptc, + ) + ) return + } - val success = editIptc( - context = context, - path = path, - uri = uri, - mimeType = mimeType, - callback = callback, - iptc = iptc, - ) - - if (success) { - if (postEditScan) { - scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback) - } else { - callback.onSuccess(HashMap()) + if (modifier.containsKey("xmp")) { + val xmp = modifier["xmp"] as Map<*, *>? + if (xmp != null) { + val coreXmp = xmp["xmp"] as String? + val extendedXmp = xmp["extendedXmp"] as String? + if (!editXmp( + context = context, + path = path, + uri = uri, + mimeType = mimeType, + callback = callback, + coreXmp = coreXmp, + extendedXmp = extendedXmp, + ) + ) return } - } else { - callback.onFailure(Exception("failed to set IPTC")) } - } - fun setXmp( - context: Context, - path: String, - uri: Uri, - mimeType: String, - callback: ImageOpCallback, - coreXmp: String? = null, - extendedXmp: String? = null, - ) { val newFields = HashMap() - - val success = editXmp( - context = context, - path = path, - uri = uri, - mimeType = mimeType, - callback = callback, - coreXmp = coreXmp, - extendedXmp = extendedXmp, - ) - - if (success) { - scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback) - } else { - callback.onFailure(Exception("failed to set XMP")) - } + scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback) } fun removeMetadataTypes( diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt index e77a1648e..02eeaf485 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt @@ -55,7 +55,7 @@ object PermissionManager { MainActivity.pendingStorageAccessResultHandlers[MainActivity.DOCUMENT_TREE_ACCESS_REQUEST] = PendingStorageAccessResultHandler(path, onGranted, onDenied) activity.startActivityForResult(intent, MainActivity.DOCUMENT_TREE_ACCESS_REQUEST) } else { - MainActivity.notifyError("failed to resolve activity for intent=$intent") + MainActivity.notifyError("failed to resolve activity for intent=$intent extras=${intent.extras}") onDenied() } } diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml new file mode 100644 index 000000000..aadf5be08 --- /dev/null +++ b/android/app/src/main/res/values-es/strings.xml @@ -0,0 +1,10 @@ + + + Aves + Búsqueda + Videos + Explorar medios + Explorar imágenes & videos + Explorando medios + Anular + \ No newline at end of file diff --git a/fastlane/metadata/android/de/images/featureGraphic.png b/fastlane/metadata/android/de/images/featureGraphic.png new file mode 100644 index 000000000..a0b3a3e77 Binary files /dev/null and b/fastlane/metadata/android/de/images/featureGraphic.png differ diff --git a/fastlane/metadata/android/es-MX/full_description.txt b/fastlane/metadata/android/es-MX/full_description.txt new file mode 100644 index 000000000..5015acfec --- /dev/null +++ b/fastlane/metadata/android/es-MX/full_description.txt @@ -0,0 +1,5 @@ +Aves puede manejar todo tipo de imágenes y videos, incluyendo los típicos JPEG y MP4, pero además cosas mas exóticas como TIFF multipágina, SVG, viejos AVI y más! Inspecciona su colección multimedia para identificar fotos en movimiento, panoramas (conocidas como fotos esféricas), videos en 360° y también archivos GeoTIFF. + +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 diff --git a/fastlane/metadata/android/es-MX/images/featureGraphic.png b/fastlane/metadata/android/es-MX/images/featureGraphic.png new file mode 100644 index 000000000..6bcf1d781 Binary files /dev/null and b/fastlane/metadata/android/es-MX/images/featureGraphic.png differ diff --git a/fastlane/metadata/android/es-MX/short_description.txt b/fastlane/metadata/android/es-MX/short_description.txt new file mode 100644 index 000000000..03b729cdc --- /dev/null +++ b/fastlane/metadata/android/es-MX/short_description.txt @@ -0,0 +1 @@ +Galería y visor de metadatos \ No newline at end of file diff --git a/lib/image_providers/region_provider.dart b/lib/image_providers/region_provider.dart index e583467f7..97faccc96 100644 --- a/lib/image_providers/region_provider.dart +++ b/lib/image_providers/region_provider.dart @@ -49,6 +49,7 @@ class RegionProvider extends ImageProvider { } return await decode(bytes); } catch (error) { + // loading may fail if the provided MIME type is incorrect (e.g. the Media Store may report a JPEG as a TIFF) debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$error'); throw StateError('$mimeType region decoding failed (page $pageId)'); } diff --git a/lib/image_providers/thumbnail_provider.dart b/lib/image_providers/thumbnail_provider.dart index 57fe937bd..3c5b5699c 100644 --- a/lib/image_providers/thumbnail_provider.dart +++ b/lib/image_providers/thumbnail_provider.dart @@ -50,7 +50,8 @@ class ThumbnailProvider extends ImageProvider { } return await decode(bytes); } catch (error) { - debugPrint('$runtimeType _loadAsync failed with uri=$uri, error=$error'); + // loading may fail if the provided MIME type is incorrect (e.g. the Media Store may report a JPEG as a TIFF) + debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$error'); throw StateError('$mimeType decoding failed (page $pageId)'); } } diff --git a/lib/image_providers/uri_image_provider.dart b/lib/image_providers/uri_image_provider.dart index 416d407f1..1aeca959e 100644 --- a/lib/image_providers/uri_image_provider.dart +++ b/lib/image_providers/uri_image_provider.dart @@ -68,6 +68,7 @@ class UriImage extends ImageProvider with EquatableMixin { } return await decode(bytes); } catch (error) { + // loading may fail if the provided MIME type is incorrect (e.g. the Media Store may report a JPEG as a TIFF) debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$error'); throw StateError('$mimeType decoding failed (page $pageId)'); } finally { diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 8a61c59b7..867ecab0d 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -22,7 +22,7 @@ "nextTooltip": "Nächste", "showTooltip": "Anzeigen", "hideTooltip": "Ausblenden", - "removeTooltip": "Entfernen", + "actionRemove": "Entfernen", "resetButtonTooltip": "Zurücksetzen", "doubleBackExitMessage": "Tippen Sie zum Verlassen erneut auf „Zurück“.", @@ -73,12 +73,15 @@ "videoActionSettings": "Einstellungen", "entryInfoActionEditDate": "Datum & Uhrzeit bearbeiten", + "entryInfoActionEditRating": "Bewertung bearbeiten", "entryInfoActionEditTags": "Tags bearbeiten", "entryInfoActionRemoveMetadata": "Metadaten entfernen", "filterFavouriteLabel": "Favorit", "filterLocationEmptyLabel": "Ungeortet", "filterTagEmptyLabel": "Unmarkiert", + "filterRatingUnratedLabel": "Nicht bewertet", + "filterRatingRejectedLabel": "Verworfen", "filterTypeAnimatedLabel": "Animationen", "filterTypeMotionPhotoLabel": "Bewegtes Foto", "filterTypePanoramaLabel": "Panorama", @@ -137,6 +140,8 @@ "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.", "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.", "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}.}}", @@ -178,14 +183,17 @@ "renameEntryDialogLabel": "Neuer Name", "editEntryDateDialogTitle": "Datum & Uhrzeit", - "editEntryDateDialogSet": "Festlegen", - "editEntryDateDialogShift": "Verschieben", + "editEntryDateDialogSetCustom": "Datum einstellen", + "editEntryDateDialogCopyField": "Von anderem Datum kopieren", "editEntryDateDialogExtractFromTitle": "Auszug aus dem Titel", - "editEntryDateDialogClear": "Aufräumen", - "editEntryDateDialogFieldSelection": "Feldauswahl", + "editEntryDateDialogShift": "Verschieben", + "editEntryDateDialogSourceFileModifiedDate": "Änderungsdatum der Datei", + "editEntryDateDialogTargetFieldsHeader": "Zu ändernde Felder", "editEntryDateDialogHours": "Stunden", "editEntryDateDialogMinutes": "Minuten", + "editEntryRatingDialogTitle": "Bewertung", + "removeEntryMetadataDialogTitle": "Entfernung von Metadaten", "removeEntryMetadataDialogMore": "Mehr", @@ -270,6 +278,7 @@ "collectionSortDate": "Nach Datum", "collectionSortSize": "Nach Größe", "collectionSortName": "Nach Album & Dateiname", + "collectionSortRating": "Nach Bewertung", "collectionGroupAlbum": "Nach Album", "collectionGroupMonth": "Nach Monat", @@ -343,6 +352,7 @@ "searchSectionCountries": "Länder", "searchSectionPlaces": "Orte", "searchSectionTags": "Tags", + "searchSectionRating": "Bewertungen", "settingsPageTitle": "Einstellungen", "settingsSystemDefault": "System", @@ -366,8 +376,10 @@ "settingsNavigationDrawerAddAlbum": "Album hinzufügen", "settingsSectionThumbnails": "Vorschaubilder", + "settingsThumbnailShowFavouriteIcon": "Favoriten-Symbol anzeigen", "settingsThumbnailShowLocationIcon": "Standort-Symbol anzeigen", "settingsThumbnailShowMotionPhotoIcon": "Bewegungsfoto-Symbol anzeigen", + "settingsThumbnailShowRating": "Bewertung anzeigen", "settingsThumbnailShowRawIcon": "Rohdaten-Symbol anzeigen", "settingsThumbnailShowVideoDuration": "Videodauer anzeigen", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 53c770b61..a4b3ac6a8 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -37,7 +37,7 @@ "nextTooltip": "Next", "showTooltip": "Show", "hideTooltip": "Hide", - "removeTooltip": "Remove", + "actionRemove": "Remove", "resetButtonTooltip": "Reset", "doubleBackExitMessage": "Tap “back” again to exit.", @@ -88,12 +88,15 @@ "videoActionSettings": "Settings", "entryInfoActionEditDate": "Edit date & time", + "entryInfoActionEditRating": "Edit rating", "entryInfoActionEditTags": "Edit tags", "entryInfoActionRemoveMetadata": "Remove metadata", "filterFavouriteLabel": "Favourite", "filterLocationEmptyLabel": "Unlocated", "filterTagEmptyLabel": "Untagged", + "filterRatingUnratedLabel": "Unrated", + "filterRatingRejectedLabel": "Rejected", "filterTypeAnimatedLabel": "Animated", "filterTypeMotionPhotoLabel": "Motion Photo", "filterTypePanoramaLabel": "Panorama", @@ -109,10 +112,12 @@ "@coordinateDms": { "placeholders": { "coordinate": { - "type": "String" + "type": "String", + "example": "38° 41′ 47.72″" }, "direction": { - "type": "String" + "type": "String", + "example": "S" } } }, @@ -159,7 +164,9 @@ "@otherDirectoryDescription": { "placeholders": { "name": { - "type": "String" + "type": "String", + "example": "Pictures", + "description": "the name of a specific directory" } } }, @@ -168,10 +175,13 @@ "@storageAccessDialogMessage": { "placeholders": { "directory": { - "type": "String" + "type": "String", + "description": "the name of a directory, using the output of `rootDirectoryDescription` or `otherDirectoryDescription`" }, "volume": { - "type": "String" + "type": "String", + "example": "SD card", + "description": "the name of a storage volume" } } }, @@ -180,10 +190,13 @@ "@restrictedAccessDialogMessage": { "placeholders": { "directory": { - "type": "String" + "type": "String", + "description": "the name of a directory, using the output of `rootDirectoryDescription` or `otherDirectoryDescription`" }, "volume": { - "type": "String" + "type": "String", + "example": "SD card", + "description": "the name of a storage volume" } } }, @@ -192,16 +205,22 @@ "@notEnoughSpaceDialogMessage": { "placeholders": { "neededSize": { - "type": "String" + "type": "String", + "example": "314 MB" }, "freeSize": { - "type": "String" + "type": "String", + "example": "123 MB" }, "volume": { - "type": "String" + "type": "String", + "example": "SD card", + "description": "the name of a storage volume" } } }, + "missingSystemFilePickerDialogTitle": "Missing System File Picker", + "missingSystemFilePickerDialogMessage": "The system file picker is missing or disabled. Please enable it and try again.", "unsupportedTypeDialogTitle": "Unsupported Types", "unsupportedTypeDialogMessage": "{count, plural, =1{This operation is not supported for items of the following type: {types}.} other{This operation is not supported for items of the following types: {types}.}}", @@ -209,7 +228,9 @@ "placeholders": { "count": {}, "types": { - "type": "String" + "type": "String", + "example": "GIF, TIFF, MP4", + "description": "a list of unsupported types" } } }, @@ -233,7 +254,10 @@ "videoResumeDialogMessage": "Do you want to resume playing at {time}?", "@videoResumeDialogMessage": { "placeholders": { - "time": {} + "time": { + "type": "String", + "example": "13:37" + } } }, "videoStartOverButtonLabel": "START OVER", @@ -271,14 +295,17 @@ "renameEntryDialogLabel": "New name", "editEntryDateDialogTitle": "Date & Time", - "editEntryDateDialogSet": "Set", - "editEntryDateDialogShift": "Shift", + "editEntryDateDialogSetCustom": "Set custom date", + "editEntryDateDialogCopyField": "Copy from other date", "editEntryDateDialogExtractFromTitle": "Extract from title", - "editEntryDateDialogClear": "Clear", - "editEntryDateDialogFieldSelection": "Field selection", + "editEntryDateDialogShift": "Shift", + "editEntryDateDialogSourceFileModifiedDate": "File modified date", + "editEntryDateDialogTargetFieldsHeader": "Fields to modify", "editEntryDateDialogHours": "Hours", "editEntryDateDialogMinutes": "Minutes", + "editEntryRatingDialogTitle": "Rating", + "removeEntryMetadataDialogTitle": "Metadata Removal", "removeEntryMetadataDialogMore": "More", @@ -338,10 +365,12 @@ "@aboutCreditsTranslatorLine": { "placeholders": { "language": { - "type": "String" + "type": "String", + "example": "Sumerian" }, "names": { - "type": "String" + "type": "String", + "example": "Reggie Lampert" } } }, @@ -378,6 +407,7 @@ "collectionSortDate": "By date", "collectionSortSize": "By size", "collectionSortName": "By album & file name", + "collectionSortRating": "By rating", "collectionGroupAlbum": "By album", "collectionGroupMonth": "By month", @@ -491,6 +521,7 @@ "searchSectionCountries": "Countries", "searchSectionPlaces": "Places", "searchSectionTags": "Tags", + "searchSectionRating": "Ratings", "settingsPageTitle": "Settings", "settingsSystemDefault": "System", @@ -514,8 +545,10 @@ "settingsNavigationDrawerAddAlbum": "Add album", "settingsSectionThumbnails": "Thumbnails", + "settingsThumbnailShowFavouriteIcon": "Show favourite icon", "settingsThumbnailShowLocationIcon": "Show location icon", "settingsThumbnailShowMotionPhotoIcon": "Show motion photo icon", + "settingsThumbnailShowRating": "Show rating", "settingsThumbnailShowRawIcon": "Show raw icon", "settingsThumbnailShowVideoDuration": "Show video duration", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb new file mode 100644 index 000000000..f23a0af12 --- /dev/null +++ b/lib/l10n/app_es.arb @@ -0,0 +1,540 @@ +{ + "appName": "Aves", + "welcomeMessage": "Bienvenido a Aves", + "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}}", + + "applyButtonLabel": "APLICAR", + "deleteButtonLabel": "BORRAR", + "nextButtonLabel": "SIGUIENTE", + "showButtonLabel": "MOSTRAR", + "hideButtonLabel": "OCULTAR", + "continueButtonLabel": "CONTINUAR", + + "cancelTooltip": "Cancelar", + "changeTooltip": "Cambiar", + "clearTooltip": "Limpiar", + "previousTooltip": "Anterior", + "nextTooltip": "Siguiente", + "showTooltip": "Mostrar", + "hideTooltip": "Ocultar", + "actionRemove": "Remover", + "resetButtonTooltip": "Restablecer", + + "doubleBackExitMessage": "Presione «atrás» nuevamente para salir.", + + "sourceStateLoading": "Cargando", + "sourceStateCataloguing": "Catalogando", + "sourceStateLocatingCountries": "Ubicando países", + "sourceStateLocatingPlaces": "Ubicando lugares", + + "chipActionDelete": "Borrar", + "chipActionGoToAlbumPage": "Mostrar en Álbumes", + "chipActionGoToCountryPage": "Mostrar en Países", + "chipActionGoToTagPage": "Mostrar en Etiquetas", + "chipActionHide": "Esconder", + "chipActionPin": "Fijar", + "chipActionUnpin": "Dejar de fijar", + "chipActionRename": "Renombrar", + "chipActionSetCover": "Elegir portada", + "chipActionCreateAlbum": "Crear álbum", + + "entryActionCopyToClipboard": "Copiar al portapapeles", + "entryActionDelete": "Borrar", + "entryActionExport": "Exportar", + "entryActionInfo": "Información", + "entryActionRename": "Renombrar", + "entryActionRotateCCW": "Rotar en sentido antihorario", + "entryActionRotateCW": "Rotar en sentido horario", + "entryActionFlip": "Voltear horizontalmente", + "entryActionPrint": "Imprimir", + "entryActionShare": "Compartir", + "entryActionViewSource": "Ver fuente", + "entryActionViewMotionPhotoVideo": "Abrir foto en movimiento", + "entryActionEdit": "Editar con…", + "entryActionOpen": "Abrir con…", + "entryActionSetAs": "Establecer como…", + "entryActionOpenMap": "Mostrar en aplicación de mapa…", + "entryActionRotateScreen": "Rotar pantalla", + "entryActionAddFavourite": "Agregar a favoritos", + "entryActionRemoveFavourite": "Quitar de favoritos", + + "videoActionCaptureFrame": "Capturar fotograma", + "videoActionPause": "Pausa", + "videoActionPlay": "Reproducir", + "videoActionReplay10": "Retroceder 10 segundos", + "videoActionSkip10": "Adelantar 10 segundos", + "videoActionSelectStreams": "Seleccionar pistas", + "videoActionSetSpeed": "Velocidad de reproducción", + "videoActionSettings": "Ajustes", + + "entryInfoActionEditDate": "Editar fecha y hora", + "entryInfoActionEditRating": "Editar clasificación", + "entryInfoActionEditTags": "Editar etiquetas", + "entryInfoActionRemoveMetadata": "Eliminar metadatos", + + "filterFavouriteLabel": "Favorito", + "filterLocationEmptyLabel": "No localizado", + "filterTagEmptyLabel": "Sin etiquetar", + "filterRatingUnratedLabel": "Sin clasificar", + "filterRatingRejectedLabel": "Rechazado", + "filterTypeAnimatedLabel": "Animado", + "filterTypeMotionPhotoLabel": "Foto en movimiento", + "filterTypePanoramaLabel": "Panorámica", + "filterTypeRawLabel": "Raw", + "filterTypeSphericalVideoLabel": "Video en 360°", + "filterTypeGeotiffLabel": "GeoTIFF", + "filterMimeImageLabel": "Imagen", + "filterMimeVideoLabel": "Video", + + "coordinateFormatDms": "GMS", + "coordinateFormatDecimal": "Grados decimales", + "coordinateDms": "{coordinate} {direction}", + "coordinateDmsNorth": "N", + "coordinateDmsSouth": "S", + "coordinateDmsEast": "E", + "coordinateDmsWest": "O", + + "unitSystemMetric": "Métrico", + "unitSystemImperial": "Imperial", + + "videoLoopModeNever": "Nunca", + "videoLoopModeShortOnly": "Sólo videos cortos", + "videoLoopModeAlways": "Siempre", + + "mapStyleGoogleNormal": "Mapas de Google", + "mapStyleGoogleHybrid": "Mapas de Google (Híbrido)", + "mapStyleGoogleTerrain": "Mapas de Google (Superficie)", + "mapStyleOsmHot": "OSM Humanitario", + "mapStyleStamenToner": "Stamen Monocromático (Toner)", + "mapStyleStamenWatercolor": "Stamen Acuarela (Watercolor)", + + "nameConflictStrategyRename": "Renombrar", + "nameConflictStrategyReplace": "Reemplazar", + "nameConflictStrategySkip": "Saltear", + + "keepScreenOnNever": "Nunca", + "keepScreenOnViewerOnly": "Sólo en el visor", + "keepScreenOnAlways": "Siempre", + + "accessibilityAnimationsRemove": "Prevenir efectos en pantalla", + "accessibilityAnimationsKeep": "Mantener efectos en pantalla", + + "albumTierNew": "Nuevo", + "albumTierPinned": "Fijado", + "albumTierSpecial": "Común", + "albumTierApps": "Aplicaciones", + "albumTierRegular": "Otros", + + "storageVolumeDescriptionFallbackPrimary": "Almacenamiento interno", + "storageVolumeDescriptionFallbackNonPrimary": "Tarjeta de memoria", + "rootDirectoryDescription": "el directorio raíz", + "otherDirectoryDescription": "el directorio «{name}»", + "storageAccessDialogTitle": "Acceso al almacenamiento", + "storageAccessDialogMessage": "Por favor seleccione {directory} en «{volume}» en la siguiente pantalla para permitir a esta aplicación tener acceso.", + "restrictedAccessDialogTitle": "Acceso restringido", + "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.", + + "addShortcutDialogLabel": "Etiqueta del atajo", + "addShortcutButtonLabel": "AGREGAR", + + "noMatchingAppDialogTitle": "Sin aplicación compatible", + "noMatchingAppDialogMessage": "No se encontraron aplicaciones para manejar esto.", + + "deleteEntriesConfirmationDialogMessage": "{count, plural, =1{¿Está seguro de borrar este elemento?} other{¿Está seguro de querer borrar {count} elementos?}}", + + "videoResumeDialogMessage": "¿Desea reanudar la reproducción a las {time}?", + "videoStartOverButtonLabel": "VOLVER A EMPEZAR", + "videoResumeButtonLabel": "REANUDAR", + + "setCoverDialogTitle": "Elegir carátula", + "setCoverDialogLatest": "Elemento más reciente", + "setCoverDialogCustom": "Personalizado", + + "hideFilterConfirmationDialogMessage": "Fotos y videos que concuerden serán ocultados de su colección. Puede volver a mostrarlos desde los ajustes de «Privacidad».\n\n¿Está seguro de que desea ocultarlos?", + + "newAlbumDialogTitle": "Álbum nuevo", + "newAlbumDialogNameLabel": "Nombre del álbum", + "newAlbumDialogNameLabelAlreadyExistsHelper": "El directorio ya existe", + "newAlbumDialogStorageLabel": "Almacenamiento:", + + "renameAlbumDialogLabel": "Renombrar", + "renameAlbumDialogLabelAlreadyExistsHelper": "El directorio ya existe", + + "deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{¿Está seguro de que desea borrar este álbum y un elemento?} other{¿Está seguro de que desea borrar este álbum y sus {count} elementos?}}", + "deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{¿Está seguro de que desea borrar estos álbumes y un elemento?} other{¿Está seguro de que desea borrar estos álbumes y sus {count} elementos?}}", + + "exportEntryDialogFormat": "Formato:", + + "renameEntryDialogLabel": "Renombrar", + + "editEntryDateDialogTitle": "Fecha y hora", + "editEntryDateDialogSetCustom": "Establecer fecha personalizada", + "editEntryDateDialogCopyField": "Copiar de otra fecha", + "editEntryDateDialogExtractFromTitle": "Extraer del título", + "editEntryDateDialogShift": "Cambiar", + "editEntryDateDialogSourceFileModifiedDate": "Fecha de modificación del archivo", + "editEntryDateDialogTargetFieldsHeader": "Campos a modificar", + "editEntryDateDialogHours": "Horas", + "editEntryDateDialogMinutes": "Minutos", + + "editEntryRatingDialogTitle": "Clasificación", + + "removeEntryMetadataDialogTitle": "Eliminación de metadatos", + "removeEntryMetadataDialogMore": "Más", + + "removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP es necesario para reproducir la animación de una foto en movimiento.\n\n¿Está seguro de que desea removerlo?", + + "videoSpeedDialogLabel": "Velocidad de reproducción", + + "videoStreamSelectionDialogVideo": "Video", + "videoStreamSelectionDialogAudio": "Audio", + "videoStreamSelectionDialogText": "Subtítulos", + "videoStreamSelectionDialogOff": "Desactivado", + "videoStreamSelectionDialogTrack": "Pista", + "videoStreamSelectionDialogNoSelection": "No hay otras pistas.", + + "genericSuccessFeedback": "¡Completado!", + "genericFailureFeedback": "Falló", + + "menuActionConfigureView": "Ver", + "menuActionSelect": "Seleccionar", + "menuActionSelectAll": "Seleccionar todo", + "menuActionSelectNone": "Deseleccionar", + "menuActionMap": "Mapa", + "menuActionStats": "Estadísticas", + + "viewDialogTabSort": "Ordenar", + "viewDialogTabGroup": "Grupo", + "viewDialogTabLayout": "Disposición", + + "tileLayoutGrid": "Cuadrícula", + "tileLayoutList": "Lista", + + "aboutPageTitle": "Acerca de", + "aboutLinkSources": "Fuentes", + "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", + "aboutBugCopyInfoInstruction": "Copiar información del sistema", + "aboutBugCopyInfoButton": "Copiar", + "aboutBugReportInstruction": "Reportar en GitHub con los registros y la información del sistema", + "aboutBugReportButton": "Reportar", + + "aboutCredits": "Créditos", + "aboutCreditsWorldAtlas1": "Esta aplicación usa un archivo TopoJSON de", + "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", + "aboutLicensesFlutterPlugins": "Añadidos de Flutter", + "aboutLicensesFlutterPackages": "Paquetes de Flutter", + "aboutLicensesDartPackages": "Paquetes de Dart", + "aboutLicensesShowAllButtonLabel": "Mostrar todas las licencias", + + "policyPageTitle": "Política de privacidad", + + "collectionPageTitle": "Colección", + "collectionPickPageTitle": "Elegir", + "collectionSelectionPageTitle": "{count, plural, =0{Seleccionar} =1{1 elemento} other{{count} elementos}}", + + "collectionActionShowTitleSearch": "Mostrar filtros de títulos", + "collectionActionHideTitleSearch": "Ocultar filtros de títulos", + "collectionActionAddShortcut": "Agregar atajo", + "collectionActionCopy": "Copiar a álbum", + "collectionActionMove": "Mover a álbum", + "collectionActionRescan": "Volver a buscar", + "collectionActionEdit": "Editar", + + "collectionSearchTitlesHintText": "Buscar títulos", + + "collectionSortDate": "Por fecha", + "collectionSortSize": "Por tamaño", + "collectionSortName": "Por nombre de álbum y archivo", + "collectionSortRating": "Por clasificación", + + "collectionGroupAlbum": "Por álbum", + "collectionGroupMonth": "Por mes", + "collectionGroupDay": "Por día", + "collectionGroupNone": "No agrupar", + + "sectionUnknown": "Desconocido", + "dateToday": "Hoy", + "dateYesterday": "Ayer", + "dateThisMonth": "Este mes", + "collectionDeleteFailureFeedback": "{count, plural, =1{Error al borrar 1 elemento} other{Error al borrar {count} elementos}}", + "collectionCopyFailureFeedback": "{count, plural, =1{Error al copiar 1 item} other{Error al copiar {count} elementos}}", + "collectionMoveFailureFeedback": "{count, plural, =1{Error al mover 1 elemento} other{Error al mover {count} elementos}}", + "collectionEditFailureFeedback": "{count, plural, =1{Error al editar 1 elemento} other{Error al editar {count} elementos}}", + "collectionExportFailureFeedback": "{count, plural, =1{Error al exportar 1 página} other{Error al exportar {count} páginas}}", + "collectionCopySuccessFeedback": "{count, plural, =1{1 elemento copiado} other{Copiados{count} elementos}}", + "collectionMoveSuccessFeedback": "{count, plural, =1{1 elemento movido} other{Movidos {count} elementos}}", + "collectionEditSuccessFeedback": "{count, plural, =1{1 elemento editado} other{Editados {count} elementos}}", + + "collectionEmptyFavourites": "Sin favoritos", + "collectionEmptyVideos": "Sin videos", + "collectionEmptyImages": "Sin imágenes", + + "collectionSelectSectionTooltip": "Seleccionar sección", + "collectionDeselectSectionTooltip": "Deseleccionar sección", + + "drawerCollectionAll": "Toda la colección", + "drawerCollectionFavourites": "Favoritos", + "drawerCollectionImages": "Imágenes", + "drawerCollectionVideos": "Videos", + "drawerCollectionAnimated": "Animaciones", + "drawerCollectionMotionPhotos": "Fotos en movimiento", + "drawerCollectionPanoramas": "Panorámicas", + "drawerCollectionRaws": "Fotos Raw", + "drawerCollectionSphericalVideos": "Videos en 360°", + + "chipSortDate": "Por fecha", + "chipSortName": "Por nombre", + "chipSortCount": "Por número de elementos", + + "albumGroupTier": "Por nivel", + "albumGroupVolume": "Por volumen de almacenamiento", + "albumGroupNone": "No agrupar", + + "albumPickPageTitleCopy": "Copiar a álbum", + "albumPickPageTitleExport": "Exportar a álbum", + "albumPickPageTitleMove": "Mover a álbum", + "albumPickPageTitlePick": "Elegir álbum", + + "albumCamera": "Cámara", + "albumDownload": "Descargar", + "albumScreenshots": "Capturas de pantalla", + "albumScreenRecordings": "Grabaciones de pantalla", + "albumVideoCaptures": "Capturas en video", + + "albumPageTitle": "Álbumes", + "albumEmpty": "Sin álbumes", + "createAlbumTooltip": "Crear álbum", + "createAlbumButtonLabel": "CREAR", + "newFilterBanner": "nuevo", + + "countryPageTitle": "Países", + "countryEmpty": "Sin países", + + "tagPageTitle": "Etiquetas", + "tagEmpty": "Sin etiquetas", + + "searchCollectionFieldHint": "Buscar en colección", + "searchSectionRecent": "Reciente", + "searchSectionAlbums": "Álbumes", + "searchSectionCountries": "Países", + "searchSectionPlaces": "Lugares", + "searchSectionTags": "Etiquetas", + "searchSectionRating": "Clasificaciones", + + "settingsPageTitle": "Ajustes", + "settingsSystemDefault": "Sistema", + "settingsDefault": "Restablecer", + + "settingsActionExport": "Exportar", + "settingsActionImport": "Importar", + + "settingsSectionNavigation": "Navegación", + "settingsHome": "Inicio", + "settingsKeepScreenOnTile": "Mantener pantalla encendida", + "settingsKeepScreenOnTitle": "Mantener pantalla encendida", + "settingsDoubleBackExit": "Presione «atrás» dos veces para salir", + + "settingsNavigationDrawerTile": "Menú de navegación", + "settingsNavigationDrawerEditorTitle": "Menú de navegación", + "settingsNavigationDrawerBanner": "Toque y mantenga para mover y reordenar elementos del menú.", + "settingsNavigationDrawerTabTypes": "Tipos", + "settingsNavigationDrawerTabAlbums": "Álbumes", + "settingsNavigationDrawerTabPages": "Páginas", + "settingsNavigationDrawerAddAlbum": "Agregar álbum", + + "settingsSectionThumbnails": "Miniaturas", + "settingsThumbnailShowFavouriteIcon": "Mostrar icono de favoritos", + "settingsThumbnailShowLocationIcon": "Mostrar icono de ubicación", + "settingsThumbnailShowMotionPhotoIcon": "Mostrar icono de foto en movimiento", + "settingsThumbnailShowRating": "Mostrar clasificación", + "settingsThumbnailShowRawIcon": "Mostrar icono Raw", + "settingsThumbnailShowVideoDuration": "Mostrar duración de video", + + "settingsCollectionQuickActionsTile": "Acciones rápidas", + "settingsCollectionQuickActionEditorTitle": "Acciones rápidas", + "settingsCollectionQuickActionTabBrowsing": "Búsqueda", + "settingsCollectionQuickActionTabSelecting": "Selección", + "settingsCollectionBrowsingQuickActionEditorBanner": "Toque y mantenga para mover botones y seleccionar cuáles acciones se muestran mientras busca elementos.", + "settingsCollectionSelectionQuickActionEditorBanner": "Toque y mantenga para mover botones y seleccionar cuáles acciones se muestran mientras selecciona elementos.", + + "settingsSectionViewer": "Visor", + "settingsViewerUseCutout": "Usar área recortada", + "settingsViewerMaximumBrightness": "Brillo máximo", + "settingsMotionPhotoAutoPlay": "Reproducir automáticamente fotos en movimiento", + "settingsImageBackground": "Imagen de fondo", + + "settingsViewerQuickActionsTile": "Acciones rápidas", + "settingsViewerQuickActionEditorTitle": "Acciones rápidas", + "settingsViewerQuickActionEditorBanner": "Toque y mantenga para mover botones y seleccionar cuáles acciones se muestran en el visor.", + "settingsViewerQuickActionEditorDisplayedButtons": "Botones mostrados", + "settingsViewerQuickActionEditorAvailableButtons": "Botones disponibles", + "settingsViewerQuickActionEmpty": "Sin botones", + + "settingsViewerOverlayTile": "Incrustaciones", + "settingsViewerOverlayTitle": "Incrustaciones", + "settingsViewerShowOverlayOnOpening": "Mostrar durante apertura", + "settingsViewerShowMinimap": "Mostrar mapa en miniatura", + "settingsViewerShowInformation": "Mostrar información", + "settingsViewerShowInformationSubtitle": "Mostrar título, fecha, ubicación, etc.", + "settingsViewerShowShootingDetails": "Mostrar detalles de toma", + "settingsViewerEnableOverlayBlurEffect": "Efecto de difuminado", + + "settingsVideoPageTitle": "Ajustes de video", + "settingsSectionVideo": "Video", + "settingsVideoShowVideos": "Mostrar videos", + "settingsVideoEnableHardwareAcceleration": "Aceleración por hardware", + "settingsVideoEnableAutoPlay": "Reproducción automática", + "settingsVideoLoopModeTile": "Modo bucle", + "settingsVideoLoopModeTitle": "Modo bucle", + "settingsVideoQuickActionsTile": "Acciones rápidas para videos", + "settingsVideoQuickActionEditorTitle": "Acciones rápidas", + + "settingsSubtitleThemeTile": "Subtítulos", + "settingsSubtitleThemeTitle": "Subtítulos", + "settingsSubtitleThemeSample": "Esto es un ejemplo.", + "settingsSubtitleThemeTextAlignmentTile": "Alineación de texto", + "settingsSubtitleThemeTextAlignmentTitle": "Alineación de texto", + "settingsSubtitleThemeTextSize": "Tamaño de texto", + "settingsSubtitleThemeShowOutline": "Mostrar contorno y sombra", + "settingsSubtitleThemeTextColor": "Color de texto", + "settingsSubtitleThemeTextOpacity": "Opacidad de texto", + "settingsSubtitleThemeBackgroundColor": "Color de fondo", + "settingsSubtitleThemeBackgroundOpacity": "Opacidad de fondo", + "settingsSubtitleThemeTextAlignmentLeft": "Izquierda", + "settingsSubtitleThemeTextAlignmentCenter": "Centro", + "settingsSubtitleThemeTextAlignmentRight": "Derecha", + + "settingsSectionPrivacy": "Privacidad", + "settingsAllowInstalledAppAccess": "Permita el acceso a lista de aplicaciones", + "settingsAllowInstalledAppAccessSubtitle": "Usado para mejorar los álbumes mostrados", + "settingsAllowErrorReporting": "Permitir reporte de errores anónimo", + "settingsSaveSearchHistory": "Guardar historial de búsqueda", + + "settingsHiddenItemsTile": "Elementos ocultos", + "settingsHiddenItemsTitle": "Elementos ocultos", + + "settingsHiddenFiltersTitle": "Filtros", + "settingsHiddenFiltersBanner": "Fotos y videos que concuerden con los filtros no aparecerán en su colección.", + "settingsHiddenFiltersEmpty": "Sin filtros", + + "settingsHiddenPathsTitle": "Ubicaciones ocultas", + "settingsHiddenPathsBanner": "Fotos y videos que se encuentren en estos directorios y cualquiera de sus subdirectorios no aparecerán en su colección.", + "addPathTooltip": "Añadir ubicación", + + "settingsStorageAccessTile": "Acceso al almacenamiento", + "settingsStorageAccessTitle": "Acceso al almacenamiento", + "settingsStorageAccessBanner": "Algunos directorios requieren un permiso de acceso explícito para que sea posible modificar los archivos que contienen. Puede revisar los directorios con permiso aquí.", + "settingsStorageAccessEmpty": "Sin permisos de acceso", + "settingsStorageAccessRevokeTooltip": "Revocar", + + "settingsSectionAccessibility": "Accesibilidad", + "settingsRemoveAnimationsTile": "Remover animaciones", + "settingsRemoveAnimationsTitle": "Remove animaciones", + "settingsTimeToTakeActionTile": "Hora de entrar en acción", + "settingsTimeToTakeActionTitle": "Hora de entrar en acción", + + "settingsSectionLanguage": "Idioma y formatos", + "settingsLanguage": "Idioma", + "settingsCoordinateFormatTile": "Formato de coordenadas", + "settingsCoordinateFormatTitle": "Formato de coordenadas", + "settingsUnitSystemTile": "Unidades", + "settingsUnitSystemTitle": "Unidades", + + "statsPageTitle": "Stats", + "statsWithGps": "{count, plural, =1{1 elemento con ubicación} other{{count} elementos con ubicación}}", + "statsTopCountries": "Países principales", + "statsTopPlaces": "Lugares principales", + "statsTopTags": "Etiquetas principales", + + "viewerOpenPanoramaButtonLabel": "ABRIR PANORÁMICA", + "viewerErrorUnknown": "¡Ups!", + "viewerErrorDoesNotExist": "El archivo no existe.", + + "viewerInfoPageTitle": "Información", + "viewerInfoBackToViewerTooltip": "Regresar al visor", + + "viewerInfoUnknown": "Desconocido", + "viewerInfoLabelTitle": "Título", + "viewerInfoLabelDate": "Fecha", + "viewerInfoLabelResolution": "Resolución", + "viewerInfoLabelSize": "Tamaño", + "viewerInfoLabelUri": "URI", + "viewerInfoLabelPath": "Ubicación", + "viewerInfoLabelDuration": "Duración", + "viewerInfoLabelOwner": "Propiedad de", + "viewerInfoLabelCoordinates": "Coordinadas", + "viewerInfoLabelAddress": "Dirección", + + "mapStyleTitle": "Estilo de mapa", + "mapStyleTooltip": "Selección de estilo de mapa", + "mapZoomInTooltip": "Acercar", + "mapZoomOutTooltip": "Alejar", + "mapPointNorthUpTooltip": "Apuntar el Norte hacia arriba", + "mapAttributionOsmHot": "Datos de mapas © [OpenStreetMap](https://www.openstreetmap.org/copyright) contribuidores • Teselas por [HOT](https://www.hotosm.org/) • Alojado por [OSM France](https://openstreetmap.fr/)", + "mapAttributionStamen": "Datos de mapas © [OpenStreetMap](https://www.openstreetmap.org/copyright) contribuidores • Teselas por [Stamen Design](http://stamen.com), [CC BY 3.0](http://creativecommons.org/licenses/by/3.0)", + "openMapPageTooltip": "Ver en página del mapa", + "mapEmptyRegion": "Sin imágenes en esta región", + + "viewerInfoOpenEmbeddedFailureFeedback": "Fallo al extraer datos embutidos", + "viewerInfoOpenLinkText": "Abrir", + "viewerInfoViewXmlLinkText": "Ver XML", + + "viewerInfoSearchFieldLabel": "Buscar metadatos", + "viewerInfoSearchEmpty": "Sin claves concordantes", + "viewerInfoSearchSuggestionDate": "Fecha y hora", + "viewerInfoSearchSuggestionDescription": "Descripción", + "viewerInfoSearchSuggestionDimensions": "Dimensiones", + "viewerInfoSearchSuggestionResolution": "Resolución", + "viewerInfoSearchSuggestionRights": "Derechos", + + "tagEditorPageTitle": "Editar Etiquetas", + "tagEditorPageNewTagFieldLabel": "Nueva etiqueta", + "tagEditorPageAddTagTooltip": "Añadir etiqueta", + "tagEditorSectionRecent": "Reciente", + + "panoramaEnableSensorControl": "Activar control de sensores", + "panoramaDisableSensorControl": "Desactivar control de sensores", + + "sourceViewerPageTitle": "Fuente", + + "filePickerShowHiddenFiles": "Mostrar archivos ocultos", + "filePickerDoNotShowHiddenFiles": "No mostrar archivos ocultos", + "filePickerOpenFrom": "Abrir desde", + "filePickerNoItems": "Sin elementos", + "filePickerUseThisFolder": "Usar esta carpeta", + "@filePickerUseThisFolder": {} +} diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index d1c2e2b7c..701a808e8 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -22,7 +22,7 @@ "nextTooltip": "Suivant", "showTooltip": "Afficher", "hideTooltip": "Masquer", - "removeTooltip": "Supprimer", + "actionRemove": "Supprimer", "resetButtonTooltip": "Réinitialiser", "doubleBackExitMessage": "Pressez «\u00A0retour\u00A0» à nouveau pour quitter.", @@ -73,12 +73,15 @@ "videoActionSettings": "Préférences", "entryInfoActionEditDate": "Modifier la date", + "entryInfoActionEditRating": "Modifier la notation", "entryInfoActionEditTags": "Modifier les libellés", "entryInfoActionRemoveMetadata": "Retirer les métadonnées", "filterFavouriteLabel": "Favori", "filterLocationEmptyLabel": "Sans lieu", "filterTagEmptyLabel": "Sans libellé", + "filterRatingUnratedLabel": "Sans notation", + "filterRatingRejectedLabel": "Rejeté", "filterTypeAnimatedLabel": "Animation", "filterTypeMotionPhotoLabel": "Photo animée", "filterTypePanoramaLabel": "Panorama", @@ -137,6 +140,8 @@ "restrictedAccessDialogMessage": "Cette app ne peut pas modifier les fichiers du {directory} de «\u00A0{volume}\u00A0».\n\nVeuillez utiliser une app pré-installée pour déplacer les fichiers vers un autre dossier.", "notEnoughSpaceDialogTitle": "Espace insuffisant", "notEnoughSpaceDialogMessage": "Cette opération nécessite {neededSize} d’espace disponible sur «\u00A0{volume}\u00A0», mais il ne reste que {freeSize}.", + "missingSystemFilePickerDialogTitle": "Sélecteur de fichiers désactivé", + "missingSystemFilePickerDialogMessage": "Le sélecteur de fichiers du système est absent ou désactivé. Veuillez le réactiver et réessayer.", "unsupportedTypeDialogTitle": "Formats non supportés", "unsupportedTypeDialogMessage": "{count, plural, =1{Cette opération n’est pas disponible pour les fichiers au format suivant : {types}.} other{Cette opération n’est pas disponible pour les fichiers aux formats suivants : {types}.}}", @@ -178,14 +183,17 @@ "renameEntryDialogLabel": "Nouveau nom", "editEntryDateDialogTitle": "Date & Heure", - "editEntryDateDialogSet": "Régler", - "editEntryDateDialogShift": "Décaler", + "editEntryDateDialogSetCustom": "Régler une date personnalisée", + "editEntryDateDialogCopyField": "Copier d'une autre date", "editEntryDateDialogExtractFromTitle": "Extraire du titre", - "editEntryDateDialogClear": "Effacer", - "editEntryDateDialogFieldSelection": "Champs affectés", + "editEntryDateDialogShift": "Décaler", + "editEntryDateDialogSourceFileModifiedDate": "Date de modification du fichier", + "editEntryDateDialogTargetFieldsHeader": "Champs à modifier", "editEntryDateDialogHours": "Heures", "editEntryDateDialogMinutes": "Minutes", + "editEntryRatingDialogTitle": "Notation", + "removeEntryMetadataDialogTitle": "Retrait de métadonnées", "removeEntryMetadataDialogMore": "Voir plus", @@ -270,6 +278,7 @@ "collectionSortDate": "par date", "collectionSortSize": "par taille", "collectionSortName": "alphabétique", + "collectionSortRating": "par notation", "collectionGroupAlbum": "par album", "collectionGroupMonth": "par mois", @@ -343,6 +352,7 @@ "searchSectionCountries": "Pays", "searchSectionPlaces": "Lieux", "searchSectionTags": "Libellés", + "searchSectionRating": "Notations", "settingsPageTitle": "Réglages", "settingsSystemDefault": "Système", @@ -366,8 +376,10 @@ "settingsNavigationDrawerAddAlbum": "Ajouter un album", "settingsSectionThumbnails": "Vignettes", + "settingsThumbnailShowFavouriteIcon": "Afficher l’icône de favori", "settingsThumbnailShowLocationIcon": "Afficher l’icône de lieu", "settingsThumbnailShowMotionPhotoIcon": "Afficher l’icône de photo animée", + "settingsThumbnailShowRating": "Afficher la notation", "settingsThumbnailShowRawIcon": "Afficher l’icône de photo raw", "settingsThumbnailShowVideoDuration": "Afficher la durée de la vidéo", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 5ec0b4db0..ac2d83ce4 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -22,7 +22,7 @@ "nextTooltip": "다음", "showTooltip": "보기", "hideTooltip": "숨기기", - "removeTooltip": "제거", + "actionRemove": "제거", "resetButtonTooltip": "복원", "doubleBackExitMessage": "종료하려면 한번 더 누르세요.", @@ -72,13 +72,16 @@ "videoActionSetSpeed": "재생 배속", "videoActionSettings": "설정", - "entryInfoActionEditDate": "날짜와 시간 수정", + "entryInfoActionEditDate": "날짜 및 시간 수정", + "entryInfoActionEditRating": "별점 수정", "entryInfoActionEditTags": "태그 수정", "entryInfoActionRemoveMetadata": "메타데이터 삭제", "filterFavouriteLabel": "즐겨찾기", "filterLocationEmptyLabel": "장소 없음", "filterTagEmptyLabel": "태그 없음", + "filterRatingUnratedLabel": "별점 없음", + "filterRatingRejectedLabel": "거부됨", "filterTypeAnimatedLabel": "애니메이션", "filterTypeMotionPhotoLabel": "모션 포토", "filterTypePanoramaLabel": "파노라마", @@ -137,6 +140,8 @@ "restrictedAccessDialogMessage": "“{volume}”의 {directory}에 있는 파일의 접근이 제한됩니다.\n\n기본으로 설치된 갤러리나 파일 관리 앱을 사용해서 다른 폴더로 파일을 이동하세요.", "notEnoughSpaceDialogTitle": "저장공간 부족", "notEnoughSpaceDialogMessage": "“{volume}”에 필요 공간은 {neededSize}인데 사용 가능한 용량은 {freeSize}만 남아있습니다.", + "missingSystemFilePickerDialogTitle": "기본 파일 선택기 없음", + "missingSystemFilePickerDialogMessage": "기본 파일 선택기가 없거나 비활성화딥니다. 파일 선택기를 켜고 다시 시도하세요.", "unsupportedTypeDialogTitle": "미지원 형식", "unsupportedTypeDialogMessage": "{count, plural, other{이 작업은 다음 항목의 형식을 지원하지 않습니다: {types}.}}", @@ -178,14 +183,17 @@ "renameEntryDialogLabel": "이름", "editEntryDateDialogTitle": "날짜 및 시간", - "editEntryDateDialogSet": "편집", - "editEntryDateDialogShift": "시간 이동", + "editEntryDateDialogSetCustom": "지정 날짜로 편집", + "editEntryDateDialogCopyField": "다른 날짜에서 지정", "editEntryDateDialogExtractFromTitle": "제목에서 추출", - "editEntryDateDialogClear": "삭제", - "editEntryDateDialogFieldSelection": "필드 선택", + "editEntryDateDialogShift": "시간 이동", + "editEntryDateDialogSourceFileModifiedDate": "파일 수정한 날짜", + "editEntryDateDialogTargetFieldsHeader": "수정할 필드", "editEntryDateDialogHours": "시간", "editEntryDateDialogMinutes": "분", + "editEntryRatingDialogTitle": "별점", + "removeEntryMetadataDialogTitle": "메타데이터 삭제", "removeEntryMetadataDialogMore": "더 보기", @@ -270,6 +278,7 @@ "collectionSortDate": "날짜", "collectionSortSize": "크기", "collectionSortName": "이름", + "collectionSortRating": "별점", "collectionGroupAlbum": "앨범별로", "collectionGroupMonth": "월별로", @@ -343,6 +352,7 @@ "searchSectionCountries": "국가", "searchSectionPlaces": "장소", "searchSectionTags": "태그", + "searchSectionRating": "별점", "settingsPageTitle": "설정", "settingsSystemDefault": "시스템", @@ -366,8 +376,10 @@ "settingsNavigationDrawerAddAlbum": "앨범 추가", "settingsSectionThumbnails": "섬네일", + "settingsThumbnailShowFavouriteIcon": "즐겨찾기 아이콘 표시", "settingsThumbnailShowLocationIcon": "위치 아이콘 표시", "settingsThumbnailShowMotionPhotoIcon": "모션 포토 아이콘 표시", + "settingsThumbnailShowRating": "별점 표시", "settingsThumbnailShowRawIcon": "Raw 아이콘 표시", "settingsThumbnailShowVideoDuration": "동영상 길이 표시", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index af5601f72..1d8649cc9 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -22,7 +22,7 @@ "nextTooltip": "Следующий", "showTooltip": "Показать", "hideTooltip": "Скрыть", - "removeTooltip": "Удалить", + "actionRemove": "Удалить", "resetButtonTooltip": "Сбросить", "doubleBackExitMessage": "Нажмите «назад» еще раз, чтобы выйти.", @@ -73,12 +73,15 @@ "videoActionSettings": "Настройки", "entryInfoActionEditDate": "Изменить дату и время", + "entryInfoActionEditRating": "Изменить рейтинг", "entryInfoActionEditTags": "Изменить теги", "entryInfoActionRemoveMetadata": "Удалить метаданные", "filterFavouriteLabel": "Избранное", "filterLocationEmptyLabel": "Без местоположения", "filterTagEmptyLabel": "Без тегов", + "filterRatingUnratedLabel": "Без рейтинга", + "filterRatingRejectedLabel": "Отклонённые", "filterTypeAnimatedLabel": "GIF", "filterTypeMotionPhotoLabel": "Живое фото", "filterTypePanoramaLabel": "Панорама", @@ -137,6 +140,8 @@ "restrictedAccessDialogMessage": "Этому приложению не разрешается изменять файлы в каталоге {directory} накопителя «{volume}».\n\nПожалуйста, используйте предустановленный файловый менеджер или галерею, чтобы переместить элементы в другой каталог.", "notEnoughSpaceDialogTitle": "Недостаточно свободного места.", "notEnoughSpaceDialogMessage": "Для завершения этой операции требуется {neededSize} свободного места на «{volume}», но осталось только {freeSize}.", + "missingSystemFilePickerDialogTitle": "Отсутствует системное приложение выбора файлов", + "missingSystemFilePickerDialogMessage": "Системное приложение выбора файлов отсутствует или отключено. Пожалуйста, включите его и повторите попытку.", "unsupportedTypeDialogTitle": "Неподдерживаемые форматы", "unsupportedTypeDialogMessage": "{count, plural, =1{Эта операция не поддерживается для объектов следующего формата: {types}.} other{Эта операция не поддерживается для объектов следующих форматов: {types}.}}", @@ -178,14 +183,17 @@ "renameEntryDialogLabel": "Новое название", "editEntryDateDialogTitle": "Дата и время", - "editEntryDateDialogSet": "Задать", - "editEntryDateDialogShift": "Сдвиг", + "editEntryDateDialogSetCustom": "Задайте дату", + "editEntryDateDialogCopyField": "Копировать с другой даты", "editEntryDateDialogExtractFromTitle": "Извлечь из названия", - "editEntryDateDialogClear": "Очистить", - "editEntryDateDialogFieldSelection": "Выбор поля", + "editEntryDateDialogShift": "Сдвиг", + "editEntryDateDialogSourceFileModifiedDate": "Дата изменения файла", + "editEntryDateDialogTargetFieldsHeader": "Поля для изменения", "editEntryDateDialogHours": "Часов", "editEntryDateDialogMinutes": "Минут", + "editEntryRatingDialogTitle": "Рейтинг", + "removeEntryMetadataDialogTitle": "Удаление метаданных", "removeEntryMetadataDialogMore": "Дополнительно", @@ -270,6 +278,7 @@ "collectionSortDate": "По дате", "collectionSortSize": "По размеру", "collectionSortName": "По имени альбома и файла", + "collectionSortRating": "По рейтингу", "collectionGroupAlbum": "По альбому", "collectionGroupMonth": "По месяцу", @@ -343,6 +352,7 @@ "searchSectionCountries": "Страны", "searchSectionPlaces": "Локации", "searchSectionTags": "Теги", + "searchSectionRating": "Рейтинги", "settingsPageTitle": "Настройки", "settingsSystemDefault": "Система", @@ -368,6 +378,7 @@ "settingsSectionThumbnails": "Эскизы", "settingsThumbnailShowLocationIcon": "Показать значок местоположения", "settingsThumbnailShowMotionPhotoIcon": "Показать значок живого фото", + "settingsThumbnailShowRating": "Показывать рейтинг", "settingsThumbnailShowRawIcon": "Показать значок RAW-файла", "settingsThumbnailShowVideoDuration": "Показывать продолжительность видео", diff --git a/lib/model/actions/entry_info_actions.dart b/lib/model/actions/entry_info_actions.dart index 901133187..ffc6ca198 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, + editRating, editTags, removeMetadata, // motion photo @@ -14,6 +15,7 @@ enum EntryInfoAction { class EntryInfoActions { static const all = [ EntryInfoAction.editDate, + EntryInfoAction.editRating, EntryInfoAction.editTags, EntryInfoAction.removeMetadata, EntryInfoAction.viewMotionPhotoVideo, @@ -26,6 +28,8 @@ extension ExtraEntryInfoAction on EntryInfoAction { // general case EntryInfoAction.editDate: return context.l10n.entryInfoActionEditDate; + case EntryInfoAction.editRating: + return context.l10n.entryInfoActionEditRating; case EntryInfoAction.editTags: return context.l10n.entryInfoActionEditTags; case EntryInfoAction.removeMetadata: @@ -45,8 +49,10 @@ extension ExtraEntryInfoAction on EntryInfoAction { // general case EntryInfoAction.editDate: return AIcons.date; + case EntryInfoAction.editRating: + return AIcons.editRating; case EntryInfoAction.editTags: - return AIcons.addTag; + return AIcons.editTags; case EntryInfoAction.removeMetadata: return AIcons.clear; // motion photo diff --git a/lib/model/actions/entry_set_actions.dart b/lib/model/actions/entry_set_actions.dart index 2d97dabca..f3601560b 100644 --- a/lib/model/actions/entry_set_actions.dart +++ b/lib/model/actions/entry_set_actions.dart @@ -21,10 +21,12 @@ enum EntrySetAction { copy, move, rescan, + toggleFavourite, rotateCCW, rotateCW, flip, editDate, + editRating, editTags, removeMetadata, } @@ -50,6 +52,7 @@ class EntrySetActions { EntrySetAction.delete, EntrySetAction.copy, EntrySetAction.move, + EntrySetAction.toggleFavourite, EntrySetAction.rescan, EntrySetAction.map, EntrySetAction.stats, @@ -93,6 +96,9 @@ extension ExtraEntrySetAction on EntrySetAction { return context.l10n.collectionActionMove; case EntrySetAction.rescan: return context.l10n.collectionActionRescan; + case EntrySetAction.toggleFavourite: + // different data depending on toggle state + return context.l10n.entryActionAddFavourite; case EntrySetAction.rotateCCW: return context.l10n.entryActionRotateCCW; case EntrySetAction.rotateCW: @@ -101,6 +107,8 @@ extension ExtraEntrySetAction on EntrySetAction { return context.l10n.entryActionFlip; case EntrySetAction.editDate: return context.l10n.entryInfoActionEditDate; + case EntrySetAction.editRating: + return context.l10n.entryInfoActionEditRating; case EntrySetAction.editTags: return context.l10n.entryInfoActionEditTags; case EntrySetAction.removeMetadata: @@ -147,6 +155,9 @@ extension ExtraEntrySetAction on EntrySetAction { return AIcons.move; case EntrySetAction.rescan: return AIcons.refresh; + case EntrySetAction.toggleFavourite: + // different data depending on toggle state + return AIcons.favourite; case EntrySetAction.rotateCCW: return AIcons.rotateLeft; case EntrySetAction.rotateCW: @@ -155,8 +166,10 @@ extension ExtraEntrySetAction on EntrySetAction { return AIcons.flip; case EntrySetAction.editDate: return AIcons.date; + case EntrySetAction.editRating: + return AIcons.editRating; case EntrySetAction.editTags: - return AIcons.addTag; + return AIcons.editTags; case EntrySetAction.removeMetadata: return AIcons.clear; } diff --git a/lib/model/actions/events.dart b/lib/model/actions/events.dart index 248e9dd88..e6fea841f 100644 --- a/lib/model/actions/events.dart +++ b/lib/model/actions/events.dart @@ -1,9 +1,13 @@ +import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; @immutable -class ActionEvent { +class ActionEvent extends Equatable { final T action; + @override + List get props => [action]; + const ActionEvent(this.action); } diff --git a/lib/model/device.dart b/lib/model/device.dart index 7a8ab076b..e26583f61 100644 --- a/lib/model/device.dart +++ b/lib/model/device.dart @@ -6,7 +6,7 @@ final Device device = Device._private(); class Device { late final String _userAgent; late final bool _canGrantDirectoryAccess, _canPinShortcut, _canPrint, _canRenderFlagEmojis, _canRenderGoogleMaps; - late final bool _hasFilePicker, _showPinShortcutFeedback, _supportEdgeToEdgeUIMode; + late final bool _showPinShortcutFeedback, _supportEdgeToEdgeUIMode; String get userAgent => _userAgent; @@ -20,8 +20,6 @@ class Device { bool get canRenderGoogleMaps => _canRenderGoogleMaps; - bool get hasFilePicker => _hasFilePicker; - bool get showPinShortcutFeedback => _showPinShortcutFeedback; bool get supportEdgeToEdgeUIMode => _supportEdgeToEdgeUIMode; @@ -38,7 +36,6 @@ class Device { _canPrint = capabilities['canPrint'] ?? false; _canRenderFlagEmojis = capabilities['canRenderFlagEmojis'] ?? false; _canRenderGoogleMaps = capabilities['canRenderGoogleMaps'] ?? false; - _hasFilePicker = capabilities['hasFilePicker'] ?? false; _showPinShortcutFeedback = capabilities['showPinShortcutFeedback'] ?? false; _supportEdgeToEdgeUIMode = capabilities['supportEdgeToEdgeUIMode'] ?? false; } diff --git a/lib/model/entry.dart b/lib/model/entry.dart index 1584e22e5..be81490c3 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -7,8 +7,6 @@ import 'package:aves/model/entry_cache.dart'; import 'package:aves/model/favourites.dart'; import 'package:aves/model/metadata/address.dart'; import 'package:aves/model/metadata/catalog.dart'; -import 'package:aves/model/metadata/date_modifier.dart'; -import 'package:aves/model/metadata/enums.dart'; import 'package:aves/model/multipage.dart'; import 'package:aves/model/video/metadata.dart'; import 'package:aves/ref/mime_types.dart'; @@ -18,7 +16,6 @@ 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'; @@ -237,7 +234,9 @@ class AvesEntry { bool get canEdit => path != null; - bool get canEditDate => canEdit && canEditExif; + bool get canEditDate => canEdit && (canEditExif || canEditXmp); + + bool get canEditRating => canEdit && canEditXmp; bool get canEditTags => canEdit && canEditXmp; @@ -361,6 +360,8 @@ class AvesEntry { return _bestDate; } + int get rating => _catalogMetadata?.rating ?? 0; + int get rotationDegrees => _catalogMetadata?.rotationDegrees ?? sourceRotationDegrees; set rotationDegrees(int rotationDegrees) { @@ -448,6 +449,7 @@ class AvesEntry { CatalogMetadata? get catalogMetadata => _catalogMetadata; set catalogMetadata(CatalogMetadata? newMetadata) { + final oldMimeType = mimeType; final oldDateModifiedSecs = dateModifiedSecs; final oldRotationDegrees = rotationDegrees; final oldIsFlipped = isFlipped; @@ -458,7 +460,7 @@ class AvesEntry { _tags = null; metadataChangeNotifier.notify(); - _onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); + _onVisualFieldChanged(oldMimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); } void clearMetadata() { @@ -477,14 +479,14 @@ class AvesEntry { 'width': size.width.ceil(), 'height': size.height.ceil(), }; - await _applyNewFields(fields, persist: persist); + await applyNewFields(fields, persist: persist); } catalogMetadata = CatalogMetadata(contentId: contentId); } else { if (isVideo && (!isSized || durationMillis == 0)) { // exotic video that is not sized during loading final fields = await VideoMetadataFormatter.getLoadingMetadata(this); - await _applyNewFields(fields, persist: persist); + await applyNewFields(fields, persist: persist); } catalogMetadata = await metadataFetchService.getCatalogMetadata(this, background: background); @@ -581,7 +583,8 @@ class AvesEntry { }.whereNotNull().where((v) => v.isNotEmpty).join(', '); } - Future _applyNewFields(Map newFields, {required bool persist}) async { + Future applyNewFields(Map newFields, {required bool persist}) async { + final oldMimeType = mimeType; final oldDateModifiedSecs = this.dateModifiedSecs; final oldRotationDegrees = this.rotationDegrees; final oldIsFlipped = this.isFlipped; @@ -621,7 +624,7 @@ class AvesEntry { if (catalogMetadata != null) await metadataDb.saveMetadata({catalogMetadata!}); } - await _onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); + await _onVisualFieldChanged(oldMimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); metadataChangeNotifier.notify(); } @@ -642,65 +645,12 @@ class AvesEntry { final updatedEntry = await mediaFileService.getEntry(uri, mimeType); if (updatedEntry != null) { - await _applyNewFields(updatedEntry.toMap(), persist: persist); + await applyNewFields(updatedEntry.toMap(), persist: persist); } await catalog(background: background, force: dataTypes.contains(EntryDataType.catalog), persist: persist); await locate(background: background, force: dataTypes.contains(EntryDataType.address), geocoderLocale: geocoderLocale); } - Future> rotate({required bool clockwise, required bool persist}) async { - final newFields = await metadataEditService.rotate(this, clockwise: clockwise); - if (newFields.isEmpty) return {}; - - await _applyNewFields(newFields, persist: persist); - return { - EntryDataType.basic, - EntryDataType.catalog, - }; - } - - Future> flip({required bool persist}) async { - final newFields = await metadataEditService.flip(this); - if (newFields.isEmpty) return {}; - - await _applyNewFields(newFields, persist: persist); - return { - EntryDataType.basic, - EntryDataType.catalog, - }; - } - - Future> editDate(DateModifier modifier) async { - if (modifier.action == DateEditAction.extractFromTitle) { - final _title = bestTitle; - if (_title == null) return {}; - final date = parseUnknownDateFormat(_title); - if (date == null) { - await reportService.recordError('failed to parse date from title=$_title', null); - return {}; - } - modifier = DateModifier(DateEditAction.set, modifier.fields, dateTime: date); - } - final newFields = await metadataEditService.editDate(this, modifier); - return newFields.isEmpty - ? {} - : { - EntryDataType.basic, - EntryDataType.catalog, - }; - } - - Future> removeMetadata(Set types) async { - final newFields = await metadataEditService.removeTypes(this, types); - return newFields.isEmpty - ? {} - : { - EntryDataType.basic, - EntryDataType.catalog, - EntryDataType.address, - }; - } - Future delete() { final completer = Completer(); mediaFileService.delete(entries: {this}).listen( @@ -715,10 +665,10 @@ class AvesEntry { return completer.future; } - // when the entry image itself changed (e.g. after rotation) - Future _onVisualFieldChanged(int? oldDateModifiedSecs, int oldRotationDegrees, bool oldIsFlipped) async { - if (oldDateModifiedSecs != dateModifiedSecs || oldRotationDegrees != rotationDegrees || oldIsFlipped != isFlipped) { - await EntryCache.evict(uri, mimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); + // when the MIME type or the image itself changed (e.g. after rotation) + Future _onVisualFieldChanged(String oldMimeType, int? oldDateModifiedSecs, int oldRotationDegrees, bool oldIsFlipped) async { + if ((!MimeTypes.refersToSameType(oldMimeType, mimeType) && !MimeTypes.isVideo(oldMimeType)) || oldDateModifiedSecs != dateModifiedSecs || oldRotationDegrees != rotationDegrees || oldIsFlipped != isFlipped) { + await EntryCache.evict(uri, oldMimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); imageChangeNotifier.notify(); } } @@ -735,13 +685,13 @@ class AvesEntry { Future addToFavourites() async { if (!isFavourite) { - await favourites.add([this]); + await favourites.add({this}); } } Future removeFromFavourites() async { if (isFavourite) { - await favourites.remove([this]); + await favourites.remove({this}); } } @@ -798,14 +748,6 @@ class AvesEntry { return c != 0 ? c : compareAsciiUpperCase(a.extension ?? '', b.extension ?? ''); } - // compare by: - // 1) size descending - // 2) name ascending - static int compareBySize(AvesEntry a, AvesEntry b) { - final c = (b.sizeBytes ?? 0).compareTo(a.sizeBytes ?? 0); - return c != 0 ? c : compareByName(a, b); - } - static final _epoch = DateTime.fromMillisecondsSinceEpoch(0); // compare by: @@ -816,4 +758,20 @@ class AvesEntry { if (c != 0) return c; return compareByName(b, a); } + + // compare by: + // 1) rating descending + // 2) date descending + static int compareByRating(AvesEntry a, AvesEntry b) { + final c = b.rating.compareTo(a.rating); + return c != 0 ? c : compareByDate(a, b); + } + + // compare by: + // 1) size descending + // 2) date descending + static int compareBySize(AvesEntry a, AvesEntry b) { + final c = (b.sizeBytes ?? 0).compareTo(a.sizeBytes ?? 0); + return c != 0 ? c : compareByDate(a, b); + } } diff --git a/lib/model/entry_cache.dart b/lib/model/entry_cache.dart index a70fd6d63..c0ff892a6 100644 --- a/lib/model/entry_cache.dart +++ b/lib/model/entry_cache.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:aves/image_providers/thumbnail_provider.dart'; import 'package:aves/image_providers/uri_image_provider.dart'; +import 'package:flutter/foundation.dart'; class EntryCache { // ordered descending @@ -19,9 +20,11 @@ class EntryCache { String uri, String mimeType, int? dateModifiedSecs, - int oldRotationDegrees, - bool oldIsFlipped, + int rotationDegrees, + bool isFlipped, ) async { + debugPrint('Evict cached images for uri=$uri, mimeType=$mimeType, dateModifiedSecs=$dateModifiedSecs, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped'); + // TODO TLAD provide pageId parameter for multi page items, if someday image editing features are added for them int? pageId; @@ -30,8 +33,8 @@ class EntryCache { uri: uri, mimeType: mimeType, pageId: pageId, - rotationDegrees: oldRotationDegrees, - isFlipped: oldIsFlipped, + rotationDegrees: rotationDegrees, + isFlipped: isFlipped, ).evict(); // evict low quality thumbnail (without specified extents) @@ -40,8 +43,8 @@ class EntryCache { mimeType: mimeType, pageId: pageId, dateModifiedSecs: dateModifiedSecs ?? 0, - rotationDegrees: oldRotationDegrees, - isFlipped: oldIsFlipped, + rotationDegrees: rotationDegrees, + isFlipped: isFlipped, )).evict(); await Future.forEach( @@ -51,8 +54,8 @@ class EntryCache { mimeType: mimeType, pageId: pageId, dateModifiedSecs: dateModifiedSecs ?? 0, - rotationDegrees: oldRotationDegrees, - isFlipped: oldIsFlipped, + rotationDegrees: rotationDegrees, + isFlipped: isFlipped, extent: extent, )).evict()); } diff --git a/lib/model/entry_metadata_edition.dart b/lib/model/entry_metadata_edition.dart new file mode 100644 index 000000000..a69300c39 --- /dev/null +++ b/lib/model/entry_metadata_edition.dart @@ -0,0 +1,309 @@ +import 'dart:convert'; +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/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:package_info_plus/package_info_plus.dart'; +import 'package:xml/xml.dart'; + +extension ExtraAvesEntryMetadataEdition on AvesEntry { + Future> editDate(DateModifier userModifier) async { + final Set dataTypes = {}; + + final appliedModifier = await _applyDateModifierToEntry(userModifier); + if (appliedModifier == null) { + await reportService.recordError('failed to get date for modifier=$userModifier, uri=$uri', null); + return {}; + } + + if (canEditExif && appliedModifier.fields.any((v) => v.type == MetadataType.exif)) { + final newFields = await metadataEditService.editExifDate(this, appliedModifier); + if (newFields.isNotEmpty) { + dataTypes.addAll({ + EntryDataType.basic, + EntryDataType.catalog, + }); + } + } + + if (canEditXmp && appliedModifier.fields.any((v) => v.type == MetadataType.xmp)) { + final metadata = { + MetadataType.xmp: await _editXmp((descriptions) { + switch (appliedModifier.action) { + case DateEditAction.setCustom: + case DateEditAction.copyField: + case DateEditAction.extractFromTitle: + editCreateDateXmp(descriptions, appliedModifier.setDateTime); + break; + case DateEditAction.shift: + final xmpDate = XMP.getString(descriptions, XMP.xmpCreateDate, namespace: Namespaces.xmp); + if (xmpDate != null) { + final date = DateTime.tryParse(xmpDate); + if (date != null) { + // TODO TLAD [date] DateTime.tryParse converts to UTC time, losing the time zone offset + final shiftedDate = date.add(Duration(minutes: appliedModifier.shiftMinutes!)); + editCreateDateXmp(descriptions, shiftedDate); + } else { + reportService.recordError('failed to parse XMP date=$xmpDate', null); + } + } + break; + case DateEditAction.remove: + editCreateDateXmp(descriptions, null); + break; + } + }), + }; + final newFields = await metadataEditService.editMetadata(this, metadata); + if (newFields.isNotEmpty) { + dataTypes.addAll({ + EntryDataType.basic, + EntryDataType.catalog, + }); + } + } + + return dataTypes; + } + + Future> _changeOrientation(Future> Function() apply) async { + final Set dataTypes = {}; + + await _missingDateCheckAndExifEdit(dataTypes); + + final newFields = await apply(); + // applying fields is only useful for a smoother visual change, + // as proper refreshing and persistence happens at the caller level + await applyNewFields(newFields, persist: false); + if (newFields.isNotEmpty) { + dataTypes.addAll({ + EntryDataType.basic, + EntryDataType.catalog, + }); + } + return dataTypes; + } + + Future> rotate({required bool clockwise}) { + return _changeOrientation(() => metadataEditService.rotate(this, clockwise: clockwise)); + } + + Future> flip() { + return _changeOrientation(() => metadataEditService.flip(this)); + } + + // write: + // - IPTC / keywords, if IPTC exists + // - XMP / dc:subject + Future> editTags(Set tags) async { + final Set dataTypes = {}; + final Map metadata = {}; + + final missingDate = await _missingDateCheckAndExifEdit(dataTypes); + + if (canEditIptc) { + final iptc = await metadataFetchService.getIptc(this); + if (iptc != null) { + editTagsIptc(iptc, tags); + metadata[MetadataType.iptc] = iptc; + } + } + + if (canEditXmp) { + metadata[MetadataType.xmp] = await _editXmp((descriptions) { + if (missingDate != null) { + editCreateDateXmp(descriptions, missingDate); + } + editTagsXmp(descriptions, tags); + }); + } + + final newFields = await metadataEditService.editMetadata(this, metadata); + if (newFields.isNotEmpty) { + dataTypes.add(EntryDataType.catalog); + } + return dataTypes; + } + + // write: + // - XMP / xmp:Rating + // update: + // - XMP / MicrosoftPhoto:Rating + // ignore (Windows tags, not part of Exif 2.32 spec): + // - Exif / Rating + // - Exif / RatingPercent + Future> editRating(int? rating) async { + final Set dataTypes = {}; + final Map metadata = {}; + + final missingDate = await _missingDateCheckAndExifEdit(dataTypes); + + if (canEditXmp) { + metadata[MetadataType.xmp] = await _editXmp((descriptions) { + if (missingDate != null) { + editCreateDateXmp(descriptions, missingDate); + } + editRatingXmp(descriptions, rating); + }); + } + + final newFields = await metadataEditService.editMetadata(this, metadata); + if (newFields.isNotEmpty) { + dataTypes.add(EntryDataType.catalog); + } + return dataTypes; + } + + Future> removeMetadata(Set types) async { + final newFields = await metadataEditService.removeTypes(this, types); + return newFields.isEmpty + ? {} + : { + EntryDataType.basic, + EntryDataType.catalog, + EntryDataType.address, + }; + } + + @visibleForTesting + static void editCreateDateXmp(List descriptions, DateTime? date) { + XMP.setAttribute( + descriptions, + XMP.xmpCreateDate, + date != null ? XMP.toXmpDate(date) : null, + namespace: Namespaces.xmp, + strat: XmpEditStrategy.always, + ); + } + + @visibleForTesting + static void editTagsIptc(List> iptc, Set tags) { + iptc.removeWhere((v) => v['record'] == IPTC.applicationRecord && v['tag'] == IPTC.keywordsTag); + iptc.add({ + 'record': IPTC.applicationRecord, + 'tag': IPTC.keywordsTag, + 'values': tags.map((v) => utf8.encode(v)).toList(), + }); + } + + @visibleForTesting + static void editTagsXmp(List descriptions, Set tags) { + XMP.setStringBag( + descriptions, + XMP.dcSubject, + tags, + namespace: Namespaces.dc, + strat: XmpEditStrategy.always, + ); + } + + @visibleForTesting + static void editRatingXmp(List descriptions, int? rating) { + XMP.setAttribute( + descriptions, + XMP.xmpRating, + (rating ?? 0) == 0 ? null : '$rating', + namespace: Namespaces.xmp, + strat: XmpEditStrategy.always, + ); + XMP.setAttribute( + descriptions, + XMP.msPhotoRating, + XMP.toMsPhotoRating(rating), + namespace: Namespaces.microsoftPhoto, + strat: XmpEditStrategy.updateIfPresent, + ); + } + + // convenience + + // This method checks whether the item already has a metadata date, + // and adds a date (the file modified date) via Exif if possible. + // It returns a date if the caller needs to add it via other metadata types (e.g. XMP). + Future _missingDateCheckAndExifEdit(Set dataTypes) async { + if (path == null) return null; + + // make sure entry is catalogued before we check whether is has a metadata date + if (!isCatalogued) { + await catalog(background: false, force: false, persist: true); + } + final dateMillis = catalogMetadata?.dateMillis; + if (dateMillis != null && dateMillis > 0) return null; + + late DateTime date; + try { + date = await File(path!).lastModified(); + } on FileSystemException catch (_) { + return null; + } + + if (canEditExif) { + final newFields = await metadataEditService.editExifDate(this, DateModifier.setCustom(const {MetadataField.exifDateOriginal}, date)); + if (newFields.isNotEmpty) { + dataTypes.addAll({ + EntryDataType.basic, + EntryDataType.catalog, + }); + return null; + } + } + + return date; + } + + Future _applyDateModifierToEntry(DateModifier modifier) async { + Set mainMetadataDate() => {canEditExif ? MetadataField.exifDateOriginal : MetadataField.xmpCreateDate}; + + switch (modifier.action) { + case DateEditAction.copyField: + DateTime? date; + final source = modifier.copyFieldSource; + if (source != null) { + switch (source) { + case DateFieldSource.fileModifiedDate: + try { + date = path != null ? await File(path!).lastModified() : null; + } on FileSystemException catch (_) {} + break; + default: + date = await metadataFetchService.getDate(this, source.toMetadataField()!); + break; + } + } + return date != null ? DateModifier.setCustom(mainMetadataDate(), date) : null; + case DateEditAction.extractFromTitle: + final date = parseUnknownDateFormat(bestTitle); + return date != null ? DateModifier.setCustom(mainMetadataDate(), date) : null; + case DateEditAction.setCustom: + return DateModifier.setCustom(mainMetadataDate(), modifier.setDateTime!); + case DateEditAction.shift: + case DateEditAction.remove: + return modifier; + } + } + + Future> _editXmp(void Function(List descriptions) apply) async { + final xmp = await metadataFetchService.getXmp(this); + final xmpString = xmp?.xmpString; + final extendedXmpString = xmp?.extendedXmpString; + + final editedXmpString = await XMP.edit( + xmpString, + () => PackageInfo.fromPlatform().then((v) => 'Aves v${v.version}'), + apply, + ); + + final editedXmp = AvesXmp(xmpString: editedXmpString, extendedXmpString: extendedXmpString); + return { + 'xmp': editedXmp.xmpString, + 'extendedXmp': editedXmp.extendedXmpString, + }; + } +} diff --git a/lib/model/entry_xmp_iptc.dart b/lib/model/entry_xmp_iptc.dart deleted file mode 100644 index f5a1a80f6..000000000 --- a/lib/model/entry_xmp_iptc.dart +++ /dev/null @@ -1,237 +0,0 @@ -import 'dart:convert'; - -import 'package:aves/model/entry.dart'; -import 'package:aves/ref/iptc.dart'; -import 'package:aves/services/common/services.dart'; -import 'package:collection/collection.dart'; -import 'package:equatable/equatable.dart'; -import 'package:flutter/foundation.dart'; -import 'package:intl/intl.dart'; -import 'package:package_info_plus/package_info_plus.dart'; -import 'package:xml/xml.dart'; - -extension ExtraAvesEntryXmpIptc on AvesEntry { - static const dcNamespace = 'http://purl.org/dc/elements/1.1/'; - static const rdfNamespace = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'; - static const xNamespace = 'adobe:ns:meta/'; - static const xmpNamespace = 'http://ns.adobe.com/xap/1.0/'; - static const xmpNoteNamespace = 'http://ns.adobe.com/xmp/note/'; - - static const xmlnsPrefix = 'xmlns'; - - static final nsDefaultPrefixes = { - dcNamespace: 'dc', - rdfNamespace: 'rdf', - xNamespace: 'x', - xmpNamespace: 'xmp', - xmpNoteNamespace: 'xmpNote', - }; - - // elements - static const xXmpmeta = 'xmpmeta'; - static const rdfRoot = 'RDF'; - static const rdfDescription = 'Description'; - static const dcSubject = 'subject'; - - // attributes - static const xXmptk = 'xmptk'; - static const rdfAbout = 'about'; - static const xmpMetadataDate = 'MetadataDate'; - static const xmpModifyDate = 'ModifyDate'; - static const xmpNoteHasExtendedXMP = 'HasExtendedXMP'; - - static String prefixOf(String ns) => nsDefaultPrefixes[ns] ?? ''; - - Future> editTags(Set tags) async { - final xmp = await metadataFetchService.getXmp(this); - final extendedXmpString = xmp?.extendedXmpString; - - XmlDocument? xmpDoc; - if (xmp != null) { - final xmpString = xmp.xmpString; - if (xmpString != null) { - xmpDoc = XmlDocument.parse(xmpString); - } - } - if (xmpDoc == null) { - final toolkit = 'Aves v${(await PackageInfo.fromPlatform()).version}'; - final builder = XmlBuilder(); - builder.namespace(xNamespace, prefixOf(xNamespace)); - builder.element(xXmpmeta, namespace: xNamespace, namespaces: { - xNamespace: prefixOf(xNamespace), - }, attributes: { - '${prefixOf(xNamespace)}:$xXmptk': toolkit, - }); - xmpDoc = builder.buildDocument(); - } - - final root = xmpDoc.rootElement; - XmlNode? rdf = root.getElement(rdfRoot, namespace: rdfNamespace); - if (rdf == null) { - final builder = XmlBuilder(); - builder.namespace(rdfNamespace, prefixOf(rdfNamespace)); - builder.element(rdfRoot, namespace: rdfNamespace, namespaces: { - rdfNamespace: prefixOf(rdfNamespace), - }); - // get element because doc fragment cannot be used to edit - root.children.add(builder.buildFragment()); - rdf = root.getElement(rdfRoot, namespace: rdfNamespace)!; - } - - XmlNode? description = rdf.getElement(rdfDescription, namespace: rdfNamespace); - if (description == null) { - final builder = XmlBuilder(); - builder.namespace(rdfNamespace, prefixOf(rdfNamespace)); - builder.element(rdfDescription, namespace: rdfNamespace, attributes: { - '${prefixOf(rdfNamespace)}:$rdfAbout': '', - }); - rdf.children.add(builder.buildFragment()); - // get element because doc fragment cannot be used to edit - description = rdf.getElement(rdfDescription, namespace: rdfNamespace)!; - } - _setNamespaces(description, { - dcNamespace: prefixOf(dcNamespace), - xmpNamespace: prefixOf(xmpNamespace), - }); - - _setStringBag(description, dcSubject, tags, namespace: dcNamespace); - - if (_isMeaningfulXmp(rdf)) { - final modifyDate = DateTime.now(); - description.setAttribute('${prefixOf(xmpNamespace)}:$xmpMetadataDate', _toXmpDate(modifyDate)); - description.setAttribute('${prefixOf(xmpNamespace)}:$xmpModifyDate', _toXmpDate(modifyDate)); - } else { - // clear XMP if there are no attributes or elements worth preserving - xmpDoc = null; - } - - final editedXmp = AvesXmp( - xmpString: xmpDoc?.toXmlString(), - extendedXmpString: extendedXmpString, - ); - - if (canEditIptc) { - final iptc = await metadataFetchService.getIptc(this); - if (iptc != null) { - await _setIptcKeywords(iptc, tags); - } - } - - final newFields = await metadataEditService.setXmp(this, editedXmp); - return newFields.isEmpty ? {} : {EntryDataType.catalog}; - } - - Future _setIptcKeywords(List> iptc, Set tags) async { - iptc.removeWhere((v) => v['record'] == IPTC.applicationRecord && v['tag'] == IPTC.keywordsTag); - iptc.add({ - 'record': IPTC.applicationRecord, - 'tag': IPTC.keywordsTag, - 'values': tags.map((v) => utf8.encode(v)).toList(), - }); - await metadataEditService.setIptc(this, iptc, postEditScan: false); - } - - int _meaningfulChildrenCount(XmlNode node) => node.children.where((v) => v.nodeType != XmlNodeType.TEXT || v.text.trim().isNotEmpty).length; - - bool _isMeaningfulXmp(XmlNode rdf) { - if (_meaningfulChildrenCount(rdf) > 1) return true; - - final description = rdf.getElement(rdfDescription, namespace: rdfNamespace); - if (description == null) return true; - - if (_meaningfulChildrenCount(description) > 0) return true; - - final hasMeaningfulAttributes = description.attributes.any((v) { - switch (v.name.local) { - case rdfAbout: - return v.value.isNotEmpty; - case xmpMetadataDate: - case xmpModifyDate: - return false; - default: - switch (v.name.prefix) { - case xmlnsPrefix: - return false; - default: - // if the attribute got defined with the prefix as part of the name, - // the prefix is not recognized as such, so we check the full name - return !v.name.qualified.startsWith(xmlnsPrefix); - } - } - }); - return hasMeaningfulAttributes; - } - - // return time zone designator, formatted as `Z` or `+hh:mm` or `-hh:mm` - // as of intl v0.17.0, formatting time zone offset is not implemented - String _xmpTimeZoneDesignator(DateTime date) { - final offsetMinutes = date.timeZoneOffset.inMinutes; - final abs = offsetMinutes.abs(); - final h = abs ~/ Duration.minutesPerHour; - final m = abs % Duration.minutesPerHour; - return '${offsetMinutes.isNegative ? '-' : '+'}${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}'; - } - - String _toXmpDate(DateTime date) => '${DateFormat('yyyy-MM-ddTHH:mm:ss').format(date)}${_xmpTimeZoneDesignator(date)}'; - - void _setNamespaces(XmlNode node, Map namespaces) => namespaces.forEach((uri, prefix) => node.setAttribute('$xmlnsPrefix:$prefix', uri)); - - void _setStringBag(XmlNode node, String name, Set values, {required String namespace}) { - // remove existing - node.findElements(name, namespace: namespace).toSet().forEach(node.children.remove); - - if (values.isNotEmpty) { - // add new bag - final rootBuilder = XmlBuilder(); - rootBuilder.namespace(namespace, prefixOf(namespace)); - rootBuilder.element(name, namespace: namespace); - node.children.add(rootBuilder.buildFragment()); - - final bagBuilder = XmlBuilder(); - bagBuilder.namespace(rdfNamespace, prefixOf(rdfNamespace)); - bagBuilder.element('Bag', namespace: rdfNamespace, nest: () { - values.forEach((v) { - bagBuilder.element('li', namespace: rdfNamespace, nest: v); - }); - }); - node.children.last.children.add(bagBuilder.buildFragment()); - } - } -} - -@immutable -class AvesXmp extends Equatable { - final String? xmpString; - final String? extendedXmpString; - - @override - List get props => [xmpString, extendedXmpString]; - - const AvesXmp({ - required this.xmpString, - this.extendedXmpString, - }); - - static AvesXmp? fromList(List xmpStrings) { - switch (xmpStrings.length) { - case 0: - return null; - case 1: - return AvesXmp(xmpString: xmpStrings.single); - default: - final byExtending = groupBy(xmpStrings, (v) => v.contains(':HasExtendedXMP=')); - final extending = byExtending[true] ?? []; - final extension = byExtending[false] ?? []; - if (extending.length == 1 && extension.length == 1) { - return AvesXmp( - xmpString: extending.single, - extendedXmpString: extension.single, - ); - } - - // take the first XMP and ignore the rest when the file is weirdly constructed - debugPrint('warning: entry has ${xmpStrings.length} XMP directories, xmpStrings=$xmpStrings'); - return AvesXmp(xmpString: xmpStrings.firstOrNull); - } - } -} diff --git a/lib/model/favourites.dart b/lib/model/favourites.dart index 4a8e225e8..aef618695 100644 --- a/lib/model/favourites.dart +++ b/lib/model/favourites.dart @@ -21,7 +21,7 @@ class Favourites with ChangeNotifier { FavouriteRow _entryToRow(AvesEntry entry) => FavouriteRow(contentId: entry.contentId!, path: entry.path!); - Future add(Iterable entries) async { + Future add(Set entries) async { final newRows = entries.map(_entryToRow); await metadataDb.addFavourites(newRows); @@ -30,7 +30,7 @@ class Favourites with ChangeNotifier { notifyListeners(); } - Future remove(Iterable entries) async { + Future remove(Set entries) async { final contentIds = entries.map((entry) => entry.contentId).toSet(); final removedRows = _rows.where((row) => contentIds.contains(row.contentId)).toSet(); diff --git a/lib/model/filters/filters.dart b/lib/model/filters/filters.dart index 2da4daaa9..f2389dce7 100644 --- a/lib/model/filters/filters.dart +++ b/lib/model/filters/filters.dart @@ -8,6 +8,7 @@ import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/filters/path.dart'; import 'package:aves/model/filters/query.dart'; +import 'package:aves/model/filters/rating.dart'; import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/filters/type.dart'; import 'package:aves/utils/color_utils.dart'; @@ -26,6 +27,7 @@ abstract class CollectionFilter extends Equatable implements Comparable get props => [rating]; + + const RatingFilter(this.rating); + + RatingFilter.fromMap(Map json) + : this( + json['rating'] ?? 0, + ); + + @override + Map toMap() => { + 'type': type, + 'rating': rating, + }; + + @override + EntryFilter get test => (entry) => entry.rating == rating; + + @override + String get universalLabel => '$rating'; + + @override + String getLabel(BuildContext context) => formatRating(context, rating); + + @override + Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) { + switch (rating) { + case -1: + return Icon(AIcons.ratingRejected, size: size); + case 0: + return Icon(AIcons.ratingUnrated, size: size); + default: + return null; + } + } + + @override + String get category => type; + + @override + String get key => '$type-$rating'; + + static String formatRating(BuildContext context, int rating) { + switch (rating) { + case -1: + return context.l10n.filterRatingRejectedLabel; + case 0: + return context.l10n.filterRatingUnratedLabel; + default: + return '\u2B50' * rating; + } + } +} diff --git a/lib/model/filters/tag.dart b/lib/model/filters/tag.dart index cda7ef146..1ec3b2a4d 100644 --- a/lib/model/filters/tag.dart +++ b/lib/model/filters/tag.dart @@ -44,7 +44,7 @@ class TagFilter extends CollectionFilter { String getLabel(BuildContext context) => tag.isEmpty ? context.l10n.filterTagEmptyLabel : tag; @override - Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => showGenericIcon ? Icon(tag.isEmpty ? AIcons.tagOff : AIcons.tag, size: size) : null; + Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => showGenericIcon ? Icon(tag.isEmpty ? AIcons.tagUntagged : AIcons.tag, size: size) : null; @override String get category => type; diff --git a/lib/model/metadata/catalog.dart b/lib/model/metadata/catalog.dart index 1c532673e..008065451 100644 --- a/lib/model/metadata/catalog.dart +++ b/lib/model/metadata/catalog.dart @@ -9,6 +9,7 @@ class CatalogMetadata { final String? mimeType, xmpSubjects, xmpTitleDescription; double? latitude, longitude; Address? address; + int rating; static const double _precisionErrorTolerance = 1e-9; static const _isAnimatedMask = 1 << 0; @@ -31,6 +32,7 @@ class CatalogMetadata { this.xmpTitleDescription, double? latitude, double? longitude, + this.rating = 0, }) { // Geocoder throws an `IllegalArgumentException` when a coordinate has a funky value like `1.7056881853375E7` // We also exclude zero coordinates, taking into account precision errors (e.g. {5.952380952380953e-11,-2.7777777777777777e-10}), @@ -67,6 +69,7 @@ class CatalogMetadata { xmpTitleDescription: xmpTitleDescription, latitude: latitude, longitude: longitude, + rating: rating, ); } @@ -87,6 +90,7 @@ class CatalogMetadata { xmpTitleDescription: map['xmpTitleDescription'] ?? '', latitude: map['latitude'], longitude: map['longitude'], + rating: map['rating'] ?? 0, ); } @@ -100,8 +104,9 @@ class CatalogMetadata { 'xmpTitleDescription': xmpTitleDescription, 'latitude': latitude, 'longitude': longitude, + 'rating': rating, }; @override - String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, is360=$is360, isMultiPage=$isMultiPage, rotationDegrees=$rotationDegrees, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}'; + String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, is360=$is360, isMultiPage=$isMultiPage, rotationDegrees=$rotationDegrees, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription, latitude=$latitude, longitude=$longitude, rating=$rating}'; } diff --git a/lib/model/metadata/date_modifier.dart b/lib/model/metadata/date_modifier.dart index 9c7e364f7..86c312f62 100644 --- a/lib/model/metadata/date_modifier.dart +++ b/lib/model/metadata/date_modifier.dart @@ -4,17 +4,45 @@ import 'package:flutter/widgets.dart'; @immutable class DateModifier { - static const allDateFields = [ + static const writableDateFields = [ MetadataField.exifDate, MetadataField.exifDateOriginal, MetadataField.exifDateDigitized, MetadataField.exifGpsDate, + MetadataField.xmpCreateDate, ]; final DateEditAction action; final Set fields; - final DateTime? dateTime; + final DateTime? setDateTime; + final DateFieldSource? copyFieldSource; final int? shiftMinutes; - const DateModifier(this.action, this.fields, {this.dateTime, this.shiftMinutes}); + const DateModifier._private( + this.action, + this.fields, { + this.setDateTime, + this.copyFieldSource, + this.shiftMinutes, + }); + + factory DateModifier.setCustom(Set fields, DateTime dateTime) { + return DateModifier._private(DateEditAction.setCustom, fields, setDateTime: dateTime); + } + + factory DateModifier.copyField(Set fields, DateFieldSource copyFieldSource) { + return DateModifier._private(DateEditAction.copyField, fields, copyFieldSource: copyFieldSource); + } + + factory DateModifier.extractFromTitle(Set fields) { + return DateModifier._private(DateEditAction.extractFromTitle, fields); + } + + factory DateModifier.shift(Set fields, int shiftMinutes) { + return DateModifier._private(DateEditAction.shift, fields, shiftMinutes: shiftMinutes); + } + + factory DateModifier.remove(Set fields) { + return DateModifier._private(DateEditAction.remove, fields); + } } diff --git a/lib/model/metadata/enums.dart b/lib/model/metadata/enums.dart index 145ce2ea7..530dc932b 100644 --- a/lib/model/metadata/enums.dart +++ b/lib/model/metadata/enums.dart @@ -3,13 +3,23 @@ enum MetadataField { exifDateOriginal, exifDateDigitized, exifGpsDate, + xmpCreateDate, } enum DateEditAction { - set, - shift, + setCustom, + copyField, extractFromTitle, - clear, + shift, + remove, +} + +enum DateFieldSource { + fileModifiedDate, + exifDate, + exifDateOriginal, + exifDateDigitized, + exifGpsDate, } enum MetadataType { @@ -56,7 +66,7 @@ class MetadataTypes { } extension ExtraMetadataType on MetadataType { - // match `ExifInterface` directory names + // match `metadata-extractor` directory names String getText() { switch (this) { case MetadataType.comment: @@ -80,3 +90,49 @@ 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) { + case DateFieldSource.fileModifiedDate: + return null; + case DateFieldSource.exifDate: + return MetadataField.exifDate; + case DateFieldSource.exifDateOriginal: + return MetadataField.exifDateOriginal; + case DateFieldSource.exifDateDigitized: + return MetadataField.exifDateDigitized; + case DateFieldSource.exifGpsDate: + return MetadataField.exifGpsDate; + } + } +} diff --git a/lib/model/metadata_db.dart b/lib/model/metadata_db.dart index bb0a63201..ca9f09623 100644 --- a/lib/model/metadata_db.dart +++ b/lib/model/metadata_db.dart @@ -145,6 +145,7 @@ class SqfliteMetadataDb implements MetadataDb { ', xmpTitleDescription TEXT' ', latitude REAL' ', longitude REAL' + ', rating INTEGER' ')'); await db.execute('CREATE TABLE $addressTable(' 'contentId INTEGER PRIMARY KEY' @@ -168,7 +169,7 @@ class SqfliteMetadataDb implements MetadataDb { ')'); }, onUpgrade: MetadataDbUpgrader.upgradeDb, - version: 5, + version: 6, ); } diff --git a/lib/model/metadata_db_upgrade.dart b/lib/model/metadata_db_upgrade.dart index 578934b8d..d69e7857c 100644 --- a/lib/model/metadata_db_upgrade.dart +++ b/lib/model/metadata_db_upgrade.dart @@ -25,6 +25,9 @@ class MetadataDbUpgrader { case 4: await _upgradeFrom4(db); break; + case 5: + await _upgradeFrom5(db); + break; } oldVersion++; } @@ -121,4 +124,9 @@ class MetadataDbUpgrader { ', resumeTimeMillis INTEGER' ')'); } + + static Future _upgradeFrom5(Database db) async { + debugPrint('upgrading DB from v5'); + await db.execute('ALTER TABLE $metadataTable ADD COLUMN rating INTEGER;'); + } } diff --git a/lib/model/settings/defaults.dart b/lib/model/settings/defaults.dart index 96ff24077..9f7a6f968 100644 --- a/lib/model/settings/defaults.dart +++ b/lib/model/settings/defaults.dart @@ -43,8 +43,10 @@ class SettingsDefaults { EntrySetAction.share, EntrySetAction.delete, ]; + static const showThumbnailFavourite = true; static const showThumbnailLocation = true; static const showThumbnailMotionPhoto = true; + static const showThumbnailRating = true; static const showThumbnailRaw = true; static const showThumbnailVideoDuration = true; diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index f76a97765..1b7f9c872 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -61,8 +61,10 @@ class Settings extends ChangeNotifier { static const collectionSortFactorKey = 'collection_sort_factor'; static const collectionBrowsingQuickActionsKey = 'collection_browsing_quick_actions'; static const collectionSelectionQuickActionsKey = 'collection_selection_quick_actions'; + static const showThumbnailFavouriteKey = 'show_thumbnail_favourite'; static const showThumbnailLocationKey = 'show_thumbnail_location'; static const showThumbnailMotionPhotoKey = 'show_thumbnail_motion_photo'; + static const showThumbnailRatingKey = 'show_thumbnail_rating'; static const showThumbnailRawKey = 'show_thumbnail_raw'; static const showThumbnailVideoDurationKey = 'show_thumbnail_video_duration'; @@ -302,6 +304,10 @@ class Settings extends ChangeNotifier { set collectionSelectionQuickActions(List newValue) => setAndNotify(collectionSelectionQuickActionsKey, newValue.map((v) => v.toString()).toList()); + bool get showThumbnailFavourite => getBoolOrDefault(showThumbnailFavouriteKey, SettingsDefaults.showThumbnailFavourite); + + set showThumbnailFavourite(bool newValue) => setAndNotify(showThumbnailFavouriteKey, newValue); + bool get showThumbnailLocation => getBoolOrDefault(showThumbnailLocationKey, SettingsDefaults.showThumbnailLocation); set showThumbnailLocation(bool newValue) => setAndNotify(showThumbnailLocationKey, newValue); @@ -310,6 +316,10 @@ class Settings extends ChangeNotifier { set showThumbnailMotionPhoto(bool newValue) => setAndNotify(showThumbnailMotionPhotoKey, newValue); + bool get showThumbnailRating => getBoolOrDefault(showThumbnailRatingKey, SettingsDefaults.showThumbnailRating); + + set showThumbnailRating(bool newValue) => setAndNotify(showThumbnailRatingKey, newValue); + bool get showThumbnailRaw => getBoolOrDefault(showThumbnailRawKey, SettingsDefaults.showThumbnailRaw); set showThumbnailRaw(bool newValue) => setAndNotify(showThumbnailRawKey, newValue); @@ -617,8 +627,10 @@ class Settings extends ChangeNotifier { case isInstalledAppAccessAllowedKey: case isErrorReportingAllowedKey: case mustBackTwiceToExitKey: + case showThumbnailFavouriteKey: case showThumbnailLocationKey: case showThumbnailMotionPhotoKey: + case showThumbnailRatingKey: case showThumbnailRawKey: case showThumbnailVideoDurationKey: case showOverlayOnOpeningKey: diff --git a/lib/model/source/collection_lens.dart b/lib/model/source/collection_lens.dart index 59166c762..f94a085a8 100644 --- a/lib/model/source/collection_lens.dart +++ b/lib/model/source/collection_lens.dart @@ -9,6 +9,7 @@ import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/query.dart'; +import 'package:aves/model/filters/rating.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/events.dart'; @@ -108,15 +109,27 @@ class CollectionLens with ChangeNotifier { } bool get showHeaders { - if (sortFactor == EntrySortFactor.size) return false; + bool showAlbumHeaders() => !filters.any((f) => f is AlbumFilter); - if (sortFactor == EntrySortFactor.date && sectionFactor == EntryGroupFactor.none) return false; - - final albumSections = sortFactor == EntrySortFactor.name || (sortFactor == EntrySortFactor.date && sectionFactor == EntryGroupFactor.album); - final filterByAlbum = filters.any((f) => f is AlbumFilter); - if (albumSections && filterByAlbum) return false; - - return true; + switch (sortFactor) { + case EntrySortFactor.date: + switch (sectionFactor) { + case EntryGroupFactor.none: + return false; + case EntryGroupFactor.album: + return showAlbumHeaders(); + case EntryGroupFactor.month: + return true; + case EntryGroupFactor.day: + return true; + } + case EntrySortFactor.name: + return showAlbumHeaders(); + case EntrySortFactor.rating: + return !filters.any((f) => f is RatingFilter); + case EntrySortFactor.size: + return false; + } } void addFilter(CollectionFilter filter) { @@ -181,12 +194,15 @@ class CollectionLens with ChangeNotifier { case EntrySortFactor.date: _filteredSortedEntries.sort(AvesEntry.compareByDate); break; - case EntrySortFactor.size: - _filteredSortedEntries.sort(AvesEntry.compareBySize); - break; case EntrySortFactor.name: _filteredSortedEntries.sort(AvesEntry.compareByName); break; + case EntrySortFactor.rating: + _filteredSortedEntries.sort(AvesEntry.compareByRating); + break; + case EntrySortFactor.size: + _filteredSortedEntries.sort(AvesEntry.compareBySize); + break; } } @@ -210,15 +226,18 @@ class CollectionLens with ChangeNotifier { break; } break; + case EntrySortFactor.name: + final byAlbum = groupBy(_filteredSortedEntries, (entry) => EntryAlbumSectionKey(entry.directory)); + sections = SplayTreeMap>.of(byAlbum, (a, b) => source.compareAlbumsByName(a.directory!, b.directory!)); + break; + case EntrySortFactor.rating: + sections = groupBy(_filteredSortedEntries, (entry) => EntryRatingSectionKey(entry.rating)); + break; case EntrySortFactor.size: sections = Map.fromEntries([ MapEntry(const SectionKey(), _filteredSortedEntries), ]); break; - case EntrySortFactor.name: - final byAlbum = groupBy(_filteredSortedEntries, (entry) => EntryAlbumSectionKey(entry.directory)); - sections = SplayTreeMap>.of(byAlbum, (a, b) => source.compareAlbumsByName(a.directory!, b.directory!)); - break; } sections = Map.unmodifiable(sections); _sortedEntries = null; diff --git a/lib/model/source/enums.dart b/lib/model/source/enums.dart index ec4f816b5..e39413950 100644 --- a/lib/model/source/enums.dart +++ b/lib/model/source/enums.dart @@ -4,7 +4,7 @@ enum ChipSortFactor { date, name, count } enum AlbumChipGroupFactor { none, importance, volume } -enum EntrySortFactor { date, size, name } +enum EntrySortFactor { date, name, rating, size } enum EntryGroupFactor { none, album, month, day } diff --git a/lib/model/source/section_keys.dart b/lib/model/source/section_keys.dart index fd455cf02..52cfb4879 100644 --- a/lib/model/source/section_keys.dart +++ b/lib/model/source/section_keys.dart @@ -23,3 +23,12 @@ class EntryDateSectionKey extends SectionKey with EquatableMixin { const EntryDateSectionKey(this.date); } + +class EntryRatingSectionKey extends SectionKey with EquatableMixin { + final int rating; + + @override + List get props => [rating]; + + const EntryRatingSectionKey(this.rating); +} diff --git a/lib/model/video/metadata.dart b/lib/model/video/metadata.dart index be38b825d..924d45918 100644 --- a/lib/model/video/metadata.dart +++ b/lib/model/video/metadata.dart @@ -22,7 +22,7 @@ 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 _anotherDatePattern = RegExp(r'(\d{4})[-/](\d{2})[-/](\d{2}) (\d{2}):(\d{2}):(\d{2})'); static final _durationPattern = RegExp(r'(\d+):(\d+):(\d+)(.\d+)'); static final _locationPattern = RegExp(r'([+-][.0-9]+)'); static final Map _codecNames = { @@ -112,9 +112,10 @@ class VideoMetadataFormatter { return date.millisecondsSinceEpoch; } - // `DateTime` does not recognize: + // `DateTime` does not recognize these values found in the wild: // - `UTC 2021-05-30 19:14:21` - // - `2021` + // - `2021/10/31 21:23:17` + // - `2021` (not enough to build a date) final match = _anotherDatePattern.firstMatch(dateString); if (match != null) { diff --git a/lib/ref/mime_types.dart b/lib/ref/mime_types.dart index 99f98642f..2b43ebf5e 100644 --- a/lib/ref/mime_types.dart +++ b/lib/ref/mime_types.dart @@ -2,6 +2,7 @@ class MimeTypes { static const anyImage = 'image/*'; static const bmp = 'image/bmp'; + static const bmpX = 'image/x-ms-bmp'; static const gif = 'image/gif'; static const heic = 'image/heic'; static const heif = 'image/heif'; @@ -43,6 +44,8 @@ class MimeTypes { static const avi = 'video/avi'; static const aviVnd = 'video/vnd.avi'; + static const flv = 'video/flv'; + static const flvX = 'video/x-flv'; static const mkv = 'video/x-matroska'; static const mov = 'video/quicktime'; static const mp2t = 'video/mp2t'; // .m2ts, .ts @@ -62,7 +65,7 @@ class MimeTypes { // groups // formats that support transparency - static const Set alphaImages = {bmp, gif, ico, png, svg, tiff, webp}; + static const Set alphaImages = {bmp, bmpX, gif, ico, png, svg, tiff, webp}; static const Set rawImages = {arw, cr2, crw, dcr, dng, erf, k25, kdc, mrw, nef, nrw, orf, pef, raf, raw, rw2, sr2, srf, srw, x3f}; @@ -71,11 +74,33 @@ class MimeTypes { static const Set _knownOpaqueImages = {heic, heif, jpeg}; - static const Set _knownVideos = {avi, aviVnd, mkv, mov, mp2t, mp2ts, mp4, mpeg, ogv, webm}; + static const Set _knownVideos = {avi, aviVnd, flv, flvX, mkv, mov, mp2t, mp2ts, mp4, mpeg, ogv, webm}; static final Set knownMediaTypes = {..._knownOpaqueImages, ...alphaImages, ...rawImages, ...undecodableImages, ..._knownVideos}; static bool isImage(String mimeType) => mimeType.startsWith('image'); static bool isVideo(String mimeType) => mimeType.startsWith('video'); + + static bool refersToSameType(String a, b) { + switch (a) { + case avi: + case aviVnd: + return [avi, aviVnd].contains(b); + case bmp: + case bmpX: + return [bmp, bmpX].contains(b); + case flv: + case flvX: + return [flv, flvX].contains(b); + case heic: + case heif: + return [heic, heif].contains(b); + case psdVnd: + case psdX: + return [psdVnd, psdX].contains(b); + default: + return a == b; + } + } } diff --git a/lib/ref/xmp.dart b/lib/ref/xmp.dart deleted file mode 100644 index 53c6f1c30..000000000 --- a/lib/ref/xmp.dart +++ /dev/null @@ -1,52 +0,0 @@ -class XMP { - static const propNamespaceSeparator = ':'; - static const structFieldSeparator = '/'; - - // cf https://exiftool.org/TagNames/XMP.html - static const Map namespaces = { - 'acdsee': 'ACDSee', - 'adsml-at': 'AdsML', - 'aux': 'Exif Aux', - 'avm': 'Astronomy Visualization', - 'Camera': 'Camera', - 'cc': 'Creative Commons', - 'crd': 'Camera Raw Defaults', - 'creatorAtom': 'After Effects', - 'crs': 'Camera Raw Settings', - 'dc': 'Dublin Core', - 'drone-dji': 'DJI Drone', - 'dwc': 'Darwin Core', - 'exif': 'Exif', - 'exifEX': 'Exif Ex', - 'GettyImagesGIFT': 'Getty Images', - 'GAudio': 'Google Audio', - 'GDepth': 'Google Depth', - 'GImage': 'Google Image', - 'GIMP': 'GIMP', - 'GCamera': 'Google Camera', - 'GCreations': 'Google Creations', - 'GFocus': 'Google Focus', - 'GPano': 'Google Panorama', - 'illustrator': 'Illustrator', - 'Iptc4xmpCore': 'IPTC Core', - 'Iptc4xmpExt': 'IPTC Extension', - 'lr': 'Lightroom', - 'MicrosoftPhoto': 'Microsoft Photo', - 'mwg-rs': 'Regions', - 'panorama': 'Panorama', - 'PanoStudioXMP': 'PanoramaStudio', - 'pdf': 'PDF', - 'pdfx': 'PDF/X', - 'photomechanic': 'Photo Mechanic', - 'photoshop': 'Photoshop', - 'plus': 'PLUS', - 'pmtm': 'Photomatix', - 'tiff': 'TIFF', - 'xmp': 'Basic', - 'xmpBJ': 'Basic Job Ticket', - 'xmpDM': 'Dynamic Media', - 'xmpMM': 'Media Management', - 'xmpRights': 'Rights Management', - 'xmpTPg': 'Paged-Text', - }; -} diff --git a/lib/services/device_service.dart b/lib/services/device_service.dart index 88cc22522..3360204f2 100644 --- a/lib/services/device_service.dart +++ b/lib/services/device_service.dart @@ -11,6 +11,8 @@ abstract class DeviceService { Future> getLocales(); Future getPerformanceClass(); + + Future isSystemFilePickerEnabled(); } class PlatformDeviceService implements DeviceService { @@ -60,7 +62,6 @@ class PlatformDeviceService implements DeviceService { @override Future getPerformanceClass() async { try { - await platform.invokeMethod('getPerformanceClass'); final result = await platform.invokeMethod('getPerformanceClass'); if (result != null) return result as int; } on PlatformException catch (e, stack) { @@ -68,4 +69,15 @@ class PlatformDeviceService implements DeviceService { } return 0; } + + @override + Future isSystemFilePickerEnabled() async { + try { + final result = await platform.invokeMethod('isSystemFilePickerEnabled'); + if (result != null) return result as bool; + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); + } + return false; + } } diff --git a/lib/services/metadata/metadata_edit_service.dart b/lib/services/metadata/metadata_edit_service.dart index 0bb1cfaa4..164b5d7e9 100644 --- a/lib/services/metadata/metadata_edit_service.dart +++ b/lib/services/metadata/metadata_edit_service.dart @@ -1,10 +1,10 @@ import 'dart:async'; import 'package:aves/model/entry.dart'; -import 'package:aves/model/entry_xmp_iptc.dart'; import 'package:aves/model/metadata/date_modifier.dart'; import 'package:aves/model/metadata/enums.dart'; import 'package:aves/services/common/services.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/services.dart'; abstract class MetadataEditService { @@ -12,11 +12,9 @@ abstract class MetadataEditService { Future> flip(AvesEntry entry); - Future> editDate(AvesEntry entry, DateModifier modifier); + Future> editExifDate(AvesEntry entry, DateModifier modifier); - Future> setIptc(AvesEntry entry, List>? iptc, {required bool postEditScan}); - - Future> setXmp(AvesEntry entry, AvesXmp? xmp); + Future> editMetadata(AvesEntry entry, Map modifier); Future> removeTypes(AvesEntry entry, Set types); } @@ -73,13 +71,13 @@ class PlatformMetadataEditService implements MetadataEditService { } @override - Future> editDate(AvesEntry entry, DateModifier modifier) async { + Future> editExifDate(AvesEntry entry, DateModifier modifier) async { try { final result = await platform.invokeMethod('editDate', { 'entry': _toPlatformEntryMap(entry), - 'dateMillis': modifier.dateTime?.millisecondsSinceEpoch, + 'dateMillis': modifier.setDateTime?.millisecondsSinceEpoch, 'shiftMinutes': modifier.shiftMinutes, - 'fields': modifier.fields.map(_toExifInterfaceTag).toList(), + 'fields': modifier.fields.where((v) => v.type == MetadataType.exif).map((v) => v.toExifInterfaceTag()).whereNotNull().toList(), }); if (result != null) return (result as Map).cast(); } on PlatformException catch (e, stack) { @@ -91,29 +89,11 @@ class PlatformMetadataEditService implements MetadataEditService { } @override - Future> setIptc(AvesEntry entry, List>? iptc, {required bool postEditScan}) async { + Future> editMetadata(AvesEntry entry, Map metadata) async { try { - final result = await platform.invokeMethod('setIptc', { + final result = await platform.invokeMethod('editMetadata', { 'entry': _toPlatformEntryMap(entry), - 'iptc': iptc, - 'postEditScan': postEditScan, - }); - if (result != null) return (result as Map).cast(); - } on PlatformException catch (e, stack) { - if (!entry.isMissingAtPath) { - await reportService.recordError(e, stack); - } - } - return {}; - } - - @override - Future> setXmp(AvesEntry entry, AvesXmp? xmp) async { - try { - final result = await platform.invokeMethod('setXmp', { - 'entry': _toPlatformEntryMap(entry), - 'xmp': xmp?.xmpString, - 'extendedXmp': xmp?.extendedXmpString, + 'metadata': metadata.map((type, value) => MapEntry(_toPlatformMetadataType(type), value)), }); if (result != null) return (result as Map).cast(); } on PlatformException catch (e, stack) { @@ -140,19 +120,6 @@ class PlatformMetadataEditService implements MetadataEditService { return {}; } - String _toExifInterfaceTag(MetadataField field) { - switch (field) { - case MetadataField.exifDate: - return 'DateTime'; - case MetadataField.exifDateOriginal: - return 'DateTimeOriginal'; - case MetadataField.exifDateDigitized: - return 'DateTimeDigitized'; - case MetadataField.exifGpsDate: - return 'GPSDateStamp'; - } - } - String _toPlatformMetadataType(MetadataType type) { switch (type) { case MetadataType.comment: diff --git a/lib/services/metadata/metadata_fetch_service.dart b/lib/services/metadata/metadata_fetch_service.dart index 4ed8210d9..5a665af3d 100644 --- a/lib/services/metadata/metadata_fetch_service.dart +++ b/lib/services/metadata/metadata_fetch_service.dart @@ -1,11 +1,12 @@ import 'package:aves/model/entry.dart'; -import 'package:aves/model/entry_xmp_iptc.dart'; import 'package:aves/model/metadata/catalog.dart'; +import 'package:aves/model/metadata/enums.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:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -28,6 +29,8 @@ abstract class MetadataFetchService { Future hasContentResolverProp(String prop); Future getContentResolverProp(AvesEntry entry, String prop); + + Future getDate(AvesEntry entry, MetadataField field); } class PlatformMetadataFetchService implements MetadataFetchService { @@ -63,6 +66,7 @@ class PlatformMetadataFetchService implements MetadataFetchService { // 'dateMillis': date taken in milliseconds since Epoch (long) // 'isAnimated': animated gif/webp (bool) // 'isFlipped': flipped according to EXIF orientation (bool) + // 'rating': rating in [-1,5] (int) // 'rotationDegrees': rotation degrees according to EXIF orientation or other metadata (int) // 'latitude': latitude (double) // 'longitude': longitude (double) @@ -223,4 +227,22 @@ class PlatformMetadataFetchService implements MetadataFetchService { } return null; } + + @override + Future getDate(AvesEntry entry, MetadataField field) async { + try { + final result = await platform.invokeMethod('getDate', { + 'mimeType': entry.mimeType, + 'uri': entry.uri, + 'sizeBytes': entry.sizeBytes, + 'field': field.toExifInterfaceTag(), + }); + if (result is int) return DateTime.fromMillisecondsSinceEpoch(result); + } on PlatformException catch (e, stack) { + if (!entry.isMissingAtPath) { + await reportService.recordError(e, stack); + } + } + return null; + } } diff --git a/lib/services/metadata/xmp.dart b/lib/services/metadata/xmp.dart new file mode 100644 index 000000000..a6be54191 --- /dev/null +++ b/lib/services/metadata/xmp.dart @@ -0,0 +1,40 @@ +import 'package:collection/collection.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; + +@immutable +class AvesXmp extends Equatable { + final String? xmpString; + final String? extendedXmpString; + + @override + List get props => [xmpString, extendedXmpString]; + + const AvesXmp({ + required this.xmpString, + this.extendedXmpString, + }); + + static AvesXmp? fromList(List xmpStrings) { + switch (xmpStrings.length) { + case 0: + return null; + case 1: + return AvesXmp(xmpString: xmpStrings.single); + default: + final byExtending = groupBy(xmpStrings, (v) => v.contains(':HasExtendedXMP=')); + final extending = byExtending[true] ?? []; + final extension = byExtending[false] ?? []; + if (extending.length == 1 && extension.length == 1) { + return AvesXmp( + xmpString: extending.single, + extendedXmpString: extension.single, + ); + } + + // take the first XMP and ignore the rest when the file is weirdly constructed + debugPrint('warning: entry has ${xmpStrings.length} XMP directories, xmpStrings=$xmpStrings'); + return AvesXmp(xmpString: xmpStrings.firstOrNull); + } + } +} diff --git a/lib/theme/durations.dart b/lib/theme/durations.dart index 33060b455..92909675c 100644 --- a/lib/theme/durations.dart +++ b/lib/theme/durations.dart @@ -97,6 +97,7 @@ class DurationsProvider extends StatelessWidget { class DurationsData { // common animations final Duration expansionTileAnimation; + final Duration formTransition; final Duration iconAnimation; final Duration staggeredAnimation; final Duration staggeredAnimationPageTarget; @@ -111,6 +112,7 @@ class DurationsData { const DurationsData({ this.expansionTileAnimation = const Duration(milliseconds: 200), + this.formTransition = const Duration(milliseconds: 200), this.iconAnimation = const Duration(milliseconds: 300), this.staggeredAnimation = const Duration(milliseconds: 375), this.staggeredAnimationPageTarget = const Duration(milliseconds: 800), @@ -123,6 +125,7 @@ class DurationsData { return DurationsData( // as of Flutter v2.5.1, `ExpansionPanelList` throws if animation duration is zero expansionTileAnimation: const Duration(microseconds: 1), + formTransition: Duration.zero, iconAnimation: Duration.zero, staggeredAnimation: Duration.zero, staggeredAnimationPageTarget: Duration.zero, diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index 7bfb34635..c91a307b7 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -19,18 +19,22 @@ class AIcons { static const IconData home = Icons.home_outlined; static const IconData language = Icons.translate_outlined; static const IconData location = Icons.place_outlined; - static const IconData locationOff = Icons.location_off_outlined; + static const IconData locationUnlocated = Icons.location_off_outlined; static const IconData mainStorage = Icons.smartphone_outlined; static const IconData privacy = MdiIcons.shieldAccountOutline; + static const IconData rating = Icons.star_border_outlined; + static const IconData ratingFull = Icons.star; + static const IconData ratingRejected = MdiIcons.starMinusOutline; + static const IconData ratingUnrated = MdiIcons.starOffOutline; static const IconData raw = Icons.raw_on_outlined; static const IconData shooting = Icons.camera_outlined; static const IconData removableStorage = Icons.sd_storage_outlined; - static const IconData sensorControl = Icons.explore_outlined; - static const IconData sensorControlOff = Icons.explore_off_outlined; + static const IconData sensorControlEnabled = Icons.explore_outlined; + static const IconData sensorControlDisabled = Icons.explore_off_outlined; static const IconData settings = Icons.settings_outlined; static const IconData text = Icons.format_quote_outlined; static const IconData tag = Icons.local_offer_outlined; - static const IconData tagOff = MdiIcons.tagOffOutline; + static const IconData tagUntagged = MdiIcons.tagOffOutline; // view static const IconData group = Icons.group_work_outlined; @@ -40,7 +44,6 @@ class AIcons { // actions static const IconData add = Icons.add_circle_outline; static const IconData addShortcut = Icons.add_to_home_screen_outlined; - static const IconData addTag = MdiIcons.tagPlusOutline; static const IconData cancel = Icons.cancel_outlined; static const IconData replay10 = Icons.replay_10_outlined; static const IconData skip10 = Icons.forward_10_outlined; @@ -51,6 +54,8 @@ class AIcons { static const IconData debug = Icons.whatshot_outlined; static const IconData delete = Icons.delete_outlined; static const IconData edit = Icons.edit_outlined; + static const IconData editRating = MdiIcons.starPlusOutline; + static const IconData editTags = MdiIcons.tagPlusOutline; static const IconData export = MdiIcons.fileExportOutline; static const IconData flip = Icons.flip_outlined; static const IconData favourite = Icons.favorite_border; @@ -111,8 +116,8 @@ class AIcons { static const IconData geo = Icons.language_outlined; static const IconData motionPhoto = Icons.motion_photos_on_outlined; static const IconData multiPage = Icons.burst_mode_outlined; - static const IconData videoThumb = Icons.play_circle_outline; static const IconData threeSixty = Icons.threesixty_outlined; + static const IconData videoThumb = Icons.play_circle_outline; static const IconData selected = Icons.check_circle_outline; static const IconData unselected = Icons.radio_button_unchecked; diff --git a/lib/theme/themes.dart b/lib/theme/themes.dart index 5eca4f2f9..5dd13fc3b 100644 --- a/lib/theme/themes.dart +++ b/lib/theme/themes.dart @@ -34,9 +34,11 @@ class Themes { fontFeatures: [FontFeature.enable('smcp')], ), ), - colorScheme: const ColorScheme.dark( + colorScheme: ColorScheme.dark( primary: _accentColor, secondary: _accentColor, + // surface color is used as background for the date picker header + surface: Colors.grey.shade800, onPrimary: Colors.white, onSecondary: Colors.white, ), diff --git a/lib/utils/time_utils.dart b/lib/utils/time_utils.dart index cf36f57c6..6980430ba 100644 --- a/lib/utils/time_utils.dart +++ b/lib/utils/time_utils.dart @@ -18,7 +18,9 @@ final _unixStampMillisPattern = RegExp(r'\d{13}'); final _unixStampSecPattern = RegExp(r'\d{10}'); final _plainPattern = RegExp(r'(\d{8})([_-\s](\d{6})([_-\s](\d{3}))?)?'); -DateTime? parseUnknownDateFormat(String s) { +DateTime? parseUnknownDateFormat(String? s) { + if (s == null) return null; + var match = _unixStampMillisPattern.firstMatch(s); if (match != null) { final stampString = match.group(0); diff --git a/lib/utils/xmp_utils.dart b/lib/utils/xmp_utils.dart new file mode 100644 index 000000000..d897e476a --- /dev/null +++ b/lib/utils/xmp_utils.dart @@ -0,0 +1,289 @@ +import 'package:intl/intl.dart'; +import 'package:xml/xml.dart'; + +class Namespaces { + static const dc = 'http://purl.org/dc/elements/1.1/'; + static const microsoftPhoto = 'http://ns.microsoft.com/photo/1.0/'; + static const rdf = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'; + static const x = 'adobe:ns:meta/'; + static const xmp = 'http://ns.adobe.com/xap/1.0/'; + static const xmpNote = 'http://ns.adobe.com/xmp/note/'; + + static final defaultPrefixes = { + dc: 'dc', + microsoftPhoto: 'MicrosoftPhoto', + rdf: 'rdf', + x: 'x', + xmp: 'xmp', + xmpNote: 'xmpNote', + }; +} + +class XMP { + static const xmlnsPrefix = 'xmlns'; + static const propNamespaceSeparator = ':'; + static const structFieldSeparator = '/'; + + static String prefixOf(String ns) => Namespaces.defaultPrefixes[ns] ?? ''; + + // elements + static const xXmpmeta = 'xmpmeta'; + static const rdfRoot = 'RDF'; + static const rdfDescription = 'Description'; + static const dcSubject = 'subject'; + static const msPhotoRating = 'Rating'; + static const xmpRating = 'Rating'; + + // attributes + static const xXmptk = 'xmptk'; + static const rdfAbout = 'about'; + static const xmpCreateDate = 'CreateDate'; + static const xmpMetadataDate = 'MetadataDate'; + static const xmpModifyDate = 'ModifyDate'; + static const xmpNoteHasExtendedXMP = 'HasExtendedXMP'; + + // for `rdf:Description` node only + static bool _hasMeaningfulChildren(XmlNode node) => node.children.any((v) => v.nodeType != XmlNodeType.TEXT || v.text.trim().isNotEmpty); + + // for `rdf:Description` node only + static bool _hasMeaningfulAttributes(XmlNode description) { + final hasMeaningfulAttributes = description.attributes.any((v) { + switch (v.name.local) { + case rdfAbout: + case xmpMetadataDate: + case xmpModifyDate: + return false; + default: + switch (v.name.prefix) { + case xmlnsPrefix: + return false; + default: + // if the attribute got defined with the prefix as part of the name, + // the prefix is not recognized as such, so we check the full name + return !v.name.qualified.startsWith(xmlnsPrefix); + } + } + }); + return hasMeaningfulAttributes; + } + + // return time zone designator, formatted as `Z` or `+hh:mm` or `-hh:mm` + // as of intl v0.17.0, formatting time zone offset is not implemented + static String _xmpTimeZoneDesignator(DateTime date) { + final offsetMinutes = date.timeZoneOffset.inMinutes; + final abs = offsetMinutes.abs(); + final h = abs ~/ Duration.minutesPerHour; + final m = abs % Duration.minutesPerHour; + return '${offsetMinutes.isNegative ? '-' : '+'}${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}'; + } + + static String toXmpDate(DateTime date) => '${DateFormat('yyyy-MM-ddTHH:mm:ss').format(date)}${_xmpTimeZoneDesignator(date)}'; + + static String? getString( + List nodes, + String name, { + required String namespace, + }) { + for (final node in nodes) { + final attribute = node.getAttribute(name, namespace: namespace); + if (attribute != null) return attribute; + + final element = node.getElement(name, namespace: namespace); + if (element != null) return element.innerText; + } + return null; + } + + static void _addNamespaces(XmlNode node, Map namespaces) => namespaces.forEach((uri, prefix) => node.setAttribute('$xmlnsPrefix:$prefix', uri)); + + // remove elements and attributes + static bool _removeElements(List nodes, String name, String namespace) { + var removed = false; + nodes.forEach((node) { + final elements = node.findElements(name, namespace: namespace).toSet(); + if (elements.isNotEmpty) { + elements.forEach(node.children.remove); + removed = true; + } + + if (node.getAttributeNode(name, namespace: namespace) != null) { + node.removeAttribute(name, namespace: namespace); + removed = true; + } + }); + return removed; + } + + // remove attribute/element from all nodes, and set attribute with new value, if any, in the first node + static void setAttribute( + List nodes, + String name, + String? value, { + required String namespace, + required XmpEditStrategy strat, + }) { + final removed = _removeElements(nodes, name, namespace); + + if (value == null) return; + + if (strat == XmpEditStrategy.always || (strat == XmpEditStrategy.updateIfPresent && removed)) { + final node = nodes.first; + _addNamespaces(node, {namespace: prefixOf(namespace)}); + + // use qualified name, otherwise the namespace prefix is not added + final qualifiedName = '${prefixOf(namespace)}$propNamespaceSeparator$name'; + node.setAttribute(qualifiedName, value); + } + } + + // remove attribute/element from all nodes, and create element with new value, if any, in the first node + static void setElement( + List nodes, + String name, + String? value, { + required String namespace, + required XmpEditStrategy strat, + }) { + final removed = _removeElements(nodes, name, namespace); + + if (value == null) return; + + if (strat == XmpEditStrategy.always || (strat == XmpEditStrategy.updateIfPresent && removed)) { + final node = nodes.first; + _addNamespaces(node, {namespace: prefixOf(namespace)}); + + final builder = XmlBuilder(); + builder.namespace(namespace, prefixOf(namespace)); + builder.element(name, namespace: namespace, nest: () { + builder.text(value); + }); + node.children.add(builder.buildFragment()); + } + } + + // remove bag from all nodes, and create bag with new values, if any, in the first node + static void setStringBag( + List nodes, + String name, + Set values, { + required String namespace, + required XmpEditStrategy strat, + }) { + // remove existing + final removed = _removeElements(nodes, name, namespace); + + if (values.isEmpty) return; + + if (strat == XmpEditStrategy.always || (strat == XmpEditStrategy.updateIfPresent && removed)) { + final node = nodes.first; + _addNamespaces(node, {namespace: prefixOf(namespace)}); + + // add new bag + final rootBuilder = XmlBuilder(); + rootBuilder.namespace(namespace, prefixOf(namespace)); + rootBuilder.element(name, namespace: namespace); + node.children.add(rootBuilder.buildFragment()); + + final bagBuilder = XmlBuilder(); + bagBuilder.namespace(Namespaces.rdf, prefixOf(Namespaces.rdf)); + bagBuilder.element('Bag', namespace: Namespaces.rdf, nest: () { + values.forEach((v) { + bagBuilder.element('li', namespace: Namespaces.rdf, nest: v); + }); + }); + node.children.last.children.add(bagBuilder.buildFragment()); + } + } + + static Future edit( + String? xmpString, + Future Function() toolkit, + void Function(List descriptions) apply, { + DateTime? modifyDate, + }) async { + XmlDocument? xmpDoc; + if (xmpString != null) { + xmpDoc = XmlDocument.parse(xmpString); + } + if (xmpDoc == null) { + final builder = XmlBuilder(); + builder.namespace(Namespaces.x, prefixOf(Namespaces.x)); + builder.element(xXmpmeta, namespace: Namespaces.x, namespaces: { + Namespaces.x: prefixOf(Namespaces.x), + }, attributes: { + '${prefixOf(Namespaces.x)}$propNamespaceSeparator$xXmptk': await toolkit(), + }); + xmpDoc = builder.buildDocument(); + } + + final root = xmpDoc.rootElement; + XmlNode? rdf = root.getElement(rdfRoot, namespace: Namespaces.rdf); + if (rdf == null) { + final builder = XmlBuilder(); + builder.namespace(Namespaces.rdf, prefixOf(Namespaces.rdf)); + builder.element(rdfRoot, namespace: Namespaces.rdf, namespaces: { + Namespaces.rdf: prefixOf(Namespaces.rdf), + }); + // get element because doc fragment cannot be used to edit + root.children.add(builder.buildFragment()); + rdf = root.getElement(rdfRoot, namespace: Namespaces.rdf)!; + } + + // content can be split in multiple `rdf:Description` elements + List descriptions = rdf.children.where((node) { + return node is XmlElement && node.name.local == rdfDescription && node.name.namespaceUri == Namespaces.rdf; + }).toList(); + + if (descriptions.isEmpty) { + final builder = XmlBuilder(); + builder.namespace(Namespaces.rdf, prefixOf(Namespaces.rdf)); + builder.element(rdfDescription, namespace: Namespaces.rdf, attributes: { + '${prefixOf(Namespaces.rdf)}$propNamespaceSeparator$rdfAbout': '', + }); + rdf.children.add(builder.buildFragment()); + // get element because doc fragment cannot be used to edit + descriptions.add(rdf.getElement(rdfDescription, namespace: Namespaces.rdf)!); + } + apply(descriptions); + + // clean description nodes with no children + descriptions.where((v) => !_hasMeaningfulChildren(v)).forEach((v) => v.children.clear()); + + // remove superfluous description nodes + rdf.children.removeWhere((v) => !_hasMeaningfulChildren(v) && !_hasMeaningfulAttributes(v)); + + if (rdf.children.isNotEmpty) { + _addNamespaces(descriptions.first, {Namespaces.xmp: prefixOf(Namespaces.xmp)}); + final xmpDate = toXmpDate(modifyDate ?? DateTime.now()); + setAttribute(descriptions, xmpMetadataDate, xmpDate, namespace: Namespaces.xmp, strat: XmpEditStrategy.always); + setAttribute(descriptions, xmpModifyDate, xmpDate, namespace: Namespaces.xmp, strat: XmpEditStrategy.always); + } else { + // clear XMP if there are no attributes or elements worth preserving + xmpDoc = null; + } + + return xmpDoc?.toXmlString(); + } + + static String? toMsPhotoRating(int? rating) { + if (rating == null) return null; + switch (rating) { + case 5: + return '99'; + case 4: + return '75'; + case 3: + return '50'; + case 2: + return '25'; + case 1: + return '1'; + case 0: + return null; + case -1: + return '-1'; + } + } +} + +enum XmpEditStrategy { always, updateIfPresent } diff --git a/lib/widgets/about/credits.dart b/lib/widgets/about/credits.dart index cb2876ac7..35251d760 100644 --- a/lib/widgets/about/credits.dart +++ b/lib/widgets/about/credits.dart @@ -8,6 +8,7 @@ class AboutCredits extends StatelessWidget { static const translators = { 'Deutsch': 'JanWaldhorn', + 'Español (México)': 'n-berenice', 'Русский': 'D3ZOXY', }; diff --git a/lib/widgets/aves_app.dart b/lib/widgets/aves_app.dart index 80c6846b0..ef35eb8fb 100644 --- a/lib/widgets/aves_app.dart +++ b/lib/widgets/aves_app.dart @@ -106,6 +106,12 @@ class _AvesAppState extends State { home: home, navigatorObservers: _navigatorObservers, builder: (context, child) { + // Flutter has various page transition implementations for Android: + // - `FadeUpwardsPageTransitionsBuilder` on Oreo / API 27 and below + // - `OpenUpwardsPageTransitionsBuilder` on Pie / API 28 + // - `ZoomPageTransitionsBuilder` on Android 10 / API 29 and above + // As of Flutter v2.8.1, `FadeUpwardsPageTransitionsBuilder` is the default, regardless of versions. + // In practice, `ZoomPageTransitionsBuilder` feels unstable when transitioning from Album to Collection. if (!areAnimationsEnabled) { child = Theme( data: Theme.of(context).copyWith( diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index fe2f0a577..e8934726f 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -20,6 +20,8 @@ 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'; import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/favourite_toggler.dart'; +import 'package:aves/widgets/common/sliver_app_bar_title.dart'; import 'package:aves/widgets/dialogs/tile_view_dialog.dart'; import 'package:aves/widgets/search/search_delegate.dart'; import 'package:flutter/material.dart'; @@ -101,48 +103,42 @@ class _CollectionAppBarState extends State with SingleTickerPr @override Widget build(BuildContext context) { final appMode = context.watch>().value; - return Selector, Tuple2>( - selector: (context, selection) => Tuple2(selection.isSelecting, selection.selectedItems.length), - builder: (context, s, child) { - final isSelecting = s.item1; - final selectedItemCount = s.item2; - _isSelectingNotifier.value = isSelecting; - return AnimatedBuilder( - animation: collection.filterChangeNotifier, - builder: (context, child) { - final removableFilters = appMode != AppMode.pickInternal; - return Selector( - selector: (context, query) => query.enabled, - builder: (context, queryEnabled, child) { - return SliverAppBar( - leading: appMode.hasDrawer ? _buildAppBarLeading(isSelecting) : null, - title: _buildAppBarTitle(isSelecting), - actions: _buildActions( - isSelecting: isSelecting, - selectedItemCount: selectedItemCount, - ), - bottom: PreferredSize( - preferredSize: Size.fromHeight(appBarBottomHeight), - child: Column( - children: [ - if (showFilterBar) - FilterBar( - filters: collection.filters.where((v) => !(v is QueryFilter && v.live)).toSet(), - removable: removableFilters, - onTap: removableFilters ? collection.removeFilter : null, - ), - if (queryEnabled) - EntryQueryBar( - queryNotifier: context.select>((query) => query.queryNotifier), - focusNode: _queryBarFocusNode, - ) - ], - ), - ), - titleSpacing: 0, - floating: true, - ); - }, + final selection = context.watch>(); + final isSelecting = selection.isSelecting; + _isSelectingNotifier.value = isSelecting; + return AnimatedBuilder( + animation: collection.filterChangeNotifier, + builder: (context, child) { + final removableFilters = appMode != AppMode.pickInternal; + return Selector( + selector: (context, query) => query.enabled, + builder: (context, queryEnabled, child) { + return SliverAppBar( + leading: appMode.hasDrawer ? _buildAppBarLeading(isSelecting) : null, + title: SliverAppBarTitleWrapper( + child: _buildAppBarTitle(isSelecting), + ), + actions: _buildActions(selection), + bottom: PreferredSize( + preferredSize: Size.fromHeight(appBarBottomHeight), + child: Column( + children: [ + if (showFilterBar) + FilterBar( + filters: collection.filters.where((v) => !(v is QueryFilter && v.live)).toSet(), + removable: removableFilters, + onTap: removableFilters ? collection.removeFilter : null, + ), + if (queryEnabled) + EntryQueryBar( + queryNotifier: context.select>((query) => query.queryNotifier), + focusNode: _queryBarFocusNode, + ) + ], + ), + ), + titleSpacing: 0, + floating: true, ); }, ); @@ -177,7 +173,7 @@ class _CollectionAppBarState extends State with SingleTickerPr ); } - Widget? _buildAppBarTitle(bool isSelecting) { + Widget _buildAppBarTitle(bool isSelecting) { final l10n = context.l10n; if (isSelecting) { @@ -201,16 +197,15 @@ class _CollectionAppBarState extends State with SingleTickerPr } } - List _buildActions({ - required bool isSelecting, - required int selectedItemCount, - }) { + List _buildActions(Selection selection) { + final isSelecting = selection.isSelecting; + final selectedItemCount = selection.selectedItems.length; + final appMode = context.watch>().value; bool isVisible(EntrySetAction action) => _actionDelegate.isVisible( action, appMode: appMode, isSelecting: isSelecting, - sortFactor: collection.sortFactor, itemCount: collection.entryCount, selectedItemCount: selectedItemCount, ); @@ -225,7 +220,7 @@ class _CollectionAppBarState extends State with SingleTickerPr final browsingQuickActions = settings.collectionBrowsingQuickActions; final selectionQuickActions = settings.collectionSelectionQuickActions; final quickActionButtons = (isSelecting ? selectionQuickActions : browsingQuickActions).where(isVisible).map( - (action) => _toActionButton(action, enabled: canApply(action)), + (action) => _toActionButton(action, enabled: canApply(action), selection: selection), ); return [ @@ -236,14 +231,14 @@ class _CollectionAppBarState extends State with SingleTickerPr key: const Key('appbar-menu-button'), itemBuilder: (context) { final generalMenuItems = EntrySetActions.general.where(isVisible).map( - (action) => _toMenuItem(action, enabled: canApply(action)), + (action) => _toMenuItem(action, enabled: canApply(action), selection: selection), ); final browsingMenuActions = EntrySetActions.browsing.where((v) => !browsingQuickActions.contains(v)); final selectionMenuActions = EntrySetActions.selection.where((v) => !selectionQuickActions.contains(v)); final contextualMenuItems = [ ...(isSelecting ? selectionMenuActions : browsingMenuActions).where(isVisible).map( - (action) => _toMenuItem(action, enabled: canApply(action)), + (action) => _toMenuItem(action, enabled: canApply(action), selection: selection), ), if (isSelecting) PopupMenuItem( @@ -257,9 +252,10 @@ class _CollectionAppBarState extends State with SingleTickerPr _buildRotateAndFlipMenuItems(context, canApply: canApply), ...[ EntrySetAction.editDate, + EntrySetAction.editRating, EntrySetAction.editTags, EntrySetAction.removeMetadata, - ].map((action) => _toMenuItem(action, enabled: canApply(action))), + ].map((action) => _toMenuItem(action, enabled: canApply(action), selection: selection)), ], ), ), @@ -283,10 +279,14 @@ class _CollectionAppBarState extends State with SingleTickerPr ]; } + Set _getExpandedSelectedItems(Selection selection) { + return selection.selectedItems.expand((entry) => entry.burstEntries ?? {entry}).toSet(); + } + // key is expected by test driver (e.g. 'menu-configureView', 'menu-map') Key _getActionKey(EntrySetAction action) => Key('menu-${action.name}'); - Widget _toActionButton(EntrySetAction action, {required bool enabled}) { + Widget _toActionButton(EntrySetAction action, {required bool enabled, required Selection selection}) { final onPressed = enabled ? () => _onActionSelected(action) : null; switch (action) { case EntrySetAction.toggleTitleSearch: @@ -299,6 +299,11 @@ class _CollectionAppBarState extends State with SingleTickerPr ); }, ); + case EntrySetAction.toggleFavourite: + return FavouriteToggler( + entries: _getExpandedSelectedItems(selection), + onPressed: onPressed, + ); default: return IconButton( key: _getActionKey(action), @@ -309,7 +314,7 @@ class _CollectionAppBarState extends State with SingleTickerPr } } - PopupMenuItem _toMenuItem(EntrySetAction action, {required bool enabled}) { + PopupMenuItem _toMenuItem(EntrySetAction action, {required bool enabled, required Selection selection}) { late Widget child; switch (action) { case EntrySetAction.toggleTitleSearch: @@ -318,6 +323,12 @@ class _CollectionAppBarState extends State with SingleTickerPr isMenuItem: true, ); break; + case EntrySetAction.toggleFavourite: + child = FavouriteToggler( + entries: _getExpandedSelectedItems(selection), + isMenuItem: true, + ); + break; default: child = MenuRow(text: action.getText(context), icon: action.getIcon()); break; @@ -421,10 +432,12 @@ class _CollectionAppBarState extends State with SingleTickerPr 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.editRating: case EntrySetAction.editTags: case EntrySetAction.removeMetadata: _actionDelegate.onActionSelected(context, action); @@ -448,6 +461,7 @@ class _CollectionAppBarState extends State with SingleTickerPr EntrySortFactor.date: l10n.collectionSortDate, EntrySortFactor.size: l10n.collectionSortSize, EntrySortFactor.name: l10n.collectionSortName, + EntrySortFactor.rating: l10n.collectionSortRating, }, groupOptions: { EntryGroupFactor.album: l10n.collectionGroupAlbum, diff --git a/lib/widgets/collection/collection_grid.dart b/lib/widgets/collection/collection_grid.dart index 4df61a326..b1d647b86 100644 --- a/lib/widgets/collection/collection_grid.dart +++ b/lib/widgets/collection/collection_grid.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:aves/app_mode.dart'; import 'package:aves/model/entry.dart'; +import 'package:aves/model/favourites.dart'; import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/settings/settings.dart'; @@ -103,13 +104,18 @@ class _CollectionGridContent extends StatelessWidget { columnCount: columnCount, spacing: tileSpacing, tileExtent: thumbnailExtent, - tileBuilder: (entry) => InteractiveTile( - key: ValueKey(entry.contentId), - collection: collection, - entry: entry, - thumbnailExtent: thumbnailExtent, - tileLayout: tileLayout, - isScrollingNotifier: _isScrollingNotifier, + tileBuilder: (entry) => AnimatedBuilder( + animation: favourites, + builder: (context, child) { + return InteractiveTile( + key: ValueKey(entry.contentId), + collection: collection, + entry: entry, + thumbnailExtent: thumbnailExtent, + tileLayout: tileLayout, + isScrollingNotifier: _isScrollingNotifier, + ); + }, ), tileAnimationDelay: tileAnimationDelay, child: child!, diff --git a/lib/widgets/collection/draggable_thumb_label.dart b/lib/widgets/collection/draggable_thumb_label.dart index b96b7e599..90017bdc0 100644 --- a/lib/widgets/collection/draggable_thumb_label.dart +++ b/lib/widgets/collection/draggable_thumb_label.dart @@ -1,4 +1,5 @@ import 'package:aves/model/entry.dart'; +import 'package:aves/model/filters/rating.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; @@ -47,6 +48,11 @@ class CollectionDraggableThumbLabel extends StatelessWidget { if (_showAlbumName(context, entry)) _getAlbumName(context, entry), if (entry.bestTitle != null) entry.bestTitle!, ]; + case EntrySortFactor.rating: + return [ + RatingFilter.formatRating(context, entry.rating), + DraggableThumbLabel.formatMonthThumbLabel(context, entry.bestDate), + ]; case EntrySortFactor.size: return [ if (entry.sizeBytes != null) formatFileSize(context.l10n.localeName, entry.sizeBytes!, round: 0), diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index 9bb428159..dc5b75c6f 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -6,7 +6,8 @@ import 'package:aves/model/actions/entry_set_actions.dart'; import 'package:aves/model/actions/move_type.dart'; import 'package:aves/model/device.dart'; import 'package:aves/model/entry.dart'; -import 'package:aves/model/entry_xmp_iptc.dart'; +import 'package:aves/model/entry_metadata_edition.dart'; +import 'package:aves/model/favourites.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/highlight.dart'; @@ -15,7 +16,6 @@ import 'package:aves/model/selection.dart'; import 'package:aves/model/source/analysis_controller.dart'; 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/services/common/image_op_events.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/services/media/enums.dart'; @@ -44,7 +44,6 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa EntrySetAction action, { required AppMode appMode, required bool isSelecting, - required EntrySortFactor sortFactor, required int itemCount, required int selectedItemCount, }) { @@ -75,10 +74,12 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa 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.editRating: case EntrySetAction.editTags: case EntrySetAction.removeMetadata: return appMode == AppMode.main && isSelecting; @@ -116,10 +117,12 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa 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.editRating: case EntrySetAction.editTags: case EntrySetAction.removeMetadata: return hasSelection; @@ -167,6 +170,9 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa case EntrySetAction.rescan: _rescan(context); break; + case EntrySetAction.toggleFavourite: + _toggleFavourite(context); + break; case EntrySetAction.rotateCCW: _rotate(context, clockwise: false); break; @@ -179,6 +185,9 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa case EntrySetAction.editDate: _editDate(context); break; + case EntrySetAction.editRating: + _editRating(context); + break; case EntrySetAction.editTags: _editTags(context); break; @@ -211,6 +220,18 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa selection.browse(); } + Future _toggleFavourite(BuildContext context) async { + final selection = context.read>(); + final selectedItems = _getExpandedSelectedItems(selection); + if (selectedItems.every((entry) => entry.isFavourite)) { + await favourites.remove(selectedItems); + } else { + await favourites.add(selectedItems); + } + + selection.browse(); + } + Future _delete(BuildContext context) async { final source = context.read(); final selection = context.read>(); @@ -489,7 +510,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canRotateAndFlip); if (todoItems == null || todoItems.isEmpty) return; - await _edit(context, selection, todoItems, (entry) => entry.rotate(clockwise: clockwise, persist: true)); + await _edit(context, selection, todoItems, (entry) => entry.rotate(clockwise: clockwise)); } Future _flip(BuildContext context) async { @@ -499,7 +520,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canRotateAndFlip); if (todoItems == null || todoItems.isEmpty) return; - await _edit(context, selection, todoItems, (entry) => entry.flip(persist: true)); + await _edit(context, selection, todoItems, (entry) => entry.flip()); } Future _editDate(BuildContext context) async { @@ -515,6 +536,19 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa await _edit(context, selection, todoItems, (entry) => entry.editDate(modifier)); } + Future _editRating(BuildContext context) async { + final selection = context.read>(); + final selectedItems = _getExpandedSelectedItems(selection); + + final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canEditRating); + if (todoItems == null || todoItems.isEmpty) return; + + final rating = await selectRating(context, todoItems); + if (rating == null) return; + + await _edit(context, selection, todoItems, (entry) => entry.editRating(rating)); + } + Future _editTags(BuildContext context) async { final selection = context.read>(); final selectedItems = _getExpandedSelectedItems(selection); diff --git a/lib/widgets/collection/grid/headers/any.dart b/lib/widgets/collection/grid/headers/any.dart index d2654a8db..7e1340005 100644 --- a/lib/widgets/collection/grid/headers/any.dart +++ b/lib/widgets/collection/grid/headers/any.dart @@ -7,6 +7,7 @@ import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/section_keys.dart'; import 'package:aves/widgets/collection/grid/headers/album.dart'; import 'package:aves/widgets/collection/grid/headers/date.dart'; +import 'package:aves/widgets/collection/grid/headers/rating.dart'; import 'package:aves/widgets/common/grid/header.dart'; import 'package:flutter/material.dart'; @@ -49,6 +50,8 @@ class CollectionSectionHeader extends StatelessWidget { break; case EntrySortFactor.name: return _buildAlbumHeader(context); + case EntrySortFactor.rating: + return RatingSectionHeader(key: ValueKey(sectionKey), rating: (sectionKey as EntryRatingSectionKey).rating); case EntrySortFactor.size: break; } diff --git a/lib/widgets/collection/grid/headers/rating.dart b/lib/widgets/collection/grid/headers/rating.dart new file mode 100644 index 000000000..225e6923e --- /dev/null +++ b/lib/widgets/collection/grid/headers/rating.dart @@ -0,0 +1,21 @@ +import 'package:aves/model/filters/rating.dart'; +import 'package:aves/model/source/section_keys.dart'; +import 'package:aves/widgets/common/grid/header.dart'; +import 'package:flutter/material.dart'; + +class RatingSectionHeader extends StatelessWidget { + final int rating; + + const RatingSectionHeader({ + Key? key, + required this.rating, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return SectionHeader( + sectionKey: EntryRatingSectionKey(rating), + title: RatingFilter.formatRating(context, rating), + ); + } +} diff --git a/lib/widgets/common/action_mixins/entry_editor.dart b/lib/widgets/common/action_mixins/entry_editor.dart index 5b383cbc7..9b84e7d32 100644 --- a/lib/widgets/common/action_mixins/entry_editor.dart +++ b/lib/widgets/common/action_mixins/entry_editor.dart @@ -5,6 +5,7 @@ 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:flutter/material.dart'; @@ -22,6 +23,18 @@ mixin EntryEditorMixin { return modifier; } + Future selectRating(BuildContext context, Set entries) async { + if (entries.isEmpty) return null; + + final rating = await showDialog( + context: context, + builder: (context) => EditEntryRatingDialog( + entry: entries.first, + ), + ); + return rating; + } + Future>?> selectTags(BuildContext context, Set entries) async { if (entries.isEmpty) return null; diff --git a/lib/widgets/common/action_mixins/permission_aware.dart b/lib/widgets/common/action_mixins/permission_aware.dart index ab41e0717..329c325f0 100644 --- a/lib/widgets/common/action_mixins/permission_aware.dart +++ b/lib/widgets/common/action_mixins/permission_aware.dart @@ -47,11 +47,12 @@ mixin PermissionAwareMixin { final confirmed = await showDialog( context: context, builder: (context) { - final directory = dir.relativeDir.isEmpty ? context.l10n.rootDirectoryDescription : context.l10n.otherDirectoryDescription(dir.relativeDir); + final l10n = context.l10n; + final directory = dir.relativeDir.isEmpty ? l10n.rootDirectoryDescription : l10n.otherDirectoryDescription(dir.relativeDir); final volume = dir.getVolumeDescription(context); return AvesDialog( - title: context.l10n.storageAccessDialogTitle, - content: Text(context.l10n.storageAccessDialogMessage(directory, volume)), + title: l10n.storageAccessDialogTitle, + content: Text(l10n.storageAccessDialogMessage(directory, volume)), actions: [ TextButton( onPressed: () => Navigator.pop(context), @@ -68,6 +69,26 @@ mixin PermissionAwareMixin { // abort if the user cancels in Flutter if (confirmed == null || !confirmed) return false; + if (!await deviceService.isSystemFilePickerEnabled()) { + await showDialog( + context: context, + builder: (context) { + final l10n = context.l10n; + return AvesDialog( + title: l10n.missingSystemFilePickerDialogTitle, + content: Text(l10n.missingSystemFilePickerDialogMessage), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(MaterialLocalizations.of(context).okButtonLabel), + ), + ], + ); + }, + ); + return false; + } + final granted = await storageService.requestDirectoryAccess(dir.volumePath); if (!granted) { // abort if the user denies access from the native dialog diff --git a/lib/widgets/common/basic/wheel.dart b/lib/widgets/common/basic/wheel.dart new file mode 100644 index 000000000..0c258668f --- /dev/null +++ b/lib/widgets/common/basic/wheel.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; + +class WheelSelector extends StatefulWidget { + final ValueNotifier valueNotifier; + final List values; + final TextStyle textStyle; + final TextAlign textAlign; + + const WheelSelector({ + Key? key, + required this.valueNotifier, + required this.values, + required this.textStyle, + required this.textAlign, + }) : super(key: key); + + @override + _WheelSelectorState createState() => _WheelSelectorState(); +} + +class _WheelSelectorState extends State> { + late final ScrollController _controller; + + static const itemSize = Size(40, 40); + + ValueNotifier get valueNotifier => widget.valueNotifier; + + List get values => widget.values; + + @override + void initState() { + super.initState(); + var indexOf = values.indexOf(valueNotifier.value); + _controller = FixedExtentScrollController( + initialItem: indexOf, + ); + } + + @override + Widget build(BuildContext context) { + const background = Colors.transparent; + final foreground = DefaultTextStyle.of(context).style.color!; + + return Padding( + padding: const EdgeInsets.all(8), + child: SizedBox( + width: itemSize.width, + height: itemSize.height * 3, + child: ShaderMask( + shaderCallback: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + background, + foreground, + foreground, + background, + ], + ).createShader, + child: ListWheelScrollView( + controller: _controller, + physics: const FixedExtentScrollPhysics(parent: BouncingScrollPhysics()), + diameterRatio: 1.2, + itemExtent: itemSize.height, + squeeze: 1.3, + onSelectedItemChanged: (i) => valueNotifier.value = values[i], + children: values + .map((i) => SizedBox.fromSize( + size: itemSize, + child: Text( + '$i', + textAlign: widget.textAlign, + style: widget.textStyle, + ), + )) + .toList(), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/common/favourite_toggler.dart b/lib/widgets/common/favourite_toggler.dart new file mode 100644 index 000000000..caec381e2 --- /dev/null +++ b/lib/widgets/common/favourite_toggler.dart @@ -0,0 +1,87 @@ +import 'package:aves/model/entry.dart'; +import 'package:aves/model/favourites.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/basic/menu.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/fx/sweeper.dart'; +import 'package:flutter/material.dart'; + +class FavouriteToggler extends StatefulWidget { + final Set entries; + final bool isMenuItem; + final VoidCallback? onPressed; + + const FavouriteToggler({ + Key? key, + required this.entries, + this.isMenuItem = false, + this.onPressed, + }) : super(key: key); + + @override + _FavouriteTogglerState createState() => _FavouriteTogglerState(); +} + +class _FavouriteTogglerState extends State { + final ValueNotifier isFavouriteNotifier = ValueNotifier(false); + + Set get entries => widget.entries; + + @override + void initState() { + super.initState(); + favourites.addListener(_onChanged); + _onChanged(); + } + + @override + void didUpdateWidget(covariant FavouriteToggler oldWidget) { + super.didUpdateWidget(oldWidget); + _onChanged(); + } + + @override + void dispose() { + favourites.removeListener(_onChanged); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: isFavouriteNotifier, + builder: (context, isFavourite, child) { + if (widget.isMenuItem) { + return isFavourite + ? MenuRow( + text: context.l10n.entryActionRemoveFavourite, + icon: const Icon(AIcons.favouriteActive), + ) + : MenuRow( + text: context.l10n.entryActionAddFavourite, + icon: const Icon(AIcons.favourite), + ); + } + return Stack( + alignment: Alignment.center, + children: [ + IconButton( + icon: Icon(isFavourite ? AIcons.favouriteActive : AIcons.favourite), + onPressed: widget.onPressed, + tooltip: isFavourite ? context.l10n.entryActionRemoveFavourite : context.l10n.entryActionAddFavourite, + ), + Sweeper( + key: ValueKey(entries.length == 1 ? entries.first : entries.length), + builder: (context) => const Icon(AIcons.favourite, color: Colors.redAccent), + toggledNotifier: isFavouriteNotifier, + ), + ], + ); + }, + ); + } + + void _onChanged() { + isFavouriteNotifier.value = entries.isNotEmpty && entries.every((entry) => entry.isFavourite); + } +} diff --git a/lib/widgets/common/grid/scaling.dart b/lib/widgets/common/grid/scaling.dart index 4b62f3a9f..58aacf147 100644 --- a/lib/widgets/common/grid/scaling.dart +++ b/lib/widgets/common/grid/scaling.dart @@ -316,7 +316,6 @@ class _ScaleOverlayState extends State<_ScaleOverlay> { colors: const [ Colors.black, Colors.black54, - // Colors.amber, ], ), ) diff --git a/lib/widgets/common/grid/theme.dart b/lib/widgets/common/grid/theme.dart index 3c4fda8d5..2422ab2bd 100644 --- a/lib/widgets/common/grid/theme.dart +++ b/lib/widgets/common/grid/theme.dart @@ -28,8 +28,10 @@ class GridTheme extends StatelessWidget { iconSize: iconSize, fontSize: fontSize, highlightBorderWidth: highlightBorderWidth, + showFavourite: settings.showThumbnailFavourite, showLocation: showLocation ?? settings.showThumbnailLocation, showMotionPhoto: settings.showThumbnailMotionPhoto, + showRating: settings.showThumbnailRating, showRaw: settings.showThumbnailRaw, showVideoDuration: settings.showThumbnailVideoDuration, ); @@ -41,14 +43,16 @@ class GridTheme extends StatelessWidget { class GridThemeData { final double iconSize, fontSize, highlightBorderWidth; - final bool showLocation, showMotionPhoto, showRaw, showVideoDuration; + final bool showFavourite, showLocation, showMotionPhoto, showRating, showRaw, showVideoDuration; const GridThemeData({ required this.iconSize, required this.fontSize, required this.highlightBorderWidth, + required this.showFavourite, required this.showLocation, required this.showMotionPhoto, + required this.showRating, required this.showRaw, required this.showVideoDuration, }); diff --git a/lib/widgets/common/identity/aves_icons.dart b/lib/widgets/common/identity/aves_icons.dart index a64941ac1..4527a50ac 100644 --- a/lib/widgets/common/identity/aves_icons.dart +++ b/lib/widgets/common/identity/aves_icons.dart @@ -72,6 +72,20 @@ class SphericalImageIcon extends StatelessWidget { } } +class FavouriteIcon extends StatelessWidget { + const FavouriteIcon({Key? key}) : super(key: key); + + static const scale = .9; + + @override + Widget build(BuildContext context) { + return const OverlayIcon( + icon: AIcons.favourite, + iconScale: scale, + ); + } +} + class GpsIcon extends StatelessWidget { const GpsIcon({Key? key}) : super(key: key); @@ -139,6 +153,30 @@ class MultiPageIcon extends StatelessWidget { } } +class RatingIcon extends StatelessWidget { + final AvesEntry entry; + + const RatingIcon({ + Key? key, + required this.entry, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final gridTheme = context.watch(); + return DefaultTextStyle( + style: TextStyle( + color: Colors.grey.shade200, + fontSize: gridTheme.fontSize, + ), + child: OverlayIcon( + icon: AIcons.rating, + text: '${entry.rating}', + ), + ); + } +} + class OverlayIcon extends StatelessWidget { final IconData icon; final String? text; diff --git a/lib/widgets/common/sliver_app_bar_title.dart b/lib/widgets/common/sliver_app_bar_title.dart new file mode 100644 index 000000000..0ed370ba2 --- /dev/null +++ b/lib/widgets/common/sliver_app_bar_title.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +// as of Flutter v2.8.1, fading opacity in `SliverAppBar` +// is not applied to title when `appBarTheme.titleTextStyle` is defined, +// so this wrapper manually applies opacity to the default text style +class SliverAppBarTitleWrapper extends StatelessWidget { + final Widget child; + + const SliverAppBarTitleWrapper({ + Key? key, + required this.child, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final toolbarOpacity = context.dependOnInheritedWidgetOfExactType()!.toolbarOpacity; + final baseColor = (DefaultTextStyle.of(context).style.color ?? Theme.of(context).primaryTextTheme.headline6!.color!); + return DefaultTextStyle.merge( + style: TextStyle(color: baseColor.withOpacity(toolbarOpacity)), + child: child, + ); + } +} diff --git a/lib/widgets/common/thumbnail/overlay.dart b/lib/widgets/common/thumbnail/overlay.dart index 4e72d1c0e..388604013 100644 --- a/lib/widgets/common/thumbnail/overlay.dart +++ b/lib/widgets/common/thumbnail/overlay.dart @@ -19,11 +19,11 @@ class ThumbnailEntryOverlay extends StatelessWidget { @override Widget build(BuildContext context) { final children = [ + if (entry.isFavourite && context.select((t) => t.showFavourite)) const FavouriteIcon(), if (entry.hasGps && context.select((t) => t.showLocation)) const GpsIcon(), + if (entry.rating != 0 && context.select((t) => t.showRating)) RatingIcon(entry: entry), if (entry.isVideo) - VideoIcon( - entry: entry, - ) + VideoIcon(entry: entry) else if (entry.isAnimated) const AnimatedImageIcon() else ...[ diff --git a/lib/widgets/debug/android_apps.dart b/lib/widgets/debug/android_apps.dart index c21c8e263..25a89ef64 100644 --- a/lib/widgets/debug/android_apps.dart +++ b/lib/widgets/debug/android_apps.dart @@ -1,6 +1,7 @@ import 'package:aves/image_providers/app_icon_image_provider.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/utils/android_file_utils.dart'; +import 'package:aves/widgets/common/basic/query_bar.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/viewer/info/common.dart'; import 'package:collection/collection.dart'; @@ -15,6 +16,7 @@ class DebugAndroidAppSection extends StatefulWidget { class _DebugAndroidAppSectionState extends State with AutomaticKeepAliveClientMixin { late Future> _loader; + final ValueNotifier _queryNotifier = ValueNotifier(''); static const iconSize = 20.0; @@ -43,53 +45,64 @@ class _DebugAndroidAppSectionState extends State with Au final disabledTheme = enabledTheme.merge(const IconThemeData(opacity: .2)); return Column( crossAxisAlignment: CrossAxisAlignment.start, - children: packages.map((package) { - return Text.rich( - TextSpan( - children: [ - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: Image( - image: AppIconImage( - packageName: package.packageName, - size: iconSize, - ), - width: iconSize, - height: iconSize, + children: [ + QueryBar(queryNotifier: _queryNotifier), + ...packages.map((package) { + return ValueListenableBuilder( + valueListenable: _queryNotifier, + builder: (context, query, child) { + if ({package.packageName, ...package.potentialDirs}.none((v) => v.toLowerCase().contains(query.toLowerCase()))) { + return const SizedBox(); + } + return Text.rich( + TextSpan( + children: [ + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Image( + image: AppIconImage( + packageName: package.packageName, + size: iconSize, + ), + width: iconSize, + height: iconSize, + ), + ), + TextSpan( + text: ' ${package.packageName}\n', + style: InfoRowGroup.keyStyle, + ), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: IconTheme( + data: package.categoryLauncher ? enabledTheme : disabledTheme, + child: const Icon( + Icons.launch_outlined, + size: iconSize, + ), + ), + ), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: IconTheme( + data: package.isSystem ? enabledTheme : disabledTheme, + child: const Icon( + Icons.android, + size: iconSize, + ), + ), + ), + TextSpan( + text: ' ${package.potentialDirs.join(', ')}\n', + style: InfoRowGroup.baseStyle, + ), + ], ), - ), - TextSpan( - text: ' ${package.packageName}\n', - style: InfoRowGroup.keyStyle, - ), - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: IconTheme( - data: package.categoryLauncher ? enabledTheme : disabledTheme, - child: const Icon( - Icons.launch_outlined, - size: iconSize, - ), - ), - ), - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: IconTheme( - data: package.isSystem ? enabledTheme : disabledTheme, - child: const Icon( - Icons.android, - size: iconSize, - ), - ), - ), - TextSpan( - text: ' ${package.potentialDirs.join(', ')}\n', - style: InfoRowGroup.baseStyle, - ), - ], - ), - ); - }).toList(), + ); + }, + ); + }) + ], ); }, ), diff --git a/lib/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart b/lib/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart index e323847c6..306b2f572 100644 --- a/lib/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart @@ -4,13 +4,13 @@ import 'package:aves/model/metadata/enums.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/format.dart'; import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/basic/wheel.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:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../aves_dialog.dart'; - class EditEntryDateDialog extends StatefulWidget { final AvesEntry entry; @@ -24,170 +24,274 @@ class EditEntryDateDialog extends StatefulWidget { } class _EditEntryDateDialogState extends State { - DateEditAction _action = DateEditAction.set; - late Set _fields; - late DateTime _dateTime; - int _shiftMinutes = 60; + DateEditAction _action = DateEditAction.setCustom; + DateFieldSource _copyFieldSource = DateFieldSource.fileModifiedDate; + late DateTime _setDateTime; + late ValueNotifier _shiftHour, _shiftMinute; + late ValueNotifier _shiftSign; bool _showOptions = false; + final Set _fields = {...DateModifier.writableDateFields}; - AvesEntry get entry => widget.entry; + // use a different shade to avoid having the same background + // on the dialog (using the theme `dialogBackgroundColor`) + // and on the dropdown (using the theme `canvasColor`) + static final dropdownColor = Colors.grey.shade800; @override void initState() { super.initState(); - _fields = { - MetadataField.exifDate, - MetadataField.exifDateDigitized, - MetadataField.exifDateOriginal, - }; - _dateTime = entry.bestDate ?? DateTime.now(); + _initSet(); + _initShift(60); + } + + void _initSet() { + _setDateTime = widget.entry.bestDate ?? DateTime.now(); + } + + void _initShift(int initialMinutes) { + final abs = initialMinutes.abs(); + _shiftHour = ValueNotifier(abs ~/ 60); + _shiftMinute = ValueNotifier(abs % 60); + _shiftSign = ValueNotifier(initialMinutes.isNegative ? '-' : '+'); } @override Widget build(BuildContext context) { return MediaQueryDataProvider( - child: Builder( - builder: (context) { + child: TooltipTheme( + data: TooltipTheme.of(context).copyWith( + preferBelow: false, + ), + child: Builder(builder: (context) { final l10n = context.l10n; - final locale = l10n.localeName; - final use24hour = context.select((v) => v.alwaysUse24HourFormat); - void _updateAction(DateEditAction? action) { - if (action == null) return; - setState(() => _action = action); - } - - Widget _tileText(String text) => Text( - text, - softWrap: false, - overflow: TextOverflow.fade, - maxLines: 1, - ); - - final setTile = Row( - children: [ - Expanded( - child: RadioListTile( - value: DateEditAction.set, - groupValue: _action, - onChanged: _updateAction, - title: _tileText(l10n.editEntryDateDialogSet), - subtitle: Text(formatDateTime(_dateTime, locale, use24hour)), + return AvesDialog( + title: l10n.editEntryDateDialogTitle, + scrollableContent: [ + Padding( + padding: const EdgeInsets.only(left: 16, top: 8, right: 16), + child: DropdownButton( + items: DateEditAction.values + .map((v) => DropdownMenuItem( + value: v, + child: Text(_actionText(context, v)), + )) + .toList(), + value: _action, + onChanged: (v) => setState(() => _action = v!), + isExpanded: true, + dropdownColor: dropdownColor, ), ), - Padding( - padding: const EdgeInsetsDirectional.only(end: 12), - child: IconButton( - icon: const Icon(AIcons.edit), - onPressed: _action == DateEditAction.set ? _editDate : null, - tooltip: l10n.changeTooltip, + AnimatedSwitcher( + duration: context.read().formTransition, + switchInCurve: Curves.easeInOutCubic, + switchOutCurve: Curves.easeInOutCubic, + transitionBuilder: _formTransitionBuilder, + child: Column( + key: ValueKey(_action), + mainAxisSize: MainAxisSize.min, + children: [ + 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), + ], ), ), ], - ); - final shiftTile = Row( - children: [ - Expanded( - child: RadioListTile( - value: DateEditAction.shift, - groupValue: _action, - onChanged: _updateAction, - title: _tileText(l10n.editEntryDateDialogShift), - subtitle: Text(_formatShiftDuration()), - ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), ), - Padding( - padding: const EdgeInsetsDirectional.only(end: 12), - child: IconButton( - icon: const Icon(AIcons.edit), - onPressed: _action == DateEditAction.shift ? _editShift : null, - tooltip: l10n.changeTooltip, - ), + TextButton( + onPressed: () => _submit(context), + child: Text(l10n.applyButtonLabel), ), ], ); - final extractFromTitleTile = RadioListTile( - value: DateEditAction.extractFromTitle, - groupValue: _action, - onChanged: _updateAction, - title: _tileText(l10n.editEntryDateDialogExtractFromTitle), - ); - final clearTile = RadioListTile( - value: DateEditAction.clear, - groupValue: _action, - onChanged: _updateAction, - title: _tileText(l10n.editEntryDateDialogClear), - ); - - final animationDuration = context.select((v) => v.expansionTileAnimation); - final theme = Theme.of(context); - return Theme( - data: theme.copyWith( - textTheme: theme.textTheme.copyWith( - // dense style font for tile subtitles, without modifying title font - bodyText2: const TextStyle(fontSize: 12), - ), - ), - child: AvesDialog( - title: l10n.editEntryDateDialogTitle, - scrollableContent: [ - setTile, - shiftTile, - extractFromTitleTile, - clearTile, - Padding( - padding: const EdgeInsets.only(bottom: 1), - child: ExpansionPanelList( - expansionCallback: (index, isExpanded) { - setState(() => _showOptions = !isExpanded); - }, - animationDuration: animationDuration, - expandedHeaderPadding: EdgeInsets.zero, - elevation: 0, - children: [ - ExpansionPanel( - headerBuilder: (context, isExpanded) => ListTile( - title: Text(l10n.editEntryDateDialogFieldSelection), - ), - body: Column( - children: DateModifier.allDateFields - .map((field) => SwitchListTile( - value: _fields.contains(field), - onChanged: (selected) => setState(() => selected ? _fields.add(field) : _fields.remove(field)), - title: Text(_fieldTitle(field)), - )) - .toList(), - ), - isExpanded: _showOptions, - canTapOnHeader: true, - backgroundColor: Theme.of(context).dialogBackgroundColor, - ), - ], - ), - ), - ], - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(MaterialLocalizations.of(context).cancelButtonLabel), - ), - TextButton( - onPressed: () => _submit(context), - child: Text(l10n.applyButtonLabel), - ), - ], - ), - ); - }, + }), ), ); } - String _formatShiftDuration() { - final abs = _shiftMinutes.abs(); - final h = abs ~/ 60; - final m = abs % 60; - return '${_shiftMinutes.isNegative ? '-' : '+'}$h:${m.toString().padLeft(2, '0')}'; + Widget _formTransitionBuilder(Widget child, Animation animation) => FadeTransition( + opacity: animation, + child: SizeTransition( + sizeFactor: animation, + axisAlignment: -1, + child: child, + ), + ); + + Widget _buildSetCustomContent(BuildContext context) { + final l10n = context.l10n; + final locale = l10n.localeName; + final use24hour = context.select((v) => v.alwaysUse24HourFormat); + + return Padding( + padding: const EdgeInsets.only(left: 16, right: 8), + child: Row( + children: [ + Expanded(child: Text(formatDateTime(_setDateTime, locale, use24hour))), + IconButton( + icon: const Icon(AIcons.edit), + onPressed: _editDate, + tooltip: l10n.changeTooltip, + ), + ], + ), + ); + } + + Widget _buildCopyFieldContent(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(left: 16, top: 0, right: 16), + child: DropdownButton( + items: DateFieldSource.values + .map((v) => DropdownMenuItem( + value: v, + child: Text(_setSourceText(context, v)), + )) + .toList(), + selectedItemBuilder: (context) => DateFieldSource.values + .map((v) => DropdownMenuItem( + value: v, + child: Text( + _setSourceText(context, v), + softWrap: false, + overflow: TextOverflow.fade, + ), + )) + .toList(), + value: _copyFieldSource, + onChanged: (v) => setState(() => _copyFieldSource = v!), + isExpanded: true, + dropdownColor: dropdownColor, + ), + ); + } + + Widget _buildShiftContent(BuildContext context) { + const textStyle = TextStyle(fontSize: 34); + return Center( + child: Table( + children: [ + TableRow( + children: [ + const SizedBox(), + Center(child: Text(context.l10n.editEntryDateDialogHours)), + const SizedBox(), + Center(child: Text(context.l10n.editEntryDateDialogMinutes)), + ], + ), + TableRow( + children: [ + WheelSelector( + valueNotifier: _shiftSign, + values: const ['+', '-'], + textStyle: textStyle, + textAlign: TextAlign.center, + ), + Align( + alignment: Alignment.centerRight, + child: WheelSelector( + valueNotifier: _shiftHour, + values: List.generate(24, (i) => i), + textStyle: textStyle, + textAlign: TextAlign.end, + ), + ), + const Padding( + padding: EdgeInsets.only(bottom: 2), + child: Text( + ':', + style: textStyle, + ), + ), + Align( + alignment: Alignment.centerLeft, + child: WheelSelector( + valueNotifier: _shiftMinute, + values: List.generate(60, (i) => i), + textStyle: textStyle, + textAlign: TextAlign.end, + ), + ), + ], + ) + ], + defaultColumnWidth: const IntrinsicColumnWidth(), + defaultVerticalAlignment: TableCellVerticalAlignment.middle, + ), + ); + } + + Widget _buildDestinationFields(BuildContext context) { + return Padding( + // small padding as a workaround to show dialog action divider + padding: const EdgeInsets.only(bottom: 1), + child: ExpansionPanelList( + expansionCallback: (index, isExpanded) { + setState(() => _showOptions = !isExpanded); + }, + animationDuration: context.read().expansionTileAnimation, + expandedHeaderPadding: EdgeInsets.zero, + elevation: 0, + children: [ + ExpansionPanel( + headerBuilder: (context, isExpanded) => ListTile( + title: Text(context.l10n.editEntryDateDialogTargetFieldsHeader), + ), + body: Column( + children: DateModifier.writableDateFields + .map((field) => SwitchListTile( + value: _fields.contains(field), + onChanged: (selected) => setState(() => selected ? _fields.add(field) : _fields.remove(field)), + title: Text(_fieldTitle(field)), + )) + .toList(), + ), + isExpanded: _showOptions, + canTapOnHeader: true, + backgroundColor: Colors.transparent, + ), + ], + ), + ); + } + + String _actionText(BuildContext context, DateEditAction action) { + final l10n = context.l10n; + switch (action) { + case DateEditAction.setCustom: + return l10n.editEntryDateDialogSetCustom; + case DateEditAction.copyField: + return l10n.editEntryDateDialogCopyField; + case DateEditAction.extractFromTitle: + return l10n.editEntryDateDialogExtractFromTitle; + case DateEditAction.shift: + return l10n.editEntryDateDialogShift; + case DateEditAction.remove: + return l10n.actionRemove; + } + } + + String _setSourceText(BuildContext context, DateFieldSource source) { + final l10n = context.l10n; + switch (source) { + case DateFieldSource.fileModifiedDate: + return l10n.editEntryDateDialogSourceFileModifiedDate; + case DateFieldSource.exifDate: + return 'Exif date'; + case DateFieldSource.exifDateOriginal: + return 'Exif original date'; + case DateFieldSource.exifDateDigitized: + return 'Exif digitized date'; + case DateFieldSource.exifGpsDate: + return 'Exif GPS date'; + } } String _fieldTitle(MetadataField field) { @@ -200,13 +304,15 @@ class _EditEntryDateDialogState extends State { return 'Exif digitized date'; case MetadataField.exifGpsDate: return 'Exif GPS date'; + case MetadataField.xmpCreateDate: + return 'XMP xmp:CreateDate'; } } Future _editDate() async { final _date = await showDatePicker( context: context, - initialDate: _dateTime, + initialDate: _setDateTime, firstDate: DateTime(0), lastDate: DateTime.now(), confirmText: context.l10n.nextButtonLabel, @@ -215,11 +321,11 @@ class _EditEntryDateDialogState extends State { final _time = await showTimePicker( context: context, - initialTime: TimeOfDay.fromDateTime(_dateTime), + initialTime: TimeOfDay.fromDateTime(_setDateTime), ); if (_time == null) return; - setState(() => _dateTime = DateTime( + setState(() => _setDateTime = DateTime( _date.year, _date.month, _date.day, @@ -228,214 +334,24 @@ class _EditEntryDateDialogState extends State { )); } - void _editShift() async { - final picked = await showDialog( - context: context, - builder: (context) => TimeShiftDialog( - initialShiftMinutes: _shiftMinutes, - ), - ); - if (picked == null) return; - - setState(() => _shiftMinutes = picked); - } - - void _submit(BuildContext context) { - late DateModifier modifier; + DateModifier _getModifier() { + // fields to modify are only set for the `shift` and `remove` actions, + // as the effective fields for the other actions will depend on + // whether each item supports Exif edition switch (_action) { - case DateEditAction.set: - modifier = DateModifier(_action, _fields, dateTime: _dateTime); - break; - case DateEditAction.shift: - modifier = DateModifier(_action, _fields, shiftMinutes: _shiftMinutes); - break; + case DateEditAction.setCustom: + return DateModifier.setCustom(const {}, _setDateTime); + case DateEditAction.copyField: + return DateModifier.copyField(const {}, _copyFieldSource); case DateEditAction.extractFromTitle: - case DateEditAction.clear: - modifier = DateModifier(_action, _fields); - break; + return DateModifier.extractFromTitle(const {}); + case DateEditAction.shift: + final shiftTotalMinutes = (_shiftHour.value * 60 + _shiftMinute.value) * (_shiftSign.value == '+' ? 1 : -1); + return DateModifier.shift(_fields, shiftTotalMinutes); + case DateEditAction.remove: + return DateModifier.remove(_fields); } - Navigator.pop(context, modifier); - } -} - -class TimeShiftDialog extends StatefulWidget { - final int initialShiftMinutes; - - const TimeShiftDialog({ - Key? key, - required this.initialShiftMinutes, - }) : super(key: key); - - @override - _TimeShiftDialogState createState() => _TimeShiftDialogState(); -} - -class _TimeShiftDialogState extends State { - late ValueNotifier _hour, _minute; - late ValueNotifier _sign; - - @override - void initState() { - super.initState(); - final initial = widget.initialShiftMinutes; - final abs = initial.abs(); - _hour = ValueNotifier(abs ~/ 60); - _minute = ValueNotifier(abs % 60); - _sign = ValueNotifier(initial.isNegative ? '-' : '+'); } - @override - Widget build(BuildContext context) { - const textStyle = TextStyle(fontSize: 34); - return AvesDialog( - scrollableContent: [ - Center( - child: Padding( - padding: const EdgeInsets.only(top: 8), - child: Table( - children: [ - TableRow( - children: [ - const SizedBox(), - Center(child: Text(context.l10n.editEntryDateDialogHours)), - const SizedBox(), - Center(child: Text(context.l10n.editEntryDateDialogMinutes)), - ], - ), - TableRow( - children: [ - _Wheel( - valueNotifier: _sign, - values: const ['+', '-'], - textStyle: textStyle, - textAlign: TextAlign.center, - ), - Align( - alignment: Alignment.centerRight, - child: _Wheel( - valueNotifier: _hour, - values: List.generate(24, (i) => i), - textStyle: textStyle, - textAlign: TextAlign.end, - ), - ), - const Padding( - padding: EdgeInsets.only(bottom: 2), - child: Text( - ':', - style: textStyle, - ), - ), - Align( - alignment: Alignment.centerLeft, - child: _Wheel( - valueNotifier: _minute, - values: List.generate(60, (i) => i), - textStyle: textStyle, - textAlign: TextAlign.end, - ), - ), - ], - ) - ], - defaultColumnWidth: const IntrinsicColumnWidth(), - defaultVerticalAlignment: TableCellVerticalAlignment.middle, - ), - ), - ), - ], - hasScrollBar: false, - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(MaterialLocalizations.of(context).cancelButtonLabel), - ), - TextButton( - onPressed: () => Navigator.pop(context, (_hour.value * 60 + _minute.value) * (_sign.value == '+' ? 1 : -1)), - child: Text(MaterialLocalizations.of(context).okButtonLabel), - ), - ], - ); - } -} - -class _Wheel extends StatefulWidget { - final ValueNotifier valueNotifier; - final List values; - final TextStyle textStyle; - final TextAlign textAlign; - - const _Wheel({ - Key? key, - required this.valueNotifier, - required this.values, - required this.textStyle, - required this.textAlign, - }) : super(key: key); - - @override - _WheelState createState() => _WheelState(); -} - -class _WheelState extends State<_Wheel> { - late final ScrollController _controller; - - static const itemSize = Size(40, 40); - - ValueNotifier get valueNotifier => widget.valueNotifier; - - List get values => widget.values; - - @override - void initState() { - super.initState(); - var indexOf = values.indexOf(valueNotifier.value); - _controller = FixedExtentScrollController( - initialItem: indexOf, - ); - } - - @override - Widget build(BuildContext context) { - final background = Theme.of(context).dialogBackgroundColor; - final foreground = DefaultTextStyle.of(context).style.color!; - - return Padding( - padding: const EdgeInsets.all(8), - child: SizedBox( - width: itemSize.width, - height: itemSize.height * 3, - child: ShaderMask( - shaderCallback: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - background, - foreground, - foreground, - background, - ], - ).createShader, - child: ListWheelScrollView( - controller: _controller, - physics: const FixedExtentScrollPhysics(parent: BouncingScrollPhysics()), - diameterRatio: 1.2, - itemExtent: itemSize.height, - squeeze: 1.3, - onSelectedItemChanged: (i) => valueNotifier.value = values[i], - children: values - .map((i) => SizedBox.fromSize( - size: itemSize, - child: Text( - '$i', - textAlign: widget.textAlign, - style: widget.textStyle, - ), - )) - .toList(), - ), - ), - ), - ); - } + void _submit(BuildContext context) => Navigator.pop(context, _getModifier()); } diff --git a/lib/widgets/dialogs/entry_editors/edit_entry_rating_dialog.dart b/lib/widgets/dialogs/entry_editors/edit_entry_rating_dialog.dart new file mode 100644 index 000000000..5ab85c56f --- /dev/null +++ b/lib/widgets/dialogs/entry_editors/edit_entry_rating_dialog.dart @@ -0,0 +1,136 @@ +import 'package:aves/model/entry.dart'; +import 'package:aves/theme/icons.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:flutter/material.dart'; + +class EditEntryRatingDialog extends StatefulWidget { + final AvesEntry entry; + + const EditEntryRatingDialog({ + Key? key, + required this.entry, + }) : super(key: key); + + @override + _EditEntryRatingDialogState createState() => _EditEntryRatingDialogState(); +} + +class _EditEntryRatingDialogState extends State { + late _RatingAction _action; + late int _rating; + + @override + void initState() { + super.initState(); + final entryRating = widget.entry.rating; + switch (entryRating) { + case -1: + _action = _RatingAction.rejected; + _rating = 0; + break; + case 0: + _action = _RatingAction.unrated; + _rating = 0; + break; + default: + _action = _RatingAction.set; + _rating = entryRating; + } + } + + @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.editEntryRatingDialogTitle, + scrollableContent: [ + RadioListTile<_RatingAction>( + value: _RatingAction.set, + groupValue: _action, + onChanged: (v) => setState(() => _action = v!), + title: Wrap( + children: [ + ...List.generate(5, (i) { + final thisRating = i + 1; + return GestureDetector( + onTap: () => setState(() { + _action = _RatingAction.set; + _rating = thisRating; + }), + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.all(4), + child: Icon( + _rating < thisRating ? AIcons.rating : AIcons.ratingFull, + color: _rating < thisRating ? Colors.grey : Colors.amber, + ), + ), + ); + }) + ], + ), + ), + RadioListTile<_RatingAction>( + value: _RatingAction.rejected, + groupValue: _action, + onChanged: (v) => setState(() { + _action = v!; + _rating = 0; + }), + title: Text(l10n.filterRatingRejectedLabel), + ), + RadioListTile<_RatingAction>( + value: _RatingAction.unrated, + groupValue: _action, + onChanged: (v) => setState(() { + _action = v!; + _rating = 0; + }), + title: Text(l10n.filterRatingUnratedLabel), + ), + ], + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), + ), + TextButton( + onPressed: isValid ? () => _submit(context) : null, + child: Text(l10n.applyButtonLabel), + ), + ], + ); + }), + ), + ); + } + + bool get isValid => !(_action == _RatingAction.set && _rating <= 0); + + void _submit(BuildContext context) { + late int entryRating; + switch (_action) { + case _RatingAction.set: + entryRating = _rating; + break; + case _RatingAction.rejected: + entryRating = -1; + break; + case _RatingAction.unrated: + entryRating = 0; + break; + } + Navigator.pop(context, entryRating); + } +} + +enum _RatingAction { set, rejected, unrated } diff --git a/lib/widgets/dialogs/entry_editors/edit_entry_tags_dialog.dart b/lib/widgets/dialogs/entry_editors/edit_entry_tags_dialog.dart index cebab3027..d3f7ea85e 100644 --- a/lib/widgets/dialogs/entry_editors/edit_entry_tags_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/edit_entry_tags_dialog.dart @@ -122,7 +122,7 @@ class _TagEditorPageState extends State { child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(AIcons.tagOff, color: untaggedColor), + const Icon(AIcons.tagUntagged, color: untaggedColor), const SizedBox(width: 8), Text( l10n.filterTagEmptyLabel, diff --git a/lib/widgets/dialogs/entry_editors/remove_entry_metadata_dialog.dart b/lib/widgets/dialogs/entry_editors/remove_entry_metadata_dialog.dart index 581cafdaf..4967af2e4 100644 --- a/lib/widgets/dialogs/entry_editors/remove_entry_metadata_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/remove_entry_metadata_dialog.dart @@ -69,7 +69,7 @@ class _RemoveEntryMetadataDialogState extends State { ), isExpanded: _showMore, canTapOnHeader: true, - backgroundColor: Theme.of(context).dialogBackgroundColor, + backgroundColor: Colors.transparent, ), ], ), diff --git a/lib/widgets/filter_grids/album_pick.dart b/lib/widgets/filter_grids/album_pick.dart index dfff41e2c..63755740b 100644 --- a/lib/widgets/filter_grids/album_pick.dart +++ b/lib/widgets/filter_grids/album_pick.dart @@ -16,6 +16,7 @@ import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/common/identity/empty.dart'; import 'package:aves/widgets/common/providers/selection_provider.dart'; +import 'package:aves/widgets/common/sliver_app_bar_title.dart'; import 'package:aves/widgets/dialogs/filter_editors/create_album_dialog.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/filter_grids/common/action_delegates/album_set.dart'; @@ -141,9 +142,11 @@ class _AlbumPickAppBar extends StatelessWidget { return SliverAppBar( leading: const BackButton(), - title: SourceStateAwareAppBarTitle( - title: Text(title()), - source: source, + title: SliverAppBarTitleWrapper( + child: SourceStateAwareAppBarTitle( + title: Text(title()), + source: source, + ), ), bottom: _AlbumQueryBar( queryNotifier: queryNotifier, diff --git a/lib/widgets/filter_grids/common/app_bar.dart b/lib/widgets/filter_grids/common/app_bar.dart index ec443c7df..d5e3c4659 100644 --- a/lib/widgets/filter_grids/common/app_bar.dart +++ b/lib/widgets/filter_grids/common/app_bar.dart @@ -8,6 +8,7 @@ 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'; import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/sliver_app_bar_title.dart'; import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart'; import 'package:aves/widgets/search/search_delegate.dart'; import 'package:flutter/material.dart'; @@ -74,7 +75,9 @@ class _FilterGridAppBarState extends State extends State>, int>( selector: (context, selection) => selection.selectedItems.length, diff --git a/lib/widgets/home_page.dart b/lib/widgets/home_page.dart index f04182b61..aeb9d078f 100644 --- a/lib/widgets/home_page.dart +++ b/lib/widgets/home_page.dart @@ -2,11 +2,13 @@ import 'dart:async'; import 'package:aves/app_mode.dart'; import 'package:aves/model/entry.dart'; +import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/settings/home_page.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/model/source/enums.dart'; import 'package:aves/services/analysis_service.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/services/global_search.dart'; @@ -18,6 +20,7 @@ import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/search/search_delegate.dart'; import 'package:aves/widgets/search/search_page.dart'; import 'package:aves/widgets/viewer/entry_viewer_page.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:permission_handler/permission_handler.dart'; @@ -114,7 +117,7 @@ class _HomePageState extends State { context.read>().value = appMode; unawaited(reportService.setCustomKey('app_mode', appMode.toString())); - if (appMode != AppMode.view) { + if (appMode != AppMode.view || _isViewerSourceable(_viewerEntry!)) { debugPrint('Storage check complete in ${stopwatch.elapsed.inMilliseconds}ms'); unawaited(GlobalSearch.registerCallback()); unawaited(AnalysisService.registerCallback()); @@ -127,11 +130,13 @@ class _HomePageState extends State { // e.g. when opening the viewer in `view` mode should replace a viewer in `main` mode unawaited(Navigator.pushAndRemoveUntil( context, - _getRedirectRoute(appMode), + await _getRedirectRoute(appMode), (route) => false, )); } + bool _isViewerSourceable(AvesEntry viewerEntry) => viewerEntry.directory != null && !settings.hiddenFilters.any((filter) => filter.test(viewerEntry)); + Future _initViewerEntry({required String uri, required String? mimeType}) async { if (uri.startsWith('/')) { // convert this file path to a proper URI @@ -145,13 +150,50 @@ class _HomePageState extends State { return entry; } - Route _getRedirectRoute(AppMode appMode) { + Future _getRedirectRoute(AppMode appMode) async { if (appMode == AppMode.view) { + AvesEntry viewerEntry = _viewerEntry!; + CollectionLens? collection; + + final source = context.read(); + if (source.initialized) { + final album = viewerEntry.directory; + if (album != null) { + // wait for collection to pass the `loading` state + final completer = Completer(); + void _onSourceStateChanged() { + if (source.stateNotifier.value != SourceState.loading) { + source.stateNotifier.removeListener(_onSourceStateChanged); + completer.complete(); + } + } + + source.stateNotifier.addListener(_onSourceStateChanged); + await completer.future; + + collection = CollectionLens( + source: source, + filters: {AlbumFilter(album, source.getAlbumDisplayName(context, album))}, + ); + final viewerEntryPath = viewerEntry.path; + final collectionEntry = collection.sortedEntries.firstWhereOrNull((entry) => entry.path == viewerEntryPath); + if (collectionEntry != null) { + viewerEntry = collectionEntry; + } else { + debugPrint('collection does not contain viewerEntry=$viewerEntry'); + collection = null; + } + } + } + return DirectMaterialPageRoute( settings: const RouteSettings(name: EntryViewerPage.routeName), - builder: (_) => EntryViewerPage( - initialEntry: _viewerEntry!, - ), + builder: (_) { + return EntryViewerPage( + collection: collection, + initialEntry: viewerEntry, + ); + }, ); } diff --git a/lib/widgets/search/search_delegate.dart b/lib/widgets/search/search_delegate.dart index 24927b16c..5d224993f 100644 --- a/lib/widgets/search/search_delegate.dart +++ b/lib/widgets/search/search_delegate.dart @@ -4,6 +4,7 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/filters/query.dart'; +import 'package:aves/model/filters/rating.dart'; import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/filters/type.dart'; import 'package:aves/model/settings/settings.dart'; @@ -179,6 +180,11 @@ 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(), + ), ], ); }); diff --git a/lib/widgets/settings/language/locale.dart b/lib/widgets/settings/language/locale.dart index 1cdbba07e..631890ad2 100644 --- a/lib/widgets/settings/language/locale.dart +++ b/lib/widgets/settings/language/locale.dart @@ -51,6 +51,8 @@ class LocaleTile extends StatelessWidget { return 'Deutsch'; case 'en': return 'English'; + case 'es': + return 'Español (México)'; case 'fr': return 'Français'; case 'ko': diff --git a/lib/widgets/settings/navigation/drawer_tab_albums.dart b/lib/widgets/settings/navigation/drawer_tab_albums.dart index b7d42d4f8..a0dbad668 100644 --- a/lib/widgets/settings/navigation/drawer_tab_albums.dart +++ b/lib/widgets/settings/navigation/drawer_tab_albums.dart @@ -43,7 +43,7 @@ class _DrawerAlbumTabState extends State { onPressed: () { setState(() => widget.items.remove(album)); }, - tooltip: context.l10n.removeTooltip, + tooltip: context.l10n.actionRemove, ), ); }, diff --git a/lib/widgets/settings/privacy/hidden_items.dart b/lib/widgets/settings/privacy/hidden_items.dart index 48e5a6d19..0c1b0f17b 100644 --- a/lib/widgets/settings/privacy/hidden_items.dart +++ b/lib/widgets/settings/privacy/hidden_items.dart @@ -154,7 +154,7 @@ class _HiddenPaths extends StatelessWidget { onPressed: () { context.read().changeFilterVisibility({pathFilter}, true); }, - tooltip: context.l10n.removeTooltip, + tooltip: context.l10n.actionRemove, ), )), ], diff --git a/lib/widgets/settings/thumbnails/thumbnails.dart b/lib/widgets/settings/thumbnails/thumbnails.dart index e2e167a76..1ff395e94 100644 --- a/lib/widgets/settings/thumbnails/thumbnails.dart +++ b/lib/widgets/settings/thumbnails/thumbnails.dart @@ -20,11 +20,6 @@ class ThumbnailsSection extends StatelessWidget { @override Widget build(BuildContext context) { - final currentShowThumbnailLocation = context.select((s) => s.showThumbnailLocation); - final currentShowThumbnailMotionPhoto = context.select((s) => s.showThumbnailMotionPhoto); - final currentShowThumbnailRaw = context.select((s) => s.showThumbnailRaw); - final currentShowThumbnailVideoDuration = context.select((s) => s.showThumbnailVideoDuration); - final iconSize = IconTheme.of(context).size! * MediaQuery.textScaleFactorOf(context); double opacityFor(bool enabled) => enabled ? 1 : .2; @@ -38,64 +33,119 @@ class ThumbnailsSection extends StatelessWidget { showHighlight: false, children: [ const CollectionActionsTile(), - SwitchListTile( - value: currentShowThumbnailLocation, - onChanged: (v) => settings.showThumbnailLocation = v, - title: Row( - children: [ - Expanded(child: Text(context.l10n.settingsThumbnailShowLocationIcon)), - AnimatedOpacity( - opacity: opacityFor(currentShowThumbnailLocation), - duration: Durations.toggleableTransitionAnimation, - child: Icon( - AIcons.location, - size: iconSize, - ), - ), - ], - ), - ), - SwitchListTile( - value: currentShowThumbnailMotionPhoto, - onChanged: (v) => settings.showThumbnailMotionPhoto = v, - title: Row( - children: [ - Expanded(child: Text(context.l10n.settingsThumbnailShowMotionPhotoIcon)), - AnimatedOpacity( - opacity: opacityFor(currentShowThumbnailMotionPhoto), - duration: Durations.toggleableTransitionAnimation, - child: Padding( - padding: EdgeInsets.symmetric(horizontal: iconSize * (1 - MotionPhotoIcon.scale) / 2), - child: Icon( - AIcons.motionPhoto, - size: iconSize * MotionPhotoIcon.scale, + Selector( + selector: (context, s) => s.showThumbnailFavourite, + builder: (context, current, child) => SwitchListTile( + value: current, + onChanged: (v) => settings.showThumbnailFavourite = v, + title: Row( + children: [ + Expanded(child: Text(context.l10n.settingsThumbnailShowFavouriteIcon)), + AnimatedOpacity( + opacity: opacityFor(current), + duration: Durations.toggleableTransitionAnimation, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: iconSize * (1 - FavouriteIcon.scale) / 2), + child: Icon( + AIcons.favourite, + size: iconSize * FavouriteIcon.scale, + ), ), ), - ), - ], + ], + ), ), ), - SwitchListTile( - value: currentShowThumbnailRaw, - onChanged: (v) => settings.showThumbnailRaw = v, - title: Row( - children: [ - Expanded(child: Text(context.l10n.settingsThumbnailShowRawIcon)), - AnimatedOpacity( - opacity: opacityFor(currentShowThumbnailRaw), - duration: Durations.toggleableTransitionAnimation, - child: Icon( - AIcons.raw, - size: iconSize, + Selector( + selector: (context, s) => s.showThumbnailLocation, + builder: (context, current, child) => SwitchListTile( + value: current, + onChanged: (v) => settings.showThumbnailLocation = v, + title: Row( + children: [ + Expanded(child: Text(context.l10n.settingsThumbnailShowLocationIcon)), + AnimatedOpacity( + opacity: opacityFor(current), + duration: Durations.toggleableTransitionAnimation, + child: Icon( + AIcons.location, + size: iconSize, + ), ), - ), - ], + ], + ), ), ), - SwitchListTile( - value: currentShowThumbnailVideoDuration, - onChanged: (v) => settings.showThumbnailVideoDuration = v, - title: Text(context.l10n.settingsThumbnailShowVideoDuration), + Selector( + selector: (context, s) => s.showThumbnailMotionPhoto, + builder: (context, current, child) => SwitchListTile( + value: current, + onChanged: (v) => settings.showThumbnailMotionPhoto = v, + title: Row( + children: [ + Expanded(child: Text(context.l10n.settingsThumbnailShowMotionPhotoIcon)), + AnimatedOpacity( + opacity: opacityFor(current), + duration: Durations.toggleableTransitionAnimation, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: iconSize * (1 - MotionPhotoIcon.scale) / 2), + child: Icon( + AIcons.motionPhoto, + size: iconSize * MotionPhotoIcon.scale, + ), + ), + ), + ], + ), + ), + ), + Selector( + selector: (context, s) => s.showThumbnailRating, + builder: (context, current, child) => SwitchListTile( + value: current, + onChanged: (v) => settings.showThumbnailRating = v, + title: Row( + children: [ + Expanded(child: Text(context.l10n.settingsThumbnailShowRating)), + AnimatedOpacity( + opacity: opacityFor(current), + duration: Durations.toggleableTransitionAnimation, + child: Icon( + AIcons.rating, + size: iconSize, + ), + ), + ], + ), + ), + ), + Selector( + selector: (context, s) => s.showThumbnailRaw, + builder: (context, current, child) => SwitchListTile( + value: current, + onChanged: (v) => settings.showThumbnailRaw = v, + title: Row( + children: [ + Expanded(child: Text(context.l10n.settingsThumbnailShowRawIcon)), + AnimatedOpacity( + opacity: opacityFor(current), + duration: Durations.toggleableTransitionAnimation, + child: Icon( + AIcons.raw, + size: iconSize, + ), + ), + ], + ), + ), + ), + Selector( + selector: (context, s) => s.showThumbnailVideoDuration, + builder: (context, current, child) => SwitchListTile( + value: current, + onChanged: (v) => settings.showThumbnailVideoDuration = v, + title: Text(context.l10n.settingsThumbnailShowVideoDuration), + ), ), ], ); diff --git a/lib/widgets/stats/filter_table.dart b/lib/widgets/stats/filter_table.dart index 2d5754440..325b518c0 100644 --- a/lib/widgets/stats/filter_table.dart +++ b/lib/widgets/stats/filter_table.dart @@ -2,15 +2,16 @@ 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/identity/aves_filter_chip.dart'; -import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:percent_indicator/linear_percent_indicator.dart'; -class FilterTable extends StatelessWidget { +class FilterTable extends StatelessWidget { final int totalEntryCount; - final Map entryCountMap; - final CollectionFilter Function(String key) filterBuilder; + final Map entryCountMap; + final CollectionFilter Function(T key) filterBuilder; + final bool sortByCount; + final int? maxRowCount; final FilterCallback onFilterSelection; const FilterTable({ @@ -18,6 +19,8 @@ class FilterTable extends StatelessWidget { required this.totalEntryCount, required this.entryCountMap, required this.filterBuilder, + required this.sortByCount, + required this.maxRowCount, required this.onFilterSelection, }) : super(key: key); @@ -27,11 +30,13 @@ class FilterTable extends StatelessWidget { @override Widget build(BuildContext context) { - final sortedEntries = entryCountMap.entries.toList() - ..sort((kv1, kv2) { + final sortedEntries = entryCountMap.entries.toList(); + if (sortByCount) { + sortedEntries.sort((kv1, kv2) { final c = kv2.value.compareTo(kv1.value); - return c != 0 ? c : compareAsciiUpperCase(kv1.key, kv2.key); + return c != 0 ? c : kv1.key.compareTo(kv2.key); }); + } final textScaleFactor = MediaQuery.textScaleFactorOf(context); final lineHeight = 16 * textScaleFactor; @@ -41,8 +46,9 @@ class FilterTable extends StatelessWidget { child: LayoutBuilder( builder: (context, constraints) { final showPercentIndicator = constraints.maxWidth - (chipWidth + countWidth) > percentIndicatorMinWidth; + final displayedEntries = maxRowCount != null ? sortedEntries.take(maxRowCount!) : sortedEntries; return Table( - children: sortedEntries.take(5).map((kv) { + children: displayedEntries.map((kv) { final filter = filterBuilder(kv.key); final label = filter.getLabel(context); final count = kv.value; diff --git a/lib/widgets/stats/stats_page.dart b/lib/widgets/stats/stats_page.dart index 884cc6280..6ece99212 100644 --- a/lib/widgets/stats/stats_page.dart +++ b/lib/widgets/stats/stats_page.dart @@ -4,6 +4,7 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/mime.dart'; +import 'package:aves/model/filters/rating.dart'; import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/settings/accessibility_animations.dart'; import 'package:aves/model/settings/settings.dart'; @@ -33,6 +34,7 @@ class StatsPage extends StatelessWidget { final CollectionLens? parentCollection; final Set entries; final Map entryCountPerCountry = {}, entryCountPerPlace = {}, entryCountPerTag = {}; + final Map entryCountPerRating = Map.fromEntries(List.generate(7, (i) => MapEntry(5 - i, 0))); static const mimeDonutMinWidth = 124.0; @@ -55,9 +57,13 @@ class StatsPage extends StatelessWidget { entryCountPerPlace[place] = (entryCountPerPlace[place] ?? 0) + 1; } } + entry.tags.forEach((tag) { entryCountPerTag[tag] = (entryCountPerTag[tag] ?? 0) + 1; }); + + final rating = entry.rating; + entryCountPerRating[rating] = (entryCountPerRating[rating] ?? 0) + 1; }); } @@ -115,13 +121,15 @@ class StatsPage extends StatelessWidget { ], ), ); + final showRatings = entryCountPerRating.entries.any((kv) => kv.key != 0 && kv.value > 0); child = ListView( children: [ mimeDonuts, locationIndicator, - ..._buildTopFilters(context, context.l10n.statsTopCountries, entryCountPerCountry, (s) => LocationFilter(LocationLevel.country, s)), - ..._buildTopFilters(context, context.l10n.statsTopPlaces, entryCountPerPlace, (s) => LocationFilter(LocationLevel.place, s)), - ..._buildTopFilters(context, context.l10n.statsTopTags, entryCountPerTag, (s) => TagFilter(s)), + ..._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), ], ); } @@ -243,12 +251,14 @@ class StatsPage extends StatelessWidget { }); } - List _buildTopFilters( + List _buildFilterSection( BuildContext context, String title, - Map entryCountMap, - CollectionFilter Function(String key) filterBuilder, - ) { + Map entryCountMap, + CollectionFilter Function(T key) filterBuilder, { + bool sortByCount = true, + int? maxRowCount = 5, + }) { if (entryCountMap.isEmpty) return []; return [ @@ -263,6 +273,8 @@ class StatsPage extends StatelessWidget { totalEntryCount: entries.length, entryCountMap: entryCountMap, filterBuilder: filterBuilder, + sortByCount: sortByCount, + maxRowCount: maxRowCount, onFilterSelection: (filter) => _onFilterSelection(context, filter), ), ]; diff --git a/lib/widgets/viewer/entry_action_delegate.dart b/lib/widgets/viewer/action/entry_action_delegate.dart similarity index 87% rename from lib/widgets/viewer/entry_action_delegate.dart rename to lib/widgets/viewer/action/entry_action_delegate.dart index 5c4ba00da..3c6cff31f 100644 --- a/lib/widgets/viewer/entry_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_action_delegate.dart @@ -6,6 +6,7 @@ import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/actions/move_type.dart'; import 'package:aves/model/device.dart'; import 'package:aves/model/entry.dart'; +import 'package:aves/model/entry_metadata_edition.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/highlight.dart'; import 'package:aves/model/source/collection_lens.dart'; @@ -24,20 +25,26 @@ import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/entry_editors/rename_entry_dialog.dart'; import 'package:aves/widgets/dialogs/export_entry_dialog.dart'; import 'package:aves/widgets/filter_grids/album_pick.dart'; +import 'package:aves/widgets/viewer/action/printer.dart'; +import 'package:aves/widgets/viewer/action/single_entry_editor.dart'; import 'package:aves/widgets/viewer/debug/debug_page.dart'; import 'package:aves/widgets/viewer/info/notifications.dart'; -import 'package:aves/widgets/viewer/printer.dart'; import 'package:aves/widgets/viewer/source_viewer_page.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; -class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { - void onActionSelected(BuildContext context, AvesEntry entry, EntryAction action) { +class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin, SingleEntryEditorMixin { + @override + final AvesEntry entry; + + EntryActionDelegate(this.entry); + + void onActionSelected(BuildContext context, EntryAction action) { switch (action) { case EntryAction.addShortcut: - _addShortcut(context, entry); + _addShortcut(context); break; case EntryAction.copyToClipboard: androidAppService.copyToClipboard(entry.uri, entry.bestTitle).then((success) { @@ -45,10 +52,10 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix }); break; case EntryAction.delete: - _delete(context, entry); + _delete(context); break; case EntryAction.export: - _export(context, entry); + _export(context); break; case EntryAction.info: ShowInfoNotification().dispatch(context); @@ -57,7 +64,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix EntryPrinter(entry).print(context); break; case EntryAction.rename: - _rename(context, entry); + _rename(context); break; case EntryAction.share: androidAppService.shareEntries({entry}).then((success) { @@ -69,17 +76,17 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix break; // raster case EntryAction.rotateCCW: - _rotate(context, entry, clockwise: false); + _rotate(context, clockwise: false); break; case EntryAction.rotateCW: - _rotate(context, entry, clockwise: true); + _rotate(context, clockwise: true); break; case EntryAction.flip: - _flip(context, entry); + _flip(context); break; // vector case EntryAction.viewSource: - _goToSourceViewer(context, entry); + _goToSourceViewer(context); break; // external case EntryAction.edit: @@ -108,12 +115,12 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix break; // debug case EntryAction.debug: - _goToDebug(context, entry); + _goToDebug(context); break; } } - Future _addShortcut(BuildContext context, AvesEntry entry) async { + Future _addShortcut(BuildContext context) async { final result = await showDialog>( context: context, builder: (context) => AddShortcutDialog( @@ -131,18 +138,12 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix } } - Future _flip(BuildContext context, AvesEntry entry) async { - if (!await checkStoragePermission(context, {entry})) return; - - final dataTypes = await entry.flip(persist: _isMainMode(context)); - if (dataTypes.isEmpty) showFeedback(context, context.l10n.genericFailureFeedback); + Future _flip(BuildContext context) async { + await edit(context, entry.flip); } - Future _rotate(BuildContext context, AvesEntry entry, {required bool clockwise}) async { - if (!await checkStoragePermission(context, {entry})) return; - - final dataTypes = await entry.rotate(clockwise: clockwise, persist: _isMainMode(context)); - if (dataTypes.isEmpty) showFeedback(context, context.l10n.genericFailureFeedback); + Future _rotate(BuildContext context, {required bool clockwise}) async { + await edit(context, () => entry.rotate(clockwise: clockwise)); } Future _rotateScreen(BuildContext context) async { @@ -156,7 +157,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix } } - Future _delete(BuildContext context, AvesEntry entry) async { + Future _delete(BuildContext context) async { final confirmed = await showDialog( context: context, builder: (context) { @@ -190,7 +191,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix } } - Future _export(BuildContext context, AvesEntry entry) async { + Future _export(BuildContext context) async { final source = context.read(); if (!source.initialized) { await source.init(); @@ -291,7 +292,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix ); } - Future _rename(BuildContext context, AvesEntry entry) async { + Future _rename(BuildContext context) async { final newName = await showDialog( context: context, builder: (context) => RenameEntryDialog(entry: entry), @@ -311,7 +312,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix bool _isMainMode(BuildContext context) => context.read>().value == AppMode.main; - void _goToSourceViewer(BuildContext context, AvesEntry entry) { + void _goToSourceViewer(BuildContext context) { Navigator.push( context, MaterialPageRoute( @@ -323,7 +324,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix ); } - void _goToDebug(BuildContext context, AvesEntry entry) { + void _goToDebug(BuildContext context) { Navigator.push( context, MaterialPageRoute( diff --git a/lib/widgets/viewer/info/entry_info_action_delegate.dart b/lib/widgets/viewer/action/entry_info_action_delegate.dart similarity index 61% rename from lib/widgets/viewer/info/entry_info_action_delegate.dart rename to lib/widgets/viewer/action/entry_info_action_delegate.dart index 5460b24dc..1c20cba58 100644 --- a/lib/widgets/viewer/info/entry_info_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_info_action_delegate.dart @@ -1,22 +1,18 @@ import 'dart:async'; -import 'package:aves/app_mode.dart'; 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_xmp_iptc.dart'; -import 'package:aves/model/settings/settings.dart'; -import 'package:aves/model/source/collection_source.dart'; -import 'package:aves/services/common/services.dart'; +import 'package:aves/model/entry_metadata_edition.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'; -import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/viewer/action/single_entry_editor.dart'; import 'package:aves/widgets/viewer/embedded/notifications.dart'; import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -class EntryInfoActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwareMixin { +class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEditorMixin, SingleEntryEditorMixin { + @override final AvesEntry entry; final StreamController> _eventStreamController = StreamController>.broadcast(); @@ -29,6 +25,7 @@ class EntryInfoActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAw switch (action) { // general case EntryInfoAction.editDate: + case EntryInfoAction.editRating: case EntryInfoAction.editTags: case EntryInfoAction.removeMetadata: return true; @@ -43,6 +40,8 @@ class EntryInfoActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAw // general case EntryInfoAction.editDate: return entry.canEditDate; + case EntryInfoAction.editRating: + return entry.canEditRating; case EntryInfoAction.editTags: return entry.canEditTags; case EntryInfoAction.removeMetadata: @@ -60,6 +59,9 @@ class EntryInfoActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAw case EntryInfoAction.editDate: await _editDate(context); break; + case EntryInfoAction.editRating: + await _editRating(context); + break; case EntryInfoAction.editTags: await _editTags(context); break; @@ -74,43 +76,18 @@ class EntryInfoActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAw _eventStreamController.add(ActionEndedEvent(action)); } - bool _isMainMode(BuildContext context) => context.read>().value == AppMode.main; - - Future _edit(BuildContext context, Future> Function() apply) async { - if (!await checkStoragePermission(context, {entry})) return; - - // check before applying, because it relies on provider - // but the widget tree may be disposed if the user navigated away - final isMainMode = _isMainMode(context); - - final l10n = context.l10n; - final source = context.read(); - source?.pauseMonitoring(); - - final dataTypes = await apply(); - final success = dataTypes.isNotEmpty; - try { - if (success) { - if (isMainMode && source != null) { - await source.refreshEntry(entry, dataTypes); - } else { - await entry.refresh(background: false, persist: false, dataTypes: dataTypes, geocoderLocale: settings.appliedLocale); - } - showFeedback(context, l10n.genericSuccessFeedback); - } else { - showFeedback(context, l10n.genericFailureFeedback); - } - } catch (e, stack) { - await reportService.recordError(e, stack); - } - source?.resumeMonitoring(); - } - Future _editDate(BuildContext context) async { final modifier = await selectDateModifier(context, {entry}); if (modifier == null) return; - await _edit(context, () => entry.editDate(modifier)); + await edit(context, () => entry.editDate(modifier)); + } + + Future _editRating(BuildContext context) async { + final rating = await selectRating(context, {entry}); + if (rating == null) return; + + await edit(context, () => entry.editRating(rating)); } Future _editTags(BuildContext context) async { @@ -121,13 +98,13 @@ class EntryInfoActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAw final currentTags = entry.tags; if (newTags.length == currentTags.length && newTags.every(currentTags.contains)) return; - await _edit(context, () => entry.editTags(newTags)); + await edit(context, () => entry.editTags(newTags)); } Future _removeMetadata(BuildContext context) async { final types = await selectMetadataToRemove(context, {entry}); if (types == null) return; - await _edit(context, () => entry.removeMetadata(types)); + await edit(context, () => entry.removeMetadata(types)); } } diff --git a/lib/widgets/viewer/printer.dart b/lib/widgets/viewer/action/printer.dart similarity index 100% rename from lib/widgets/viewer/printer.dart rename to lib/widgets/viewer/action/printer.dart diff --git a/lib/widgets/viewer/action/single_entry_editor.dart b/lib/widgets/viewer/action/single_entry_editor.dart new file mode 100644 index 000000000..f47d31314 --- /dev/null +++ b/lib/widgets/viewer/action/single_entry_editor.dart @@ -0,0 +1,48 @@ +import 'dart:async'; + +import 'package:aves/app_mode.dart'; +import 'package:aves/model/entry.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/services/common/services.dart'; +import 'package:aves/widgets/common/action_mixins/feedback.dart'; +import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +mixin SingleEntryEditorMixin on FeedbackMixin, PermissionAwareMixin { + AvesEntry get entry; + + bool _isMainMode(BuildContext context) => context.read>().value == AppMode.main; + + Future edit(BuildContext context, Future> Function() apply) async { + if (!await checkStoragePermission(context, {entry})) return; + + // check before applying, because it relies on provider + // but the widget tree may be disposed if the user navigated away + final isMainMode = _isMainMode(context); + + final l10n = context.l10n; + final source = context.read(); + source?.pauseMonitoring(); + + final dataTypes = await apply(); + final success = dataTypes.isNotEmpty; + try { + if (success) { + if (isMainMode && source != null) { + await source.refreshEntry(entry, dataTypes); + } else { + await entry.refresh(background: false, persist: false, dataTypes: dataTypes, geocoderLocale: settings.appliedLocale); + } + showFeedback(context, l10n.genericSuccessFeedback); + } else { + showFeedback(context, l10n.genericFailureFeedback); + } + } catch (e, stack) { + await reportService.recordError(e, stack); + } + source?.resumeMonitoring(); + } +} diff --git a/lib/widgets/viewer/debug/db.dart b/lib/widgets/viewer/debug/db.dart index 11279a2f3..ecc490ee5 100644 --- a/lib/widgets/viewer/debug/db.dart +++ b/lib/widgets/viewer/debug/db.dart @@ -123,6 +123,7 @@ class _DbTabState extends State { 'longitude': '${data.longitude}', 'xmpSubjects': data.xmpSubjects ?? '', 'xmpTitleDescription': data.xmpTitleDescription ?? '', + 'rating': '${data.rating}', }, ), ], diff --git a/lib/widgets/viewer/info/basic_section.dart b/lib/widgets/viewer/info/basic_section.dart index ffe5db8b8..70556e422 100644 --- a/lib/widgets/viewer/info/basic_section.dart +++ b/lib/widgets/viewer/info/basic_section.dart @@ -4,16 +4,16 @@ import 'package:aves/model/favourites.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/mime.dart'; +import 'package:aves/model/filters/rating.dart'; import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/filters/type.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/theme/format.dart'; -import 'package:aves/theme/icons.dart'; import 'package:aves/utils/file_utils.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; +import 'package:aves/widgets/viewer/action/entry_info_action_delegate.dart'; import 'package:aves/widgets/viewer/info/common.dart'; -import 'package:aves/widgets/viewer/info/entry_info_action_delegate.dart'; import 'package:aves/widgets/viewer/info/owner.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; @@ -23,7 +23,7 @@ class BasicSection extends StatelessWidget { final AvesEntry entry; final CollectionLens? collection; final EntryInfoActionDelegate actionDelegate; - final ValueNotifier isEditingTagNotifier; + final ValueNotifier isEditingMetadataNotifier; final FilterCallback onFilter; const BasicSection({ @@ -31,7 +31,7 @@ class BasicSection extends StatelessWidget { required this.entry, this.collection, required this.actionDelegate, - required this.isEditingTagNotifier, + required this.isEditingMetadataNotifier, required this.onFilter, }) : super(key: key); @@ -74,10 +74,9 @@ class BasicSection extends StatelessWidget { if (path != null) l10n.viewerInfoLabelPath: path, }, ), - OwnerProp( - entry: entry, - ), + OwnerProp(entry: entry), _buildChips(context), + _buildEditButtons(context), ], ); }); @@ -96,6 +95,7 @@ class BasicSection extends StatelessWidget { if (entry.isVideo && entry.is360) TypeFilter.sphericalVideo, 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)), }; return AnimatedBuilder( @@ -106,58 +106,78 @@ class BasicSection extends StatelessWidget { if (entry.isFavourite) FavouriteFilter.instance, ]..sort(); - final children = [ - ...effectiveFilters.map((filter) => AvesFilterChip( - filter: filter, - onTap: onFilter, - )), - if (actionDelegate.canApply(EntryInfoAction.editTags)) _buildEditTagButton(context), - ]; - - return children.isEmpty - ? const SizedBox() - : Padding( - padding: const EdgeInsets.symmetric(horizontal: AvesFilterChip.outlineWidth / 2) + const EdgeInsets.only(top: 8), - child: Wrap( - spacing: 8, - runSpacing: 8, - children: children, - ), - ); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: AvesFilterChip.outlineWidth / 2) + const EdgeInsets.only(top: 8), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: effectiveFilters + .map((filter) => AvesFilterChip( + filter: filter, + onTap: onFilter, + )) + .toList(), + ), + ); }, ); } - Widget _buildEditTagButton(BuildContext context) { - const action = EntryInfoAction.editTags; - return ValueListenableBuilder( - valueListenable: isEditingTagNotifier, - builder: (context, isEditing, child) { + Widget _buildEditButtons(BuildContext context) { + final children = [ + EntryInfoAction.editRating, + EntryInfoAction.editTags, + ].where(actionDelegate.canApply).map((v) => _buildEditMetadataButton(context, v)).toList(); + + return children.isEmpty + ? const SizedBox() + : TooltipTheme( + data: TooltipTheme.of(context).copyWith( + preferBelow: false, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: AvesFilterChip.outlineWidth / 2) + const EdgeInsets.only(top: 8), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: children, + ), + ), + ); + } + + Widget _buildEditMetadataButton(BuildContext context, EntryInfoAction action) { + return ValueListenableBuilder( + valueListenable: isEditingMetadataNotifier, + builder: (context, editingAction, child) { + final isEditing = editingAction != null; return Stack( children: [ DecoratedBox( - decoration: const BoxDecoration( + decoration: BoxDecoration( border: Border.fromBorderSide(BorderSide( - color: AvesFilterChip.defaultOutlineColor, + color: isEditing ? Theme.of(context).disabledColor : AvesFilterChip.defaultOutlineColor, width: AvesFilterChip.outlineWidth, )), - borderRadius: BorderRadius.all(Radius.circular(AvesFilterChip.defaultRadius)), + borderRadius: const BorderRadius.all(Radius.circular(AvesFilterChip.defaultRadius)), ), child: IconButton( - icon: const Icon(AIcons.addTag), + icon: action.getIcon(), onPressed: isEditing ? null : () => actionDelegate.onActionSelected(context, action), tooltip: action.getText(context), ), ), - if (isEditing) - const Positioned.fill( - child: Padding( + Positioned.fill( + child: Visibility( + visible: editingAction == action, + child: const Padding( padding: EdgeInsets.all(1.0), child: CircularProgressIndicator( strokeWidth: AvesFilterChip.outlineWidth, ), ), ), + ), ], ); }, diff --git a/lib/widgets/viewer/info/info_app_bar.dart b/lib/widgets/viewer/info/info_app_bar.dart index d44e8059d..0b4185886 100644 --- a/lib/widgets/viewer/info/info_app_bar.dart +++ b/lib/widgets/viewer/info/info_app_bar.dart @@ -5,7 +5,8 @@ import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/app_bar_title.dart'; import 'package:aves/widgets/common/basic/menu.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:aves/widgets/viewer/info/entry_info_action_delegate.dart'; +import 'package:aves/widgets/common/sliver_app_bar_title.dart'; +import 'package:aves/widgets/viewer/action/entry_info_action_delegate.dart'; import 'package:aves/widgets/viewer/info/info_search.dart'; import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart'; import 'package:flutter/material.dart'; @@ -37,9 +38,11 @@ class InfoAppBar extends StatelessWidget { onPressed: onBackPressed, tooltip: context.l10n.viewerInfoBackToViewerTooltip, ), - title: InteractiveAppBarTitle( - onTap: () => _goToSearch(context), - child: Text(context.l10n.viewerInfoPageTitle), + title: SliverAppBarTitleWrapper( + child: InteractiveAppBarTitle( + onTap: () => _goToSearch(context), + child: Text(context.l10n.viewerInfoPageTitle), + ), ), actions: [ IconButton( diff --git a/lib/widgets/viewer/info/info_page.dart b/lib/widgets/viewer/info/info_page.dart index 78810f832..ad14c8ee6 100644 --- a/lib/widgets/viewer/info/info_page.dart +++ b/lib/widgets/viewer/info/info_page.dart @@ -8,9 +8,9 @@ import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:aves/widgets/viewer/action/entry_info_action_delegate.dart'; import 'package:aves/widgets/viewer/embedded/embedded_data_opener.dart'; import 'package:aves/widgets/viewer/info/basic_section.dart'; -import 'package:aves/widgets/viewer/info/entry_info_action_delegate.dart'; import 'package:aves/widgets/viewer/info/info_app_bar.dart'; import 'package:aves/widgets/viewer/info/location_section.dart'; import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart'; @@ -150,7 +150,7 @@ class _InfoPageContentState extends State<_InfoPageContent> { final List _subscriptions = []; late EntryInfoActionDelegate _actionDelegate; final ValueNotifier> _metadataNotifier = ValueNotifier({}); - final ValueNotifier _isEditingTagNotifier = ValueNotifier(false); + final ValueNotifier _isEditingMetadataNotifier = ValueNotifier(null); static const horizontalPadding = EdgeInsets.symmetric(horizontal: 8); @@ -197,7 +197,7 @@ class _InfoPageContentState extends State<_InfoPageContent> { entry: entry, collection: collection, actionDelegate: _actionDelegate, - isEditingTagNotifier: _isEditingTagNotifier, + isEditingMetadataNotifier: _isEditingMetadataNotifier, onFilter: _goToCollection, ); final locationAtTop = widget.split && entry.hasGps; @@ -255,15 +255,13 @@ class _InfoPageContentState extends State<_InfoPageContent> { } void _onActionDelegateEvent(ActionEvent event) { - if (event.action == EntryInfoAction.editTags) { - Future.delayed(Durations.dialogTransitionAnimation).then((_) { - if (event is ActionStartedEvent) { - _isEditingTagNotifier.value = true; - } else if (event is ActionEndedEvent) { - _isEditingTagNotifier.value = false; - } - }); - } + Future.delayed(Durations.dialogTransitionAnimation).then((_) { + if (event is ActionStartedEvent) { + _isEditingMetadataNotifier.value = event.action; + } else if (event is ActionEndedEvent) { + _isEditingMetadataNotifier.value = null; + } + }); } void _goToCollection(CollectionFilter filter) { diff --git a/lib/widgets/viewer/info/info_search.dart b/lib/widgets/viewer/info/info_search.dart index 30c8b6e3e..6dbb9200a 100644 --- a/lib/widgets/viewer/info/info_search.dart +++ b/lib/widgets/viewer/info/info_search.dart @@ -54,8 +54,8 @@ class InfoSearchDelegate extends SearchDelegate { Widget buildSuggestions(BuildContext context) { final l10n = context.l10n; final suggestions = { - l10n.viewerInfoSearchSuggestionDate: 'date or time or when -timer -uptime -exposure -timeline', - l10n.viewerInfoSearchSuggestionDescription: 'abstract or description or comment or textual or title', + l10n.viewerInfoSearchSuggestionDate: 'date or time or when -timer -uptime -exposure -timeline -verbatim', + l10n.viewerInfoSearchSuggestionDescription: 'abstract or description or comment or textual or title -line', l10n.viewerInfoSearchSuggestionDimensions: 'width or height or dimension or framesize or imagelength', l10n.viewerInfoSearchSuggestionResolution: 'resolution', l10n.viewerInfoSearchSuggestionRights: 'rights or copyright or attribution or license or artist or creator or by-line or credit -tool', diff --git a/lib/widgets/viewer/info/metadata/xmp_namespaces.dart b/lib/widgets/viewer/info/metadata/xmp_namespaces.dart index 317ec7e5d..d4154c64e 100644 --- a/lib/widgets/viewer/info/metadata/xmp_namespaces.dart +++ b/lib/widgets/viewer/info/metadata/xmp_namespaces.dart @@ -1,7 +1,7 @@ import 'package:aves/ref/brand_colors.dart'; -import 'package:aves/ref/xmp.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/utils/string_utils.dart'; +import 'package:aves/utils/xmp_utils.dart'; import 'package:aves/widgets/common/identity/highlight_title.dart'; import 'package:aves/widgets/viewer/info/common.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_ns/crs.dart'; @@ -66,7 +66,55 @@ class XmpNamespace extends Equatable { } } - String get displayTitle => XMP.namespaces[namespace] ?? namespace; + // cf https://exiftool.org/TagNames/XMP.html + static const Map nsTitles = { + 'acdsee': 'ACDSee', + 'adsml-at': 'AdsML', + 'aux': 'Exif Aux', + 'avm': 'Astronomy Visualization', + 'Camera': 'Camera', + 'cc': 'Creative Commons', + 'crd': 'Camera Raw Defaults', + 'creatorAtom': 'After Effects', + 'crs': 'Camera Raw Settings', + 'dc': 'Dublin Core', + 'drone-dji': 'DJI Drone', + 'dwc': 'Darwin Core', + 'exif': 'Exif', + 'exifEX': 'Exif Ex', + 'GettyImagesGIFT': 'Getty Images', + 'GAudio': 'Google Audio', + 'GDepth': 'Google Depth', + 'GImage': 'Google Image', + 'GIMP': 'GIMP', + 'GCamera': 'Google Camera', + 'GCreations': 'Google Creations', + 'GFocus': 'Google Focus', + 'GPano': 'Google Panorama', + 'illustrator': 'Illustrator', + 'Iptc4xmpCore': 'IPTC Core', + 'Iptc4xmpExt': 'IPTC Extension', + 'lr': 'Lightroom', + 'MicrosoftPhoto': 'Microsoft Photo', + 'mwg-rs': 'Regions', + 'panorama': 'Panorama', + 'PanoStudioXMP': 'PanoramaStudio', + 'pdf': 'PDF', + 'pdfx': 'PDF/X', + 'photomechanic': 'Photo Mechanic', + 'photoshop': 'Photoshop', + 'plus': 'PLUS', + 'pmtm': 'Photomatix', + 'tiff': 'TIFF', + 'xmp': 'Basic', + 'xmpBJ': 'Basic Job Ticket', + 'xmpDM': 'Dynamic Media', + 'xmpMM': 'Media Management', + 'xmpRights': 'Rights Management', + 'xmpTPg': 'Paged-Text', + }; + + String get displayTitle => nsTitles[namespace] ?? namespace; Map get buildProps => rawProps; diff --git a/lib/widgets/viewer/info/metadata/xmp_tile.dart b/lib/widgets/viewer/info/metadata/xmp_tile.dart index 7c738e002..01dd5a393 100644 --- a/lib/widgets/viewer/info/metadata/xmp_tile.dart +++ b/lib/widgets/viewer/info/metadata/xmp_tile.dart @@ -1,8 +1,8 @@ import 'dart:collection'; import 'package:aves/model/entry.dart'; -import 'package:aves/ref/xmp.dart'; import 'package:aves/utils/color_utils.dart'; +import 'package:aves/utils/xmp_utils.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart'; import 'package:collection/collection.dart'; diff --git a/lib/widgets/viewer/overlay/top.dart b/lib/widgets/viewer/overlay/top.dart index 0d255c6a2..846f258d5 100644 --- a/lib/widgets/viewer/overlay/top.dart +++ b/lib/widgets/viewer/overlay/top.dart @@ -1,15 +1,12 @@ import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/device.dart'; import 'package:aves/model/entry.dart'; -import 'package:aves/model/favourites.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/durations.dart'; -import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/basic/menu.dart'; import 'package:aves/widgets/common/basic/popup_menu_button.dart'; -import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:aves/widgets/common/fx/sweeper.dart'; -import 'package:aves/widgets/viewer/entry_action_delegate.dart'; +import 'package:aves/widgets/common/favourite_toggler.dart'; +import 'package:aves/widgets/viewer/action/entry_action_delegate.dart'; import 'package:aves/widgets/viewer/multipage/conductor.dart'; import 'package:aves/widgets/viewer/overlay/common.dart'; import 'package:aves/widgets/viewer/overlay/minimap.dart'; @@ -204,8 +201,8 @@ class _TopOverlayRow extends StatelessWidget { void onPressed() => _onActionSelected(context, action); switch (action) { case EntryAction.toggleFavourite: - child = _FavouriteToggler( - entry: favouriteTargetEntry, + child = FavouriteToggler( + entries: {favouriteTargetEntry}, onPressed: onPressed, ); break; @@ -251,8 +248,8 @@ class _TopOverlayRow extends StatelessWidget { switch (action) { // in app actions case EntryAction.toggleFavourite: - child = _FavouriteToggler( - entry: favouriteTargetEntry, + child = FavouriteToggler( + entries: {favouriteTargetEntry}, isMenuItem: true, ); break; @@ -312,83 +309,6 @@ class _TopOverlayRow extends StatelessWidget { } } } - EntryActionDelegate().onActionSelected(context, targetEntry, action); - } -} - -class _FavouriteToggler extends StatefulWidget { - final AvesEntry entry; - final bool isMenuItem; - final VoidCallback? onPressed; - - const _FavouriteToggler({ - required this.entry, - this.isMenuItem = false, - this.onPressed, - }); - - @override - _FavouriteTogglerState createState() => _FavouriteTogglerState(); -} - -class _FavouriteTogglerState extends State<_FavouriteToggler> { - final ValueNotifier isFavouriteNotifier = ValueNotifier(false); - - @override - void initState() { - super.initState(); - favourites.addListener(_onChanged); - _onChanged(); - } - - @override - void didUpdateWidget(covariant _FavouriteToggler oldWidget) { - super.didUpdateWidget(oldWidget); - _onChanged(); - } - - @override - void dispose() { - favourites.removeListener(_onChanged); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return ValueListenableBuilder( - valueListenable: isFavouriteNotifier, - builder: (context, isFavourite, child) { - if (widget.isMenuItem) { - return isFavourite - ? MenuRow( - text: context.l10n.entryActionRemoveFavourite, - icon: const Icon(AIcons.favouriteActive), - ) - : MenuRow( - text: context.l10n.entryActionAddFavourite, - icon: const Icon(AIcons.favourite), - ); - } - return Stack( - alignment: Alignment.center, - children: [ - IconButton( - icon: Icon(isFavourite ? AIcons.favouriteActive : AIcons.favourite), - onPressed: widget.onPressed, - tooltip: isFavourite ? context.l10n.entryActionRemoveFavourite : context.l10n.entryActionAddFavourite, - ), - Sweeper( - key: ValueKey(widget.entry), - builder: (context) => const Icon(AIcons.favourite, color: Colors.redAccent), - toggledNotifier: isFavouriteNotifier, - ), - ], - ); - }, - ); - } - - void _onChanged() { - isFavouriteNotifier.value = widget.entry.isFavourite; + EntryActionDelegate(targetEntry).onActionSelected(context, action); } } diff --git a/lib/widgets/viewer/panorama_page.dart b/lib/widgets/viewer/panorama_page.dart index 5cdaa4dd1..2ca1be7a2 100644 --- a/lib/widgets/viewer/panorama_page.dart +++ b/lib/widgets/viewer/panorama_page.dart @@ -109,7 +109,7 @@ class _PanoramaPageState extends State { valueListenable: _sensorControl, builder: (context, sensorControl, child) { return IconButton( - icon: Icon(sensorControl == SensorControl.None ? AIcons.sensorControl : AIcons.sensorControlOff), + icon: Icon(sensorControl == SensorControl.None ? AIcons.sensorControlEnabled : AIcons.sensorControlDisabled), onPressed: _toggleSensor, tooltip: sensorControl == SensorControl.None ? context.l10n.panoramaEnableSensorControl : context.l10n.panoramaDisableSensorControl, ); diff --git a/pubspec.yaml b/pubspec.yaml index 3c42fcd40..49396db87 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: aves description: A visual media gallery and metadata explorer app. repository: https://github.com/deckerst/aves -version: 1.5.9+63 +version: 1.5.10+64 publish_to: none environment: diff --git a/test/model/filters_test.dart b/test/model/filters_test.dart index af979fad6..b8eaf5ce5 100644 --- a/test/model/filters_test.dart +++ b/test/model/filters_test.dart @@ -6,6 +6,7 @@ import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/filters/path.dart'; import 'package:aves/model/filters/query.dart'; +import 'package:aves/model/filters/rating.dart'; import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/filters/type.dart'; import 'package:aves/services/common/services.dart'; @@ -50,6 +51,9 @@ void main() { final query = QueryFilter('some query'); expect(query, jsonRoundTrip(query)); + const rating = RatingFilter(3); + expect(rating, jsonRoundTrip(rating)); + final tag = TagFilter('some tag'); expect(tag, jsonRoundTrip(tag)); diff --git a/test/model/video/metadata_test.dart b/test/model/video/metadata_test.dart index ac60cb941..469e0c574 100644 --- a/test/model/video/metadata_test.dart +++ b/test/model/video/metadata_test.dart @@ -7,5 +7,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); }); } diff --git a/test/utils/xmp_utils_test.dart b/test/utils/xmp_utils_test.dart new file mode 100644 index 000000000..a81497a6b --- /dev/null +++ b/test/utils/xmp_utils_test.dart @@ -0,0 +1,405 @@ +import 'package:aves/model/entry_metadata_edition.dart'; +import 'package:aves/utils/xmp_utils.dart'; +import 'package:test/test.dart'; +import 'package:xml/xml.dart'; + +void main() { + const toolkit = 'test-toolkit'; + + String? _toExpect(String? xmpString) => xmpString != null + ? XmlDocument.parse(xmpString).toXmlString( + pretty: true, + sortAttributes: (a, b) => a.name.qualified.compareTo(b.name.qualified), + ) + : null; + + List _getDescriptions(String xmpString) { + final xmpDoc = XmlDocument.parse(xmpString); + final root = xmpDoc.rootElement; + final rdf = root.getElement(XMP.rdfRoot, namespace: Namespaces.rdf); + return rdf!.children.where((node) { + return node is XmlElement && node.name.local == XMP.rdfDescription && node.name.namespaceUri == Namespaces.rdf; + }).toList(); + } + + const inMultiDescriptionRatings = ''' + + + + 5 + + + 99 + + + +'''; + const inRatingAttribute = ''' + + + + + +'''; + const inRatingElement = ''' + + + + 5 + + + +'''; + const inSubjects = ''' + + + + + + the king + + + + + +'''; + const inSubjectsCreator = ''' + + + + + + c + + + + + a + b + + + + + +'''; + + test('Get string', () async { + expect(XMP.getString(_getDescriptions(inRatingAttribute), XMP.xmpRating, namespace: Namespaces.xmp), '5'); + expect(XMP.getString(_getDescriptions(inRatingElement), XMP.xmpRating, namespace: Namespaces.xmp), '5'); + expect(XMP.getString(_getDescriptions(inSubjects), XMP.xmpRating, namespace: Namespaces.xmp), null); + }); + + test('Set tags without existing XMP', () async { + final modifyDate = DateTime.now(); + final xmpDate = XMP.toXmpDate(modifyDate); + + expect( + _toExpect(await XMP.edit( + null, + () async => toolkit, + (descriptions) => ExtraAvesEntryMetadataEdition.editTagsXmp(descriptions, {'one', 'two'}), + modifyDate: modifyDate, + )), + _toExpect(''' + + + + + + one + two + + + + + +''')); + }); + + test('Set tags to XMP with ratings (multiple descriptions)', () async { + final modifyDate = DateTime.now(); + final xmpDate = XMP.toXmpDate(modifyDate); + + expect( + _toExpect(await XMP.edit( + inMultiDescriptionRatings, + () async => toolkit, + (descriptions) => ExtraAvesEntryMetadataEdition.editTagsXmp(descriptions, {'one', 'two'}), + modifyDate: modifyDate, + )), + _toExpect(''' + + + + 5 + + + one + two + + + + + 99 + + + +''')); + }); + + test('Set tags to XMP with subjects only', () async { + final modifyDate = DateTime.now(); + final xmpDate = XMP.toXmpDate(modifyDate); + + expect( + _toExpect(await XMP.edit( + inSubjects, + () async => toolkit, + (descriptions) => ExtraAvesEntryMetadataEdition.editTagsXmp(descriptions, {'one', 'two'}), + modifyDate: modifyDate, + )), + _toExpect(''' + + + + + + one + two + + + + + +''')); + }); + + test('Remove tags from XMP with subjects only', () async { + expect( + _toExpect(await XMP.edit( + inSubjects, + () async => toolkit, + (descriptions) => ExtraAvesEntryMetadataEdition.editTagsXmp(descriptions, {}), + )), + _toExpect(null)); + }); + + test('Remove tags from XMP with subjects and creator', () async { + final modifyDate = DateTime.now(); + final xmpDate = XMP.toXmpDate(modifyDate); + + expect( + _toExpect(await XMP.edit( + inSubjectsCreator, + () async => toolkit, + (descriptions) => ExtraAvesEntryMetadataEdition.editTagsXmp(descriptions, {}), + modifyDate: modifyDate, + )), + _toExpect(''' + + + + + + c + + + + + +''')); + }); + + test('Set rating without existing XMP', () async { + final modifyDate = DateTime.now(); + final xmpDate = XMP.toXmpDate(modifyDate); + + expect( + _toExpect(await XMP.edit( + null, + () async => toolkit, + (descriptions) => ExtraAvesEntryMetadataEdition.editRatingXmp(descriptions, 3), + modifyDate: modifyDate, + )), + _toExpect(''' + + + + + +''')); + }); + + test('Set rating to XMP with ratings (multiple descriptions)', () async { + final modifyDate = DateTime.now(); + final xmpDate = XMP.toXmpDate(modifyDate); + + expect( + _toExpect(await XMP.edit( + inMultiDescriptionRatings, + () async => toolkit, + (descriptions) => ExtraAvesEntryMetadataEdition.editRatingXmp(descriptions, 3), + modifyDate: modifyDate, + )), + _toExpect(''' + + + + + +''')); + }); + + test('Set rating to XMP with rating attribute', () async { + final modifyDate = DateTime.now(); + final xmpDate = XMP.toXmpDate(modifyDate); + + expect( + _toExpect(await XMP.edit( + inRatingAttribute, + () async => toolkit, + (descriptions) => ExtraAvesEntryMetadataEdition.editRatingXmp(descriptions, 3), + modifyDate: modifyDate, + )), + _toExpect(''' + + + + + +''')); + }); + + test('Set rating to XMP with rating element', () async { + final modifyDate = DateTime.now(); + final xmpDate = XMP.toXmpDate(modifyDate); + + expect( + _toExpect(await XMP.edit( + inRatingElement, + () async => toolkit, + (descriptions) => ExtraAvesEntryMetadataEdition.editRatingXmp(descriptions, 3), + modifyDate: modifyDate, + )), + _toExpect(''' + + + + + +''')); + }); + + test('Set rating to XMP with subjects only', () async { + final modifyDate = DateTime.now(); + final xmpDate = XMP.toXmpDate(modifyDate); + + expect( + _toExpect(await XMP.edit( + inSubjects, + () async => toolkit, + (descriptions) => ExtraAvesEntryMetadataEdition.editRatingXmp(descriptions, 3), + modifyDate: modifyDate, + )), + _toExpect(''' + + + + + + the king + + + + + +''')); + }); + + test('Remove rating from XMP with subjects only', () async { + final modifyDate = DateTime.now(); + final xmpDate = XMP.toXmpDate(modifyDate); + + expect( + _toExpect(await XMP.edit( + inSubjects, + () async => toolkit, + (descriptions) => ExtraAvesEntryMetadataEdition.editRatingXmp(descriptions, null), + modifyDate: modifyDate, + )), + _toExpect(''' + + + + + + the king + + + + + +''')); + }); + + test('Remove rating from XMP with ratings (multiple descriptions)', () async { + final modifyDate = DateTime.now(); + + expect( + _toExpect(await XMP.edit( + inMultiDescriptionRatings, + () async => toolkit, + (descriptions) => ExtraAvesEntryMetadataEdition.editRatingXmp(descriptions, null), + modifyDate: modifyDate, + )), + _toExpect(null)); + }); +} diff --git a/untranslated.json b/untranslated.json index 9e26dfeeb..3b8f080bb 100644 --- a/untranslated.json +++ b/untranslated.json @@ -1 +1,5 @@ -{} \ No newline at end of file +{ + "ru": [ + "settingsThumbnailShowFavouriteIcon" + ] +} diff --git a/whatsnew/whatsnew-en-US b/whatsnew/whatsnew-en-US index 743503987..9394939f5 100644 --- a/whatsnew/whatsnew-en-US +++ b/whatsnew/whatsnew-en-US @@ -1,6 +1,6 @@ Thanks for using Aves! -In v1.5.9: -- list view for items and albums -- moving, editing or deleting items can be cancelled -- enjoy the app in German +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