Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2022-01-07 16:48:52 +09:00
commit 3c408051b1
111 changed files with 3737 additions and 1456 deletions

View file

@ -4,6 +4,27 @@ All notable changes to this project will be documented in this file.
## <a id="unreleased"></a>[Unreleased] ## <a id="unreleased"></a>[Unreleased]
## <a id="v1.5.10"></a>[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
## <a id="v1.5.9"></a>[v1.5.9] - 2021-12-22 ## <a id="v1.5.9"></a>[v1.5.9] - 2021-12-22
### Added ### Added
@ -37,7 +58,8 @@ All notable changes to this project will be documented in this file.
### Changed ### Changed
- Settings: select hidden path directory with a custom file picker instead of the native SAF one - 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 ### Fixed
@ -75,7 +97,8 @@ All notable changes to this project will be documented in this file.
### Changed ### 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 - use 12/24 hour format settings from device to display times
- Privacy: consent request on first launch for installed app inventory access - 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) - use File API to rename and delete items, when possible (primary storage, Android <11)

View file

@ -59,7 +59,7 @@ At this stage this project does *not* accept PRs, except for translations.
### 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 ### Donations

View file

@ -1,6 +1,7 @@
package deckers.thibault.aves.channel.calls package deckers.thibault.aves.channel.calls
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.res.Resources import android.content.res.Resources
import android.os.Build import android.os.Build
import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.content.pm.ShortcutManagerCompat
@ -18,6 +19,7 @@ class DeviceHandler(private val context: Context) : MethodCallHandler {
"getDefaultTimeZone" -> safe(call, result, ::getDefaultTimeZone) "getDefaultTimeZone" -> safe(call, result, ::getDefaultTimeZone)
"getLocales" -> safe(call, result, ::getLocales) "getLocales" -> safe(call, result, ::getLocales)
"getPerformanceClass" -> safe(call, result, ::getPerformanceClass) "getPerformanceClass" -> safe(call, result, ::getPerformanceClass)
"isSystemFilePickerEnabled" -> safe(call, result, ::isSystemFilePickerEnabled)
else -> result.notImplemented() 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, // but using hybrid composition would make it usable on API 19 too,
// cf https://github.com/flutter/flutter/issues/23728 // cf https://github.com/flutter/flutter/issues/23728
"canRenderGoogleMaps" to (sdkInt >= Build.VERSION_CODES.KITKAT_WATCH), "canRenderGoogleMaps" to (sdkInt >= Build.VERSION_CODES.KITKAT_WATCH),
"hasFilePicker" to (sdkInt >= Build.VERSION_CODES.KITKAT),
"showPinShortcutFeedback" to (sdkInt >= Build.VERSION_CODES.O), "showPinShortcutFeedback" to (sdkInt >= Build.VERSION_CODES.O),
"supportEdgeToEdgeUIMode" to (sdkInt >= Build.VERSION_CODES.Q), "supportEdgeToEdgeUIMode" to (sdkInt >= Build.VERSION_CODES.Q),
) )
@ -82,6 +83,15 @@ class DeviceHandler(private val context: Context) : MethodCallHandler {
result.success(Build.VERSION.SDK_INT) 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 { companion object {
const val CHANNEL = "deckers.thibault/aves/device" const val CHANNEL = "deckers.thibault/aves/device"
} }

View file

@ -20,8 +20,7 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
"rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) } "rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) }
"flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) } "flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) }
"editDate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::editDate) } "editDate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::editDate) }
"setIptc" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::setIptc) } "editMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::editMetadata) }
"setXmp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::setXmp) }
"removeTypes" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::removeTypes) } "removeTypes" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::removeTypes) }
else -> result.notImplemented() else -> result.notImplemented()
} }
@ -99,12 +98,11 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
}) })
} }
private fun setIptc(call: MethodCall, result: MethodChannel.Result) { private fun editMetadata(call: MethodCall, result: MethodChannel.Result) {
val iptc = call.argument<List<FieldMap>>("iptc") val metadata = call.argument<FieldMap>("metadata")
val entryMap = call.argument<FieldMap>("entry") val entryMap = call.argument<FieldMap>("entry")
val postEditScan = call.argument<Boolean>("postEditScan") if (entryMap == null || metadata == null) {
if (entryMap == null || postEditScan == null) { result.error("editMetadata-args", "failed because of missing arguments", null)
result.error("setIptc-args", "failed because of missing arguments", null)
return return
} }
@ -112,48 +110,19 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
val path = entryMap["path"] as String? val path = entryMap["path"] as String?
val mimeType = entryMap["mimeType"] as String? val mimeType = entryMap["mimeType"] as String?
if (uri == null || path == null || mimeType == null) { 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 return
} }
val provider = getProvider(uri) val provider = getProvider(uri)
if (provider == null) { 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 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 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) override fun onFailure(throwable: Throwable) = result.error("editMetadata-failure", "failed to edit metadata for mimeType=$mimeType uri=$uri", throwable.message)
})
}
private fun setXmp(call: MethodCall, result: MethodChannel.Result) {
val xmp = call.argument<String>("xmp")
val extendedXmp = call.argument<String>("extendedXmp")
val entryMap = call.argument<FieldMap>("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)
}) })
} }

View file

@ -19,7 +19,10 @@ import com.drew.lang.KeyValuePair
import com.drew.lang.Rational import com.drew.lang.Rational
import com.drew.metadata.Tag import com.drew.metadata.Tag
import com.drew.metadata.avi.AviDirectory 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.file.FileTypeDirectory
import com.drew.metadata.gif.GifAnimationDirectory import com.drew.metadata.gif.GifAnimationDirectory
import com.drew.metadata.iptc.IptcDirectory import com.drew.metadata.iptc.IptcDirectory
@ -78,6 +81,7 @@ import java.nio.charset.Charset
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.text.ParseException import java.text.ParseException
import java.util.* import java.util.*
import kotlin.math.roundToInt
import kotlin.math.roundToLong import kotlin.math.roundToLong
class MetadataFetchHandler(private val context: Context) : MethodCallHandler { 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) } "getXmp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getXmp) }
"hasContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::hasContentResolverProp) } "hasContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::hasContentResolverProp) }
"getContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverProp) } "getContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverProp) }
"getDate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getDate) }
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
@ -113,8 +118,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
try { try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input) val metadata = ImageMetadataReader.readMetadata(input)
foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java) foundExif = metadata.directories.any { it is ExifDirectoryBase && it.tagCount > 0 }
foundXmp = metadata.containsDirectoryOfType(XmpDirectory::class.java) foundXmp = metadata.directories.any { it is XmpDirectory && it.tagCount > 0 }
val uuidDirCount = HashMap<String, Int>() val uuidDirCount = HashMap<String, Int>()
val dirByName = metadata.directories.filter { val dirByName = metadata.directories.filter {
it.tagCount > 0 it.tagCount > 0
@ -158,25 +163,16 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
// tags // tags
val tags = dir.tags val tags = dir.tags
if (mimeType == MimeTypes.TIFF && (dir is ExifIFD0Directory || dir is ExifThumbnailDirectory)) { if (dir is ExifDirectoryBase) {
fun tagMapper(it: Tag): Pair<String, String> { if (dir.isGeoTiff()) {
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()) {
// split GeoTIFF tags in their own directory // 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<String, String>().apply { metadataMap["GeoTIFF"] = HashMap<String, String>().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 { } else {
dirMap.putAll(tags.map { tagMapper(it) }) dirMap.putAll(tags.map { exifTagMapper(it) })
} }
} else if (dir.isPngTextDir()) { } else if (dir.isPngTextDir()) {
metadataMap.remove(thisDirName) metadataMap.remove(thisDirName)
@ -205,10 +201,15 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
val dirs = extractPngProfile(key, valueString) val dirs = extractPngProfile(key, valueString)
if (dirs?.any() == true) { if (dirs?.any() == true) {
dirs.forEach { profileDir -> dirs.forEach { profileDir ->
val profileDirName = profileDir.name val profileDirName = "${dir.name}/${profileDir.name}"
val profileDirMap = metadataMap[profileDirName] ?: HashMap() val profileDirMap = metadataMap[profileDirName] ?: HashMap()
metadataMap[profileDirName] = profileDirMap 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 null
} else { } else {
@ -357,22 +358,22 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
return dirMap return dirMap
} }
// legend: ME=MetadataExtractor, EI=ExifInterface, MMR=MediaMetadataRetriever
// set `KEY_DATE_MILLIS` from these fields (by precedence): // set `KEY_DATE_MILLIS` from these fields (by precedence):
// - ME / Exif / DATETIME_ORIGINAL // - Exif / DATETIME_ORIGINAL
// - ME / Exif / DATETIME // - Exif / DATETIME
// - EI / Exif / DATETIME_ORIGINAL // - XMP / xmp:CreateDate
// - EI / Exif / DATETIME // - XMP / photoshop:DateCreated
// - ME / XMP / xmp:CreateDate // - PNG / TIME / LAST_MODIFICATION_TIME
// - ME / XMP / photoshop:DateCreated // - Video / METADATA_KEY_DATE
// - ME / PNG / TIME / LAST_MODIFICATION_TIME
// - MMR / METADATA_KEY_DATE
// set `KEY_XMP_TITLE_DESCRIPTION` from these fields (by precedence): // set `KEY_XMP_TITLE_DESCRIPTION` from these fields (by precedence):
// - ME / XMP / dc:title // - XMP / dc:title
// - ME / XMP / dc:description // - XMP / dc:description
// set `KEY_XMP_SUBJECTS` from these fields (by precedence): // set `KEY_XMP_SUBJECTS` from these fields (by precedence):
// - ME / XMP / dc:subject // - XMP / dc:subject
// - ME / IPTC / keywords // - IPTC / keywords
// set `KEY_RATING` from these fields (by precedence):
// - XMP / xmp:Rating
// - XMP / MicrosoftPhoto:Rating
private fun getCatalogMetadata(call: MethodCall, result: MethodChannel.Result) { private fun getCatalogMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
@ -407,7 +408,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
try { try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input) val metadata = ImageMetadataReader.readMetadata(input)
foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java) foundExif = metadata.directories.any { it is ExifDirectoryBase && it.tagCount > 0 }
// File type // File type
for (dir in metadata.getDirectoriesOfType(FileTypeDirectory::class.java)) { for (dir in metadata.getDirectoriesOfType(FileTypeDirectory::class.java)) {
@ -432,13 +433,13 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
// EXIF // EXIF
for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) { 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)) { for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) {
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) { 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 val orientation = it
if (isFlippedForExifCode(orientation)) flags = flags or MASK_IS_FLIPPED if (isFlippedForExifCode(orientation)) flags = flags or MASK_IS_FLIPPED
metadataMap[KEY_ROTATION_DEGREES] = getRotationDegreesForExifCode(orientation) 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)) { for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
val xmpMeta = dir.xmpMeta val xmpMeta = dir.xmpMeta
try { try {
if (xmpMeta.doesPropertyExist(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME)) { if (xmpMeta.doesPropertyExist(XMP.DC_SCHEMA_NS, XMP.DC_SUBJECT_PROP_NAME)) {
val count = xmpMeta.countArrayItems(XMP.DC_SCHEMA_NS, XMP.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.SUBJECT_PROP_NAME, it).value } 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) 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)) { 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)) { 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)) { if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
xmpMeta.getSafeDateMillis(XMP.PHOTOSHOP_SCHEMA_NS, XMP.PS_DATE_CREATED_PROP_NAME) { metadataMap[KEY_DATE_MILLIS] = it } 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) // identification of panorama (aka photo sphere)
if (xmpMeta.isPanorama()) { if (xmpMeta.isPanorama()) {
flags = flags or MASK_IS_360 flags = flags or MASK_IS_360
@ -659,10 +669,10 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
val metadata = ImageMetadataReader.readMetadata(input) val metadata = ImageMetadataReader.readMetadata(input)
for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) { for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) {
foundExif = true foundExif = true
dir.getSafeRational(ExifSubIFDDirectory.TAG_FNUMBER) { metadataMap[KEY_APERTURE] = it.numerator.toDouble() / it.denominator } dir.getSafeRational(ExifDirectoryBase.TAG_FNUMBER) { metadataMap[KEY_APERTURE] = it.numerator.toDouble() / it.denominator }
dir.getSafeRational(ExifSubIFDDirectory.TAG_EXPOSURE_TIME, saveExposureTime) dir.getSafeRational(ExifDirectoryBase.TAG_EXPOSURE_TIME, saveExposureTime)
dir.getSafeRational(ExifSubIFDDirectory.TAG_FOCAL_LENGTH) { metadataMap[KEY_FOCAL_LENGTH] = it.numerator.toDouble() / it.denominator } dir.getSafeRational(ExifDirectoryBase.TAG_FOCAL_LENGTH) { metadataMap[KEY_FOCAL_LENGTH] = it.numerator.toDouble() / it.denominator }
dir.getSafeInt(ExifSubIFDDirectory.TAG_ISO_EQUIVALENT) { metadataMap[KEY_ISO] = it } dir.getSafeInt(ExifDirectoryBase.TAG_ISO_EQUIVALENT) { metadataMap[KEY_ISO] = it }
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
@ -876,6 +886,57 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
result.success(value?.toString()) result.success(value?.toString())
} }
private fun getDate(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
val field = call.argument<String>("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 { companion object {
private val LOG_TAG = LogUtils.createTag<MetadataFetchHandler>() private val LOG_TAG = LogUtils.createTag<MetadataFetchHandler>()
const val CHANNEL = "deckers.thibault/aves/metadata_fetch" const val CHANNEL = "deckers.thibault/aves/metadata_fetch"
@ -905,6 +966,15 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
omitXmpMetaElement = false // e.g. <x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core Test.SNAPSHOT">...</x:xmpmeta> omitXmpMetaElement = false // e.g. <x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core Test.SNAPSHOT">...</x:xmpmeta>
} }
private fun exifTagMapper(it: Tag): Pair<String, String> {
val name = if (it.hasTagName()) {
it.tagName
} else {
ExifTags.getTagName(it.tagType) ?: it.tagName
}
return Pair(name, it.description)
}
// catalog metadata // catalog metadata
private const val KEY_MIME_TYPE = "mimeType" private const val KEY_MIME_TYPE = "mimeType"
private const val KEY_DATE_MILLIS = "dateMillis" 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_LONGITUDE = "longitude"
private const val KEY_XMP_SUBJECTS = "xmpSubjects" private const val KEY_XMP_SUBJECTS = "xmpSubjects"
private const val KEY_XMP_TITLE_DESCRIPTION = "xmpTitleDescription" 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_ANIMATED = 1 shl 0
private const val MASK_IS_FLIPPED = 1 shl 1 private const val MASK_IS_FLIPPED = 1 shl 1

View file

@ -170,7 +170,7 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
MainActivity.pendingStorageAccessResultHandlers[MainActivity.OPEN_FILE_REQUEST] = PendingStorageAccessResultHandler(null, ::onGranted, ::onDenied) MainActivity.pendingStorageAccessResultHandlers[MainActivity.OPEN_FILE_REQUEST] = PendingStorageAccessResultHandler(null, ::onGranted, ::onDenied)
activity.startActivityForResult(intent, MainActivity.OPEN_FILE_REQUEST) activity.startActivityForResult(intent, MainActivity.OPEN_FILE_REQUEST)
} else { } else {
MainActivity.notifyError("failed to resolve activity for intent=$intent") MainActivity.notifyError("failed to resolve activity for intent=$intent extras=${intent.extras}")
onDenied() onDenied()
} }
} }

View file

@ -1,6 +1,7 @@
package deckers.thibault.aves.metadata package deckers.thibault.aves.metadata
object TiffTags { // Exif tags missing from `metadata-extractor`
object ExifTags {
// XPosition // XPosition
// Tag = 286 (011E.H) // Tag = 286 (011E.H)
private const val TAG_X_POSITION = 0x011e private const val TAG_X_POSITION = 0x011e
@ -32,6 +33,12 @@ object TiffTags {
// SAMPLEFORMAT_COMPLEXIEEEFP 6 // complex ieee floating // SAMPLEFORMAT_COMPLEXIEEEFP 6 // complex ieee floating
private const val TAG_SAMPLE_FORMAT = 0x0153 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 SGI
tags 32995-32999 tags 32995-32999
@ -125,6 +132,7 @@ object TiffTags {
TAG_COLOR_MAP to "Color Map", TAG_COLOR_MAP to "Color Map",
TAG_EXTRA_SAMPLES to "Extra Samples", TAG_EXTRA_SAMPLES to "Extra Samples",
TAG_SAMPLE_FORMAT to "Sample Format", TAG_SAMPLE_FORMAT to "Sample Format",
TAG_RATING_PERCENT to "Rating Percent",
// SGI // SGI
TAG_MATTEING to "Matteing", TAG_MATTEING to "Matteing",
// GeoTIFF // GeoTIFF

View file

@ -1,25 +1,35 @@
package deckers.thibault.aves.metadata package deckers.thibault.aves.metadata
import android.util.Log
import com.drew.lang.ByteArrayReader
import com.drew.lang.Rational import com.drew.lang.Rational
import com.drew.lang.SequentialByteArrayReader import com.drew.lang.SequentialByteArrayReader
import com.drew.metadata.Directory import com.drew.metadata.Directory
import com.drew.metadata.exif.ExifDirectoryBase
import com.drew.metadata.exif.ExifIFD0Directory import com.drew.metadata.exif.ExifIFD0Directory
import com.drew.metadata.exif.ExifReader
import com.drew.metadata.iptc.IptcReader import com.drew.metadata.iptc.IptcReader
import com.drew.metadata.png.PngDirectory import com.drew.metadata.png.PngDirectory
import deckers.thibault.aves.utils.LogUtils
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
object MetadataExtractorHelper { object MetadataExtractorHelper {
private val LOG_TAG = LogUtils.createTag<MetadataExtractorHelper>()
const val PNG_ITXT_DIR_NAME = "PNG-iTXt" const val PNG_ITXT_DIR_NAME = "PNG-iTXt"
private const val PNG_TEXT_DIR_NAME = "PNG-tEXt" private const val PNG_TEXT_DIR_NAME = "PNG-tEXt"
const val PNG_TIME_DIR_NAME = "PNG-tIME" const val PNG_TIME_DIR_NAME = "PNG-tIME"
private const val PNG_ZTXT_DIR_NAME = "PNG-zTXt" 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) val PNG_LAST_MODIFICATION_TIME_FORMAT = SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.ROOT)
// Pattern to extract profile name, length, and text data // Pattern to extract profile name, length, and text data
// of raw profiles (EXIF, IPTC, etc.) in PNG `zTXt` chunks // of raw profiles (EXIF, IPTC, etc.) in PNG `zTXt` chunks
// e.g. "iptc [...] 114 [...] 3842494d040400[...]" // 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) private val PNG_RAW_PROFILE_PATTERN = Regex("^\\n(.*?)\\n\\s*(\\d+)\\n(.*)", RegexOption.DOT_MATCHES_ALL)
// extensions // extensions
@ -59,14 +69,14 @@ object MetadataExtractorHelper {
- If the ModelTransformationTag is included in an IFD, then a ModelPixelScaleTag SHALL NOT be included - 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. - If the ModelPixelScaleTag is included in an IFD, then a ModelTiepointTag SHALL also be included.
*/ */
fun ExifIFD0Directory.isGeoTiff(): Boolean { fun ExifDirectoryBase.isGeoTiff(): Boolean {
if (!this.containsTag(TiffTags.TAG_GEO_KEY_DIRECTORY)) return false if (!this.containsTag(ExifTags.TAG_GEO_KEY_DIRECTORY)) return false
val modelTiepoint = this.containsTag(TiffTags.TAG_MODEL_TIEPOINT) val modelTiepoint = this.containsTag(ExifTags.TAG_MODEL_TIEPOINT)
val modelTransformation = this.containsTag(TiffTags.TAG_MODEL_TRANSFORMATION) val modelTransformation = this.containsTag(ExifTags.TAG_MODEL_TRANSFORMATION)
if (!modelTiepoint && !modelTransformation) return false 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 if ((modelTransformation && modelPixelScale) || (modelPixelScale && !modelTiepoint)) return false
return true return true
@ -77,39 +87,49 @@ 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 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<Directory>? { fun extractPngProfile(key: String, valueString: String): Iterable<Directory>? {
when (key) { if (key == PNG_RAW_PROFILE_EXIF || key == PNG_RAW_PROFILE_IPTC) {
"Raw profile type iptc" -> {
val match = PNG_RAW_PROFILE_PATTERN.matchEntire(valueString) val match = PNG_RAW_PROFILE_PATTERN.matchEntire(valueString)
if (match != null) { if (match != null) {
val dataString = match.groupValues[3] val dataString = match.groupValues[3]
val hexString = dataString.replace(Regex("[\\r\\n]"), "") val hexString = dataString.replace(Regex("[\\r\\n]"), "")
val dataBytes = hexStringToByteArray(hexString) val dataBytes = hexString.decodeHex()
if (dataBytes != null) { 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) val start = dataBytes.indexOf(Metadata.IPTC_MARKER_BYTE)
if (start != -1) { if (start != -1) {
val segmentBytes = dataBytes.copyOfRange(fromIndex = start, toIndex = dataBytes.size - start) val segmentBytes = dataBytes.copyOfRange(fromIndex = start, toIndex = dataBytes.size)
val metadata = com.drew.metadata.Metadata()
IptcReader().extract(SequentialByteArrayReader(segmentBytes), metadata, segmentBytes.size.toLong()) IptcReader().extract(SequentialByteArrayReader(segmentBytes), metadata, segmentBytes.size.toLong())
}
}
}
return metadata.directories return metadata.directories
} }
} }
} }
}
}
return null return null
} }
// convenience methods // convenience methods
private fun hexStringToByteArray(hexString: String): ByteArray? { private fun String.decodeHex(): ByteArray? {
if (hexString.length % 2 != 0) return null if (length % 2 != 0) return null
val dataBytes = ByteArray(hexString.length / 2) try {
var i = 0 val byteIterator = chunkedSequence(2)
while (i < hexString.length) { .map { it.toInt(16).toByte() }
dataBytes[i / 2] = hexString.substring(i, i + 2).toByte(16) .iterator()
i += 2
return ByteArray(length / 2) { byteIterator.next() }
} catch (e: NumberFormatException) {
Log.w(LOG_TAG, "failed to decode hex string=$this", e)
} }
return dataBytes return null
} }
} }

View file

@ -15,6 +15,7 @@ object XMP {
// standard namespaces // standard namespaces
// cf com.adobe.internal.xmp.XMPConst // cf com.adobe.internal.xmp.XMPConst
const val DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/" 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 PHOTOSHOP_SCHEMA_NS = "http://ns.adobe.com/photoshop/1.0/"
const val XMP_SCHEMA_NS = "http://ns.adobe.com/xap/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/" 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/" 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/" 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 DC_DESCRIPTION_PROP_NAME = "dc:description"
const val TITLE_PROP_NAME = "dc:title" const val DC_SUBJECT_PROP_NAME = "dc:subject"
const val DESCRIPTION_PROP_NAME = "dc:description" 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 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 GENERIC_LANG = ""
private const val SPECIFIC_LANG = "en-US" private const val SPECIFIC_LANG = "en-US"

View file

@ -800,18 +800,17 @@ abstract class ImageProvider {
} }
} }
fun setIptc( fun editMetadata(
context: Context, context: Context,
path: String, path: String,
uri: Uri, uri: Uri,
mimeType: String, mimeType: String,
postEditScan: Boolean, modifier: FieldMap,
callback: ImageOpCallback, callback: ImageOpCallback,
iptc: List<FieldMap>? = null,
) { ) {
val newFields = HashMap<String, Any?>() if (modifier.containsKey("iptc")) {
val iptc = (modifier["iptc"] as List<*>?)?.filterIsInstance<FieldMap>()
val success = editIptc( if (!editIptc(
context = context, context = context,
path = path, path = path,
uri = uri, uri = uri,
@ -819,30 +818,15 @@ abstract class ImageProvider {
callback = callback, callback = callback,
iptc = iptc, iptc = iptc,
) )
) return
if (success) {
if (postEditScan) {
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
} else {
callback.onSuccess(HashMap())
}
} else {
callback.onFailure(Exception("failed to set IPTC"))
}
} }
fun setXmp( if (modifier.containsKey("xmp")) {
context: Context, val xmp = modifier["xmp"] as Map<*, *>?
path: String, if (xmp != null) {
uri: Uri, val coreXmp = xmp["xmp"] as String?
mimeType: String, val extendedXmp = xmp["extendedXmp"] as String?
callback: ImageOpCallback, if (!editXmp(
coreXmp: String? = null,
extendedXmp: String? = null,
) {
val newFields = HashMap<String, Any?>()
val success = editXmp(
context = context, context = context,
path = path, path = path,
uri = uri, uri = uri,
@ -851,14 +835,14 @@ abstract class ImageProvider {
coreXmp = coreXmp, coreXmp = coreXmp,
extendedXmp = extendedXmp, extendedXmp = extendedXmp,
) )
) return
if (success) {
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
} else {
callback.onFailure(Exception("failed to set XMP"))
} }
} }
val newFields = HashMap<String, Any?>()
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
}
fun removeMetadataTypes( fun removeMetadataTypes(
context: Context, context: Context,
path: String, path: String,

View file

@ -55,7 +55,7 @@ object PermissionManager {
MainActivity.pendingStorageAccessResultHandlers[MainActivity.DOCUMENT_TREE_ACCESS_REQUEST] = PendingStorageAccessResultHandler(path, onGranted, onDenied) MainActivity.pendingStorageAccessResultHandlers[MainActivity.DOCUMENT_TREE_ACCESS_REQUEST] = PendingStorageAccessResultHandler(path, onGranted, onDenied)
activity.startActivityForResult(intent, MainActivity.DOCUMENT_TREE_ACCESS_REQUEST) activity.startActivityForResult(intent, MainActivity.DOCUMENT_TREE_ACCESS_REQUEST)
} else { } else {
MainActivity.notifyError("failed to resolve activity for intent=$intent") MainActivity.notifyError("failed to resolve activity for intent=$intent extras=${intent.extras}")
onDenied() onDenied()
} }
} }

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Aves</string>
<string name="search_shortcut_short_label">Búsqueda</string>
<string name="videos_shortcut_short_label">Videos</string>
<string name="analysis_channel_name">Explorar medios</string>
<string name="analysis_service_description">Explorar imágenes &amp; videos</string>
<string name="analysis_notification_default_title">Explorando medios</string>
<string name="analysis_notification_action_stop">Anular</string>
</resources>

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -0,0 +1,5 @@
<i>Aves</i> puede manejar todo tipo de imágenes y videos, incluyendo los típicos JPEG y MP4, pero además cosas mas exóticas como <b>TIFF multipágina, SVG, viejos AVI y más</b>! Inspecciona su colección multimedia para identificar <b>fotos en movimiento</b>, <b>panoramas</b> (conocidas como fotos esféricas), <b>videos en 360°</b> y también archivos <b>GeoTIFF</b>.
La <b>navegación y búsqueda</b> son partes importantes de <i>Aves</i>. Su propósito es que los usuarios puedan fácimente ir de álbumes a fotos, etiquetas, mapas, etc.
<i>Aves</i> se integra con Android (desde <b>API 19 a 31</b>, por ej. desde KitKat hasta S) con características como <b>vínculos de aplicación</b> y manejo de <b>búsqueda global</b>. También funciona como un <b>visor y seleccionador multimedia</b>.

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View file

@ -0,0 +1 @@
Galería y visor de metadatos

View file

@ -49,6 +49,7 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
} }
return await decode(bytes); return await decode(bytes);
} catch (error) { } 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'); debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$error');
throw StateError('$mimeType region decoding failed (page $pageId)'); throw StateError('$mimeType region decoding failed (page $pageId)');
} }

View file

@ -50,7 +50,8 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
} }
return await decode(bytes); return await decode(bytes);
} catch (error) { } 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)'); throw StateError('$mimeType decoding failed (page $pageId)');
} }
} }

View file

@ -68,6 +68,7 @@ class UriImage extends ImageProvider<UriImage> with EquatableMixin {
} }
return await decode(bytes); return await decode(bytes);
} catch (error) { } 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'); debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$error');
throw StateError('$mimeType decoding failed (page $pageId)'); throw StateError('$mimeType decoding failed (page $pageId)');
} finally { } finally {

View file

@ -22,7 +22,7 @@
"nextTooltip": "Nächste", "nextTooltip": "Nächste",
"showTooltip": "Anzeigen", "showTooltip": "Anzeigen",
"hideTooltip": "Ausblenden", "hideTooltip": "Ausblenden",
"removeTooltip": "Entfernen", "actionRemove": "Entfernen",
"resetButtonTooltip": "Zurücksetzen", "resetButtonTooltip": "Zurücksetzen",
"doubleBackExitMessage": "Tippen Sie zum Verlassen erneut auf „Zurück“.", "doubleBackExitMessage": "Tippen Sie zum Verlassen erneut auf „Zurück“.",
@ -73,12 +73,15 @@
"videoActionSettings": "Einstellungen", "videoActionSettings": "Einstellungen",
"entryInfoActionEditDate": "Datum & Uhrzeit bearbeiten", "entryInfoActionEditDate": "Datum & Uhrzeit bearbeiten",
"entryInfoActionEditRating": "Bewertung bearbeiten",
"entryInfoActionEditTags": "Tags bearbeiten", "entryInfoActionEditTags": "Tags bearbeiten",
"entryInfoActionRemoveMetadata": "Metadaten entfernen", "entryInfoActionRemoveMetadata": "Metadaten entfernen",
"filterFavouriteLabel": "Favorit", "filterFavouriteLabel": "Favorit",
"filterLocationEmptyLabel": "Ungeortet", "filterLocationEmptyLabel": "Ungeortet",
"filterTagEmptyLabel": "Unmarkiert", "filterTagEmptyLabel": "Unmarkiert",
"filterRatingUnratedLabel": "Nicht bewertet",
"filterRatingRejectedLabel": "Verworfen",
"filterTypeAnimatedLabel": "Animationen", "filterTypeAnimatedLabel": "Animationen",
"filterTypeMotionPhotoLabel": "Bewegtes Foto", "filterTypeMotionPhotoLabel": "Bewegtes Foto",
"filterTypePanoramaLabel": "Panorama", "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.", "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", "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.", "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", "unsupportedTypeDialogTitle": "Nicht unterstützte Typen",
"unsupportedTypeDialogMessage": " {count, plural, =1{Dieser Vorgang wird für Elemente des folgenden Typs nicht unterstützt: {types}.} other{Dieser Vorgang wird für Elemente der folgenden Typen nicht unterstützt: {types}.}}", "unsupportedTypeDialogMessage": " {count, plural, =1{Dieser Vorgang wird für Elemente des folgenden Typs nicht unterstützt: {types}.} other{Dieser Vorgang wird für Elemente der folgenden Typen nicht unterstützt: {types}.}}",
@ -178,14 +183,17 @@
"renameEntryDialogLabel": "Neuer Name", "renameEntryDialogLabel": "Neuer Name",
"editEntryDateDialogTitle": "Datum & Uhrzeit", "editEntryDateDialogTitle": "Datum & Uhrzeit",
"editEntryDateDialogSet": "Festlegen", "editEntryDateDialogSetCustom": "Datum einstellen",
"editEntryDateDialogShift": "Verschieben", "editEntryDateDialogCopyField": "Von anderem Datum kopieren",
"editEntryDateDialogExtractFromTitle": "Auszug aus dem Titel", "editEntryDateDialogExtractFromTitle": "Auszug aus dem Titel",
"editEntryDateDialogClear": "Aufräumen", "editEntryDateDialogShift": "Verschieben",
"editEntryDateDialogFieldSelection": "Feldauswahl", "editEntryDateDialogSourceFileModifiedDate": "Änderungsdatum der Datei",
"editEntryDateDialogTargetFieldsHeader": "Zu ändernde Felder",
"editEntryDateDialogHours": "Stunden", "editEntryDateDialogHours": "Stunden",
"editEntryDateDialogMinutes": "Minuten", "editEntryDateDialogMinutes": "Minuten",
"editEntryRatingDialogTitle": "Bewertung",
"removeEntryMetadataDialogTitle": "Entfernung von Metadaten", "removeEntryMetadataDialogTitle": "Entfernung von Metadaten",
"removeEntryMetadataDialogMore": "Mehr", "removeEntryMetadataDialogMore": "Mehr",
@ -270,6 +278,7 @@
"collectionSortDate": "Nach Datum", "collectionSortDate": "Nach Datum",
"collectionSortSize": "Nach Größe", "collectionSortSize": "Nach Größe",
"collectionSortName": "Nach Album & Dateiname", "collectionSortName": "Nach Album & Dateiname",
"collectionSortRating": "Nach Bewertung",
"collectionGroupAlbum": "Nach Album", "collectionGroupAlbum": "Nach Album",
"collectionGroupMonth": "Nach Monat", "collectionGroupMonth": "Nach Monat",
@ -343,6 +352,7 @@
"searchSectionCountries": "Länder", "searchSectionCountries": "Länder",
"searchSectionPlaces": "Orte", "searchSectionPlaces": "Orte",
"searchSectionTags": "Tags", "searchSectionTags": "Tags",
"searchSectionRating": "Bewertungen",
"settingsPageTitle": "Einstellungen", "settingsPageTitle": "Einstellungen",
"settingsSystemDefault": "System", "settingsSystemDefault": "System",
@ -366,8 +376,10 @@
"settingsNavigationDrawerAddAlbum": "Album hinzufügen", "settingsNavigationDrawerAddAlbum": "Album hinzufügen",
"settingsSectionThumbnails": "Vorschaubilder", "settingsSectionThumbnails": "Vorschaubilder",
"settingsThumbnailShowFavouriteIcon": "Favoriten-Symbol anzeigen",
"settingsThumbnailShowLocationIcon": "Standort-Symbol anzeigen", "settingsThumbnailShowLocationIcon": "Standort-Symbol anzeigen",
"settingsThumbnailShowMotionPhotoIcon": "Bewegungsfoto-Symbol anzeigen", "settingsThumbnailShowMotionPhotoIcon": "Bewegungsfoto-Symbol anzeigen",
"settingsThumbnailShowRating": "Bewertung anzeigen",
"settingsThumbnailShowRawIcon": "Rohdaten-Symbol anzeigen", "settingsThumbnailShowRawIcon": "Rohdaten-Symbol anzeigen",
"settingsThumbnailShowVideoDuration": "Videodauer anzeigen", "settingsThumbnailShowVideoDuration": "Videodauer anzeigen",

View file

@ -37,7 +37,7 @@
"nextTooltip": "Next", "nextTooltip": "Next",
"showTooltip": "Show", "showTooltip": "Show",
"hideTooltip": "Hide", "hideTooltip": "Hide",
"removeTooltip": "Remove", "actionRemove": "Remove",
"resetButtonTooltip": "Reset", "resetButtonTooltip": "Reset",
"doubleBackExitMessage": "Tap “back” again to exit.", "doubleBackExitMessage": "Tap “back” again to exit.",
@ -88,12 +88,15 @@
"videoActionSettings": "Settings", "videoActionSettings": "Settings",
"entryInfoActionEditDate": "Edit date & time", "entryInfoActionEditDate": "Edit date & time",
"entryInfoActionEditRating": "Edit rating",
"entryInfoActionEditTags": "Edit tags", "entryInfoActionEditTags": "Edit tags",
"entryInfoActionRemoveMetadata": "Remove metadata", "entryInfoActionRemoveMetadata": "Remove metadata",
"filterFavouriteLabel": "Favourite", "filterFavouriteLabel": "Favourite",
"filterLocationEmptyLabel": "Unlocated", "filterLocationEmptyLabel": "Unlocated",
"filterTagEmptyLabel": "Untagged", "filterTagEmptyLabel": "Untagged",
"filterRatingUnratedLabel": "Unrated",
"filterRatingRejectedLabel": "Rejected",
"filterTypeAnimatedLabel": "Animated", "filterTypeAnimatedLabel": "Animated",
"filterTypeMotionPhotoLabel": "Motion Photo", "filterTypeMotionPhotoLabel": "Motion Photo",
"filterTypePanoramaLabel": "Panorama", "filterTypePanoramaLabel": "Panorama",
@ -109,10 +112,12 @@
"@coordinateDms": { "@coordinateDms": {
"placeholders": { "placeholders": {
"coordinate": { "coordinate": {
"type": "String" "type": "String",
"example": "38° 41 47.72″"
}, },
"direction": { "direction": {
"type": "String" "type": "String",
"example": "S"
} }
} }
}, },
@ -159,7 +164,9 @@
"@otherDirectoryDescription": { "@otherDirectoryDescription": {
"placeholders": { "placeholders": {
"name": { "name": {
"type": "String" "type": "String",
"example": "Pictures",
"description": "the name of a specific directory"
} }
} }
}, },
@ -168,10 +175,13 @@
"@storageAccessDialogMessage": { "@storageAccessDialogMessage": {
"placeholders": { "placeholders": {
"directory": { "directory": {
"type": "String" "type": "String",
"description": "the name of a directory, using the output of `rootDirectoryDescription` or `otherDirectoryDescription`"
}, },
"volume": { "volume": {
"type": "String" "type": "String",
"example": "SD card",
"description": "the name of a storage volume"
} }
} }
}, },
@ -180,10 +190,13 @@
"@restrictedAccessDialogMessage": { "@restrictedAccessDialogMessage": {
"placeholders": { "placeholders": {
"directory": { "directory": {
"type": "String" "type": "String",
"description": "the name of a directory, using the output of `rootDirectoryDescription` or `otherDirectoryDescription`"
}, },
"volume": { "volume": {
"type": "String" "type": "String",
"example": "SD card",
"description": "the name of a storage volume"
} }
} }
}, },
@ -192,16 +205,22 @@
"@notEnoughSpaceDialogMessage": { "@notEnoughSpaceDialogMessage": {
"placeholders": { "placeholders": {
"neededSize": { "neededSize": {
"type": "String" "type": "String",
"example": "314 MB"
}, },
"freeSize": { "freeSize": {
"type": "String" "type": "String",
"example": "123 MB"
}, },
"volume": { "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", "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}.}}", "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": { "placeholders": {
"count": {}, "count": {},
"types": { "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": "Do you want to resume playing at {time}?",
"@videoResumeDialogMessage": { "@videoResumeDialogMessage": {
"placeholders": { "placeholders": {
"time": {} "time": {
"type": "String",
"example": "13:37"
}
} }
}, },
"videoStartOverButtonLabel": "START OVER", "videoStartOverButtonLabel": "START OVER",
@ -271,14 +295,17 @@
"renameEntryDialogLabel": "New name", "renameEntryDialogLabel": "New name",
"editEntryDateDialogTitle": "Date & Time", "editEntryDateDialogTitle": "Date & Time",
"editEntryDateDialogSet": "Set", "editEntryDateDialogSetCustom": "Set custom date",
"editEntryDateDialogShift": "Shift", "editEntryDateDialogCopyField": "Copy from other date",
"editEntryDateDialogExtractFromTitle": "Extract from title", "editEntryDateDialogExtractFromTitle": "Extract from title",
"editEntryDateDialogClear": "Clear", "editEntryDateDialogShift": "Shift",
"editEntryDateDialogFieldSelection": "Field selection", "editEntryDateDialogSourceFileModifiedDate": "File modified date",
"editEntryDateDialogTargetFieldsHeader": "Fields to modify",
"editEntryDateDialogHours": "Hours", "editEntryDateDialogHours": "Hours",
"editEntryDateDialogMinutes": "Minutes", "editEntryDateDialogMinutes": "Minutes",
"editEntryRatingDialogTitle": "Rating",
"removeEntryMetadataDialogTitle": "Metadata Removal", "removeEntryMetadataDialogTitle": "Metadata Removal",
"removeEntryMetadataDialogMore": "More", "removeEntryMetadataDialogMore": "More",
@ -338,10 +365,12 @@
"@aboutCreditsTranslatorLine": { "@aboutCreditsTranslatorLine": {
"placeholders": { "placeholders": {
"language": { "language": {
"type": "String" "type": "String",
"example": "Sumerian"
}, },
"names": { "names": {
"type": "String" "type": "String",
"example": "Reggie Lampert"
} }
} }
}, },
@ -378,6 +407,7 @@
"collectionSortDate": "By date", "collectionSortDate": "By date",
"collectionSortSize": "By size", "collectionSortSize": "By size",
"collectionSortName": "By album & file name", "collectionSortName": "By album & file name",
"collectionSortRating": "By rating",
"collectionGroupAlbum": "By album", "collectionGroupAlbum": "By album",
"collectionGroupMonth": "By month", "collectionGroupMonth": "By month",
@ -491,6 +521,7 @@
"searchSectionCountries": "Countries", "searchSectionCountries": "Countries",
"searchSectionPlaces": "Places", "searchSectionPlaces": "Places",
"searchSectionTags": "Tags", "searchSectionTags": "Tags",
"searchSectionRating": "Ratings",
"settingsPageTitle": "Settings", "settingsPageTitle": "Settings",
"settingsSystemDefault": "System", "settingsSystemDefault": "System",
@ -514,8 +545,10 @@
"settingsNavigationDrawerAddAlbum": "Add album", "settingsNavigationDrawerAddAlbum": "Add album",
"settingsSectionThumbnails": "Thumbnails", "settingsSectionThumbnails": "Thumbnails",
"settingsThumbnailShowFavouriteIcon": "Show favourite icon",
"settingsThumbnailShowLocationIcon": "Show location icon", "settingsThumbnailShowLocationIcon": "Show location icon",
"settingsThumbnailShowMotionPhotoIcon": "Show motion photo icon", "settingsThumbnailShowMotionPhotoIcon": "Show motion photo icon",
"settingsThumbnailShowRating": "Show rating",
"settingsThumbnailShowRawIcon": "Show raw icon", "settingsThumbnailShowRawIcon": "Show raw icon",
"settingsThumbnailShowVideoDuration": "Show video duration", "settingsThumbnailShowVideoDuration": "Show video duration",

540
lib/l10n/app_es.arb Normal file
View file

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

View file

@ -22,7 +22,7 @@
"nextTooltip": "Suivant", "nextTooltip": "Suivant",
"showTooltip": "Afficher", "showTooltip": "Afficher",
"hideTooltip": "Masquer", "hideTooltip": "Masquer",
"removeTooltip": "Supprimer", "actionRemove": "Supprimer",
"resetButtonTooltip": "Réinitialiser", "resetButtonTooltip": "Réinitialiser",
"doubleBackExitMessage": "Pressez «\u00A0retour\u00A0» à nouveau pour quitter.", "doubleBackExitMessage": "Pressez «\u00A0retour\u00A0» à nouveau pour quitter.",
@ -73,12 +73,15 @@
"videoActionSettings": "Préférences", "videoActionSettings": "Préférences",
"entryInfoActionEditDate": "Modifier la date", "entryInfoActionEditDate": "Modifier la date",
"entryInfoActionEditRating": "Modifier la notation",
"entryInfoActionEditTags": "Modifier les libellés", "entryInfoActionEditTags": "Modifier les libellés",
"entryInfoActionRemoveMetadata": "Retirer les métadonnées", "entryInfoActionRemoveMetadata": "Retirer les métadonnées",
"filterFavouriteLabel": "Favori", "filterFavouriteLabel": "Favori",
"filterLocationEmptyLabel": "Sans lieu", "filterLocationEmptyLabel": "Sans lieu",
"filterTagEmptyLabel": "Sans libellé", "filterTagEmptyLabel": "Sans libellé",
"filterRatingUnratedLabel": "Sans notation",
"filterRatingRejectedLabel": "Rejeté",
"filterTypeAnimatedLabel": "Animation", "filterTypeAnimatedLabel": "Animation",
"filterTypeMotionPhotoLabel": "Photo animée", "filterTypeMotionPhotoLabel": "Photo animée",
"filterTypePanoramaLabel": "Panorama", "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.", "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", "notEnoughSpaceDialogTitle": "Espace insuffisant",
"notEnoughSpaceDialogMessage": "Cette opération nécessite {neededSize} despace disponible sur «\u00A0{volume}\u00A0», mais il ne reste que {freeSize}.", "notEnoughSpaceDialogMessage": "Cette opération nécessite {neededSize} despace 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", "unsupportedTypeDialogTitle": "Formats non supportés",
"unsupportedTypeDialogMessage": "{count, plural, =1{Cette opération nest pas disponible pour les fichiers au format suivant : {types}.} other{Cette opération nest pas disponible pour les fichiers aux formats suivants : {types}.}}", "unsupportedTypeDialogMessage": "{count, plural, =1{Cette opération nest pas disponible pour les fichiers au format suivant : {types}.} other{Cette opération nest pas disponible pour les fichiers aux formats suivants : {types}.}}",
@ -178,14 +183,17 @@
"renameEntryDialogLabel": "Nouveau nom", "renameEntryDialogLabel": "Nouveau nom",
"editEntryDateDialogTitle": "Date & Heure", "editEntryDateDialogTitle": "Date & Heure",
"editEntryDateDialogSet": "Régler", "editEntryDateDialogSetCustom": "Régler une date personnalisée",
"editEntryDateDialogShift": "Décaler", "editEntryDateDialogCopyField": "Copier d'une autre date",
"editEntryDateDialogExtractFromTitle": "Extraire du titre", "editEntryDateDialogExtractFromTitle": "Extraire du titre",
"editEntryDateDialogClear": "Effacer", "editEntryDateDialogShift": "Décaler",
"editEntryDateDialogFieldSelection": "Champs affectés", "editEntryDateDialogSourceFileModifiedDate": "Date de modification du fichier",
"editEntryDateDialogTargetFieldsHeader": "Champs à modifier",
"editEntryDateDialogHours": "Heures", "editEntryDateDialogHours": "Heures",
"editEntryDateDialogMinutes": "Minutes", "editEntryDateDialogMinutes": "Minutes",
"editEntryRatingDialogTitle": "Notation",
"removeEntryMetadataDialogTitle": "Retrait de métadonnées", "removeEntryMetadataDialogTitle": "Retrait de métadonnées",
"removeEntryMetadataDialogMore": "Voir plus", "removeEntryMetadataDialogMore": "Voir plus",
@ -270,6 +278,7 @@
"collectionSortDate": "par date", "collectionSortDate": "par date",
"collectionSortSize": "par taille", "collectionSortSize": "par taille",
"collectionSortName": "alphabétique", "collectionSortName": "alphabétique",
"collectionSortRating": "par notation",
"collectionGroupAlbum": "par album", "collectionGroupAlbum": "par album",
"collectionGroupMonth": "par mois", "collectionGroupMonth": "par mois",
@ -343,6 +352,7 @@
"searchSectionCountries": "Pays", "searchSectionCountries": "Pays",
"searchSectionPlaces": "Lieux", "searchSectionPlaces": "Lieux",
"searchSectionTags": "Libellés", "searchSectionTags": "Libellés",
"searchSectionRating": "Notations",
"settingsPageTitle": "Réglages", "settingsPageTitle": "Réglages",
"settingsSystemDefault": "Système", "settingsSystemDefault": "Système",
@ -366,8 +376,10 @@
"settingsNavigationDrawerAddAlbum": "Ajouter un album", "settingsNavigationDrawerAddAlbum": "Ajouter un album",
"settingsSectionThumbnails": "Vignettes", "settingsSectionThumbnails": "Vignettes",
"settingsThumbnailShowFavouriteIcon": "Afficher licône de favori",
"settingsThumbnailShowLocationIcon": "Afficher licône de lieu", "settingsThumbnailShowLocationIcon": "Afficher licône de lieu",
"settingsThumbnailShowMotionPhotoIcon": "Afficher licône de photo animée", "settingsThumbnailShowMotionPhotoIcon": "Afficher licône de photo animée",
"settingsThumbnailShowRating": "Afficher la notation",
"settingsThumbnailShowRawIcon": "Afficher licône de photo raw", "settingsThumbnailShowRawIcon": "Afficher licône de photo raw",
"settingsThumbnailShowVideoDuration": "Afficher la durée de la vidéo", "settingsThumbnailShowVideoDuration": "Afficher la durée de la vidéo",

View file

@ -22,7 +22,7 @@
"nextTooltip": "다음", "nextTooltip": "다음",
"showTooltip": "보기", "showTooltip": "보기",
"hideTooltip": "숨기기", "hideTooltip": "숨기기",
"removeTooltip": "제거", "actionRemove": "제거",
"resetButtonTooltip": "복원", "resetButtonTooltip": "복원",
"doubleBackExitMessage": "종료하려면 한번 더 누르세요.", "doubleBackExitMessage": "종료하려면 한번 더 누르세요.",
@ -72,13 +72,16 @@
"videoActionSetSpeed": "재생 배속", "videoActionSetSpeed": "재생 배속",
"videoActionSettings": "설정", "videoActionSettings": "설정",
"entryInfoActionEditDate": "날짜와 시간 수정", "entryInfoActionEditDate": "날짜 및 시간 수정",
"entryInfoActionEditRating": "별점 수정",
"entryInfoActionEditTags": "태그 수정", "entryInfoActionEditTags": "태그 수정",
"entryInfoActionRemoveMetadata": "메타데이터 삭제", "entryInfoActionRemoveMetadata": "메타데이터 삭제",
"filterFavouriteLabel": "즐겨찾기", "filterFavouriteLabel": "즐겨찾기",
"filterLocationEmptyLabel": "장소 없음", "filterLocationEmptyLabel": "장소 없음",
"filterTagEmptyLabel": "태그 없음", "filterTagEmptyLabel": "태그 없음",
"filterRatingUnratedLabel": "별점 없음",
"filterRatingRejectedLabel": "거부됨",
"filterTypeAnimatedLabel": "애니메이션", "filterTypeAnimatedLabel": "애니메이션",
"filterTypeMotionPhotoLabel": "모션 포토", "filterTypeMotionPhotoLabel": "모션 포토",
"filterTypePanoramaLabel": "파노라마", "filterTypePanoramaLabel": "파노라마",
@ -137,6 +140,8 @@
"restrictedAccessDialogMessage": "“{volume}”의 {directory}에 있는 파일의 접근이 제한됩니다.\n\n기본으로 설치된 갤러리나 파일 관리 앱을 사용해서 다른 폴더로 파일을 이동하세요.", "restrictedAccessDialogMessage": "“{volume}”의 {directory}에 있는 파일의 접근이 제한됩니다.\n\n기본으로 설치된 갤러리나 파일 관리 앱을 사용해서 다른 폴더로 파일을 이동하세요.",
"notEnoughSpaceDialogTitle": "저장공간 부족", "notEnoughSpaceDialogTitle": "저장공간 부족",
"notEnoughSpaceDialogMessage": "“{volume}”에 필요 공간은 {neededSize}인데 사용 가능한 용량은 {freeSize}만 남아있습니다.", "notEnoughSpaceDialogMessage": "“{volume}”에 필요 공간은 {neededSize}인데 사용 가능한 용량은 {freeSize}만 남아있습니다.",
"missingSystemFilePickerDialogTitle": "기본 파일 선택기 없음",
"missingSystemFilePickerDialogMessage": "기본 파일 선택기가 없거나 비활성화딥니다. 파일 선택기를 켜고 다시 시도하세요.",
"unsupportedTypeDialogTitle": "미지원 형식", "unsupportedTypeDialogTitle": "미지원 형식",
"unsupportedTypeDialogMessage": "{count, plural, other{이 작업은 다음 항목의 형식을 지원하지 않습니다: {types}.}}", "unsupportedTypeDialogMessage": "{count, plural, other{이 작업은 다음 항목의 형식을 지원하지 않습니다: {types}.}}",
@ -178,14 +183,17 @@
"renameEntryDialogLabel": "이름", "renameEntryDialogLabel": "이름",
"editEntryDateDialogTitle": "날짜 및 시간", "editEntryDateDialogTitle": "날짜 및 시간",
"editEntryDateDialogSet": "편집", "editEntryDateDialogSetCustom": "지정 날짜로 편집",
"editEntryDateDialogShift": "시간 이동", "editEntryDateDialogCopyField": "다른 날짜에서 지정",
"editEntryDateDialogExtractFromTitle": "제목에서 추출", "editEntryDateDialogExtractFromTitle": "제목에서 추출",
"editEntryDateDialogClear": "삭제", "editEntryDateDialogShift": "시간 이동",
"editEntryDateDialogFieldSelection": "필드 선택", "editEntryDateDialogSourceFileModifiedDate": "파일 수정한 날짜",
"editEntryDateDialogTargetFieldsHeader": "수정할 필드",
"editEntryDateDialogHours": "시간", "editEntryDateDialogHours": "시간",
"editEntryDateDialogMinutes": "분", "editEntryDateDialogMinutes": "분",
"editEntryRatingDialogTitle": "별점",
"removeEntryMetadataDialogTitle": "메타데이터 삭제", "removeEntryMetadataDialogTitle": "메타데이터 삭제",
"removeEntryMetadataDialogMore": "더 보기", "removeEntryMetadataDialogMore": "더 보기",
@ -270,6 +278,7 @@
"collectionSortDate": "날짜", "collectionSortDate": "날짜",
"collectionSortSize": "크기", "collectionSortSize": "크기",
"collectionSortName": "이름", "collectionSortName": "이름",
"collectionSortRating": "별점",
"collectionGroupAlbum": "앨범별로", "collectionGroupAlbum": "앨범별로",
"collectionGroupMonth": "월별로", "collectionGroupMonth": "월별로",
@ -343,6 +352,7 @@
"searchSectionCountries": "국가", "searchSectionCountries": "국가",
"searchSectionPlaces": "장소", "searchSectionPlaces": "장소",
"searchSectionTags": "태그", "searchSectionTags": "태그",
"searchSectionRating": "별점",
"settingsPageTitle": "설정", "settingsPageTitle": "설정",
"settingsSystemDefault": "시스템", "settingsSystemDefault": "시스템",
@ -366,8 +376,10 @@
"settingsNavigationDrawerAddAlbum": "앨범 추가", "settingsNavigationDrawerAddAlbum": "앨범 추가",
"settingsSectionThumbnails": "섬네일", "settingsSectionThumbnails": "섬네일",
"settingsThumbnailShowFavouriteIcon": "즐겨찾기 아이콘 표시",
"settingsThumbnailShowLocationIcon": "위치 아이콘 표시", "settingsThumbnailShowLocationIcon": "위치 아이콘 표시",
"settingsThumbnailShowMotionPhotoIcon": "모션 포토 아이콘 표시", "settingsThumbnailShowMotionPhotoIcon": "모션 포토 아이콘 표시",
"settingsThumbnailShowRating": "별점 표시",
"settingsThumbnailShowRawIcon": "Raw 아이콘 표시", "settingsThumbnailShowRawIcon": "Raw 아이콘 표시",
"settingsThumbnailShowVideoDuration": "동영상 길이 표시", "settingsThumbnailShowVideoDuration": "동영상 길이 표시",

View file

@ -22,7 +22,7 @@
"nextTooltip": "Следующий", "nextTooltip": "Следующий",
"showTooltip": "Показать", "showTooltip": "Показать",
"hideTooltip": "Скрыть", "hideTooltip": "Скрыть",
"removeTooltip": "Удалить", "actionRemove": "Удалить",
"resetButtonTooltip": "Сбросить", "resetButtonTooltip": "Сбросить",
"doubleBackExitMessage": "Нажмите «назад» еще раз, чтобы выйти.", "doubleBackExitMessage": "Нажмите «назад» еще раз, чтобы выйти.",
@ -73,12 +73,15 @@
"videoActionSettings": "Настройки", "videoActionSettings": "Настройки",
"entryInfoActionEditDate": "Изменить дату и время", "entryInfoActionEditDate": "Изменить дату и время",
"entryInfoActionEditRating": "Изменить рейтинг",
"entryInfoActionEditTags": "Изменить теги", "entryInfoActionEditTags": "Изменить теги",
"entryInfoActionRemoveMetadata": "Удалить метаданные", "entryInfoActionRemoveMetadata": "Удалить метаданные",
"filterFavouriteLabel": "Избранное", "filterFavouriteLabel": "Избранное",
"filterLocationEmptyLabel": "Без местоположения", "filterLocationEmptyLabel": "Без местоположения",
"filterTagEmptyLabel": "Без тегов", "filterTagEmptyLabel": "Без тегов",
"filterRatingUnratedLabel": "Без рейтинга",
"filterRatingRejectedLabel": "Отклонённые",
"filterTypeAnimatedLabel": "GIF", "filterTypeAnimatedLabel": "GIF",
"filterTypeMotionPhotoLabel": "Живое фото", "filterTypeMotionPhotoLabel": "Живое фото",
"filterTypePanoramaLabel": "Панорама", "filterTypePanoramaLabel": "Панорама",
@ -137,6 +140,8 @@
"restrictedAccessDialogMessage": "Этому приложению не разрешается изменять файлы в каталоге {directory} накопителя «{volume}».\n\nПожалуйста, используйте предустановленный файловый менеджер или галерею, чтобы переместить элементы в другой каталог.", "restrictedAccessDialogMessage": "Этому приложению не разрешается изменять файлы в каталоге {directory} накопителя «{volume}».\n\nПожалуйста, используйте предустановленный файловый менеджер или галерею, чтобы переместить элементы в другой каталог.",
"notEnoughSpaceDialogTitle": "Недостаточно свободного места.", "notEnoughSpaceDialogTitle": "Недостаточно свободного места.",
"notEnoughSpaceDialogMessage": "Для завершения этой операции требуется {neededSize} свободного места на «{volume}», но осталось только {freeSize}.", "notEnoughSpaceDialogMessage": "Для завершения этой операции требуется {neededSize} свободного места на «{volume}», но осталось только {freeSize}.",
"missingSystemFilePickerDialogTitle": "Отсутствует системное приложение выбора файлов",
"missingSystemFilePickerDialogMessage": "Системное приложение выбора файлов отсутствует или отключено. Пожалуйста, включите его и повторите попытку.",
"unsupportedTypeDialogTitle": "Неподдерживаемые форматы", "unsupportedTypeDialogTitle": "Неподдерживаемые форматы",
"unsupportedTypeDialogMessage": "{count, plural, =1{Эта операция не поддерживается для объектов следующего формата: {types}.} other{Эта операция не поддерживается для объектов следующих форматов: {types}.}}", "unsupportedTypeDialogMessage": "{count, plural, =1{Эта операция не поддерживается для объектов следующего формата: {types}.} other{Эта операция не поддерживается для объектов следующих форматов: {types}.}}",
@ -178,14 +183,17 @@
"renameEntryDialogLabel": "Новое название", "renameEntryDialogLabel": "Новое название",
"editEntryDateDialogTitle": "Дата и время", "editEntryDateDialogTitle": "Дата и время",
"editEntryDateDialogSet": "Задать", "editEntryDateDialogSetCustom": "Задайте дату",
"editEntryDateDialogShift": "Сдвиг", "editEntryDateDialogCopyField": "Копировать с другой даты",
"editEntryDateDialogExtractFromTitle": "Извлечь из названия", "editEntryDateDialogExtractFromTitle": "Извлечь из названия",
"editEntryDateDialogClear": "Очистить", "editEntryDateDialogShift": "Сдвиг",
"editEntryDateDialogFieldSelection": "Выбор поля", "editEntryDateDialogSourceFileModifiedDate": "Дата изменения файла",
"editEntryDateDialogTargetFieldsHeader": "Поля для изменения",
"editEntryDateDialogHours": "Часов", "editEntryDateDialogHours": "Часов",
"editEntryDateDialogMinutes": "Минут", "editEntryDateDialogMinutes": "Минут",
"editEntryRatingDialogTitle": "Рейтинг",
"removeEntryMetadataDialogTitle": "Удаление метаданных", "removeEntryMetadataDialogTitle": "Удаление метаданных",
"removeEntryMetadataDialogMore": "Дополнительно", "removeEntryMetadataDialogMore": "Дополнительно",
@ -270,6 +278,7 @@
"collectionSortDate": "По дате", "collectionSortDate": "По дате",
"collectionSortSize": "По размеру", "collectionSortSize": "По размеру",
"collectionSortName": "По имени альбома и файла", "collectionSortName": "По имени альбома и файла",
"collectionSortRating": "По рейтингу",
"collectionGroupAlbum": "По альбому", "collectionGroupAlbum": "По альбому",
"collectionGroupMonth": "По месяцу", "collectionGroupMonth": "По месяцу",
@ -343,6 +352,7 @@
"searchSectionCountries": "Страны", "searchSectionCountries": "Страны",
"searchSectionPlaces": "Локации", "searchSectionPlaces": "Локации",
"searchSectionTags": "Теги", "searchSectionTags": "Теги",
"searchSectionRating": "Рейтинги",
"settingsPageTitle": "Настройки", "settingsPageTitle": "Настройки",
"settingsSystemDefault": "Система", "settingsSystemDefault": "Система",
@ -368,6 +378,7 @@
"settingsSectionThumbnails": "Эскизы", "settingsSectionThumbnails": "Эскизы",
"settingsThumbnailShowLocationIcon": "Показать значок местоположения", "settingsThumbnailShowLocationIcon": "Показать значок местоположения",
"settingsThumbnailShowMotionPhotoIcon": "Показать значок живого фото", "settingsThumbnailShowMotionPhotoIcon": "Показать значок живого фото",
"settingsThumbnailShowRating": "Показывать рейтинг",
"settingsThumbnailShowRawIcon": "Показать значок RAW-файла", "settingsThumbnailShowRawIcon": "Показать значок RAW-файла",
"settingsThumbnailShowVideoDuration": "Показывать продолжительность видео", "settingsThumbnailShowVideoDuration": "Показывать продолжительность видео",

View file

@ -5,6 +5,7 @@ import 'package:flutter/widgets.dart';
enum EntryInfoAction { enum EntryInfoAction {
// general // general
editDate, editDate,
editRating,
editTags, editTags,
removeMetadata, removeMetadata,
// motion photo // motion photo
@ -14,6 +15,7 @@ enum EntryInfoAction {
class EntryInfoActions { class EntryInfoActions {
static const all = [ static const all = [
EntryInfoAction.editDate, EntryInfoAction.editDate,
EntryInfoAction.editRating,
EntryInfoAction.editTags, EntryInfoAction.editTags,
EntryInfoAction.removeMetadata, EntryInfoAction.removeMetadata,
EntryInfoAction.viewMotionPhotoVideo, EntryInfoAction.viewMotionPhotoVideo,
@ -26,6 +28,8 @@ extension ExtraEntryInfoAction on EntryInfoAction {
// general // general
case EntryInfoAction.editDate: case EntryInfoAction.editDate:
return context.l10n.entryInfoActionEditDate; return context.l10n.entryInfoActionEditDate;
case EntryInfoAction.editRating:
return context.l10n.entryInfoActionEditRating;
case EntryInfoAction.editTags: case EntryInfoAction.editTags:
return context.l10n.entryInfoActionEditTags; return context.l10n.entryInfoActionEditTags;
case EntryInfoAction.removeMetadata: case EntryInfoAction.removeMetadata:
@ -45,8 +49,10 @@ extension ExtraEntryInfoAction on EntryInfoAction {
// general // general
case EntryInfoAction.editDate: case EntryInfoAction.editDate:
return AIcons.date; return AIcons.date;
case EntryInfoAction.editRating:
return AIcons.editRating;
case EntryInfoAction.editTags: case EntryInfoAction.editTags:
return AIcons.addTag; return AIcons.editTags;
case EntryInfoAction.removeMetadata: case EntryInfoAction.removeMetadata:
return AIcons.clear; return AIcons.clear;
// motion photo // motion photo

View file

@ -21,10 +21,12 @@ enum EntrySetAction {
copy, copy,
move, move,
rescan, rescan,
toggleFavourite,
rotateCCW, rotateCCW,
rotateCW, rotateCW,
flip, flip,
editDate, editDate,
editRating,
editTags, editTags,
removeMetadata, removeMetadata,
} }
@ -50,6 +52,7 @@ class EntrySetActions {
EntrySetAction.delete, EntrySetAction.delete,
EntrySetAction.copy, EntrySetAction.copy,
EntrySetAction.move, EntrySetAction.move,
EntrySetAction.toggleFavourite,
EntrySetAction.rescan, EntrySetAction.rescan,
EntrySetAction.map, EntrySetAction.map,
EntrySetAction.stats, EntrySetAction.stats,
@ -93,6 +96,9 @@ extension ExtraEntrySetAction on EntrySetAction {
return context.l10n.collectionActionMove; return context.l10n.collectionActionMove;
case EntrySetAction.rescan: case EntrySetAction.rescan:
return context.l10n.collectionActionRescan; return context.l10n.collectionActionRescan;
case EntrySetAction.toggleFavourite:
// different data depending on toggle state
return context.l10n.entryActionAddFavourite;
case EntrySetAction.rotateCCW: case EntrySetAction.rotateCCW:
return context.l10n.entryActionRotateCCW; return context.l10n.entryActionRotateCCW;
case EntrySetAction.rotateCW: case EntrySetAction.rotateCW:
@ -101,6 +107,8 @@ extension ExtraEntrySetAction on EntrySetAction {
return context.l10n.entryActionFlip; return context.l10n.entryActionFlip;
case EntrySetAction.editDate: case EntrySetAction.editDate:
return context.l10n.entryInfoActionEditDate; return context.l10n.entryInfoActionEditDate;
case EntrySetAction.editRating:
return context.l10n.entryInfoActionEditRating;
case EntrySetAction.editTags: case EntrySetAction.editTags:
return context.l10n.entryInfoActionEditTags; return context.l10n.entryInfoActionEditTags;
case EntrySetAction.removeMetadata: case EntrySetAction.removeMetadata:
@ -147,6 +155,9 @@ extension ExtraEntrySetAction on EntrySetAction {
return AIcons.move; return AIcons.move;
case EntrySetAction.rescan: case EntrySetAction.rescan:
return AIcons.refresh; return AIcons.refresh;
case EntrySetAction.toggleFavourite:
// different data depending on toggle state
return AIcons.favourite;
case EntrySetAction.rotateCCW: case EntrySetAction.rotateCCW:
return AIcons.rotateLeft; return AIcons.rotateLeft;
case EntrySetAction.rotateCW: case EntrySetAction.rotateCW:
@ -155,8 +166,10 @@ extension ExtraEntrySetAction on EntrySetAction {
return AIcons.flip; return AIcons.flip;
case EntrySetAction.editDate: case EntrySetAction.editDate:
return AIcons.date; return AIcons.date;
case EntrySetAction.editRating:
return AIcons.editRating;
case EntrySetAction.editTags: case EntrySetAction.editTags:
return AIcons.addTag; return AIcons.editTags;
case EntrySetAction.removeMetadata: case EntrySetAction.removeMetadata:
return AIcons.clear; return AIcons.clear;
} }

View file

@ -1,9 +1,13 @@
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@immutable @immutable
class ActionEvent<T> { class ActionEvent<T> extends Equatable {
final T action; final T action;
@override
List<Object?> get props => [action];
const ActionEvent(this.action); const ActionEvent(this.action);
} }

View file

@ -6,7 +6,7 @@ final Device device = Device._private();
class Device { class Device {
late final String _userAgent; late final String _userAgent;
late final bool _canGrantDirectoryAccess, _canPinShortcut, _canPrint, _canRenderFlagEmojis, _canRenderGoogleMaps; late final bool _canGrantDirectoryAccess, _canPinShortcut, _canPrint, _canRenderFlagEmojis, _canRenderGoogleMaps;
late final bool _hasFilePicker, _showPinShortcutFeedback, _supportEdgeToEdgeUIMode; late final bool _showPinShortcutFeedback, _supportEdgeToEdgeUIMode;
String get userAgent => _userAgent; String get userAgent => _userAgent;
@ -20,8 +20,6 @@ class Device {
bool get canRenderGoogleMaps => _canRenderGoogleMaps; bool get canRenderGoogleMaps => _canRenderGoogleMaps;
bool get hasFilePicker => _hasFilePicker;
bool get showPinShortcutFeedback => _showPinShortcutFeedback; bool get showPinShortcutFeedback => _showPinShortcutFeedback;
bool get supportEdgeToEdgeUIMode => _supportEdgeToEdgeUIMode; bool get supportEdgeToEdgeUIMode => _supportEdgeToEdgeUIMode;
@ -38,7 +36,6 @@ class Device {
_canPrint = capabilities['canPrint'] ?? false; _canPrint = capabilities['canPrint'] ?? false;
_canRenderFlagEmojis = capabilities['canRenderFlagEmojis'] ?? false; _canRenderFlagEmojis = capabilities['canRenderFlagEmojis'] ?? false;
_canRenderGoogleMaps = capabilities['canRenderGoogleMaps'] ?? false; _canRenderGoogleMaps = capabilities['canRenderGoogleMaps'] ?? false;
_hasFilePicker = capabilities['hasFilePicker'] ?? false;
_showPinShortcutFeedback = capabilities['showPinShortcutFeedback'] ?? false; _showPinShortcutFeedback = capabilities['showPinShortcutFeedback'] ?? false;
_supportEdgeToEdgeUIMode = capabilities['supportEdgeToEdgeUIMode'] ?? false; _supportEdgeToEdgeUIMode = capabilities['supportEdgeToEdgeUIMode'] ?? false;
} }

View file

@ -7,8 +7,6 @@ import 'package:aves/model/entry_cache.dart';
import 'package:aves/model/favourites.dart'; import 'package:aves/model/favourites.dart';
import 'package:aves/model/metadata/address.dart'; import 'package:aves/model/metadata/address.dart';
import 'package:aves/model/metadata/catalog.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/multipage.dart';
import 'package:aves/model/video/metadata.dart'; import 'package:aves/model/video/metadata.dart';
import 'package:aves/ref/mime_types.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/services/metadata/svg_metadata_service.dart';
import 'package:aves/theme/format.dart'; import 'package:aves/theme/format.dart';
import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/change_notifier.dart';
import 'package:aves/utils/time_utils.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:country_code/country_code.dart'; import 'package:country_code/country_code.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -237,7 +234,9 @@ class AvesEntry {
bool get canEdit => path != null; 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; bool get canEditTags => canEdit && canEditXmp;
@ -361,6 +360,8 @@ class AvesEntry {
return _bestDate; return _bestDate;
} }
int get rating => _catalogMetadata?.rating ?? 0;
int get rotationDegrees => _catalogMetadata?.rotationDegrees ?? sourceRotationDegrees; int get rotationDegrees => _catalogMetadata?.rotationDegrees ?? sourceRotationDegrees;
set rotationDegrees(int rotationDegrees) { set rotationDegrees(int rotationDegrees) {
@ -448,6 +449,7 @@ class AvesEntry {
CatalogMetadata? get catalogMetadata => _catalogMetadata; CatalogMetadata? get catalogMetadata => _catalogMetadata;
set catalogMetadata(CatalogMetadata? newMetadata) { set catalogMetadata(CatalogMetadata? newMetadata) {
final oldMimeType = mimeType;
final oldDateModifiedSecs = dateModifiedSecs; final oldDateModifiedSecs = dateModifiedSecs;
final oldRotationDegrees = rotationDegrees; final oldRotationDegrees = rotationDegrees;
final oldIsFlipped = isFlipped; final oldIsFlipped = isFlipped;
@ -458,7 +460,7 @@ class AvesEntry {
_tags = null; _tags = null;
metadataChangeNotifier.notify(); metadataChangeNotifier.notify();
_onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); _onVisualFieldChanged(oldMimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
} }
void clearMetadata() { void clearMetadata() {
@ -477,14 +479,14 @@ class AvesEntry {
'width': size.width.ceil(), 'width': size.width.ceil(),
'height': size.height.ceil(), 'height': size.height.ceil(),
}; };
await _applyNewFields(fields, persist: persist); await applyNewFields(fields, persist: persist);
} }
catalogMetadata = CatalogMetadata(contentId: contentId); catalogMetadata = CatalogMetadata(contentId: contentId);
} else { } else {
if (isVideo && (!isSized || durationMillis == 0)) { if (isVideo && (!isSized || durationMillis == 0)) {
// exotic video that is not sized during loading // exotic video that is not sized during loading
final fields = await VideoMetadataFormatter.getLoadingMetadata(this); final fields = await VideoMetadataFormatter.getLoadingMetadata(this);
await _applyNewFields(fields, persist: persist); await applyNewFields(fields, persist: persist);
} }
catalogMetadata = await metadataFetchService.getCatalogMetadata(this, background: background); catalogMetadata = await metadataFetchService.getCatalogMetadata(this, background: background);
@ -581,7 +583,8 @@ class AvesEntry {
}.whereNotNull().where((v) => v.isNotEmpty).join(', '); }.whereNotNull().where((v) => v.isNotEmpty).join(', ');
} }
Future<void> _applyNewFields(Map newFields, {required bool persist}) async { Future<void> applyNewFields(Map newFields, {required bool persist}) async {
final oldMimeType = mimeType;
final oldDateModifiedSecs = this.dateModifiedSecs; final oldDateModifiedSecs = this.dateModifiedSecs;
final oldRotationDegrees = this.rotationDegrees; final oldRotationDegrees = this.rotationDegrees;
final oldIsFlipped = this.isFlipped; final oldIsFlipped = this.isFlipped;
@ -621,7 +624,7 @@ class AvesEntry {
if (catalogMetadata != null) await metadataDb.saveMetadata({catalogMetadata!}); if (catalogMetadata != null) await metadataDb.saveMetadata({catalogMetadata!});
} }
await _onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); await _onVisualFieldChanged(oldMimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
metadataChangeNotifier.notify(); metadataChangeNotifier.notify();
} }
@ -642,65 +645,12 @@ class AvesEntry {
final updatedEntry = await mediaFileService.getEntry(uri, mimeType); final updatedEntry = await mediaFileService.getEntry(uri, mimeType);
if (updatedEntry != null) { 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 catalog(background: background, force: dataTypes.contains(EntryDataType.catalog), persist: persist);
await locate(background: background, force: dataTypes.contains(EntryDataType.address), geocoderLocale: geocoderLocale); await locate(background: background, force: dataTypes.contains(EntryDataType.address), geocoderLocale: geocoderLocale);
} }
Future<Set<EntryDataType>> 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<Set<EntryDataType>> 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<Set<EntryDataType>> 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<Set<EntryDataType>> removeMetadata(Set<MetadataType> types) async {
final newFields = await metadataEditService.removeTypes(this, types);
return newFields.isEmpty
? {}
: {
EntryDataType.basic,
EntryDataType.catalog,
EntryDataType.address,
};
}
Future<bool> delete() { Future<bool> delete() {
final completer = Completer<bool>(); final completer = Completer<bool>();
mediaFileService.delete(entries: {this}).listen( mediaFileService.delete(entries: {this}).listen(
@ -715,10 +665,10 @@ class AvesEntry {
return completer.future; return completer.future;
} }
// when the entry image itself changed (e.g. after rotation) // when the MIME type or the image itself changed (e.g. after rotation)
Future<void> _onVisualFieldChanged(int? oldDateModifiedSecs, int oldRotationDegrees, bool oldIsFlipped) async { Future<void> _onVisualFieldChanged(String oldMimeType, int? oldDateModifiedSecs, int oldRotationDegrees, bool oldIsFlipped) async {
if (oldDateModifiedSecs != dateModifiedSecs || oldRotationDegrees != rotationDegrees || oldIsFlipped != isFlipped) { if ((!MimeTypes.refersToSameType(oldMimeType, mimeType) && !MimeTypes.isVideo(oldMimeType)) || oldDateModifiedSecs != dateModifiedSecs || oldRotationDegrees != rotationDegrees || oldIsFlipped != isFlipped) {
await EntryCache.evict(uri, mimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); await EntryCache.evict(uri, oldMimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
imageChangeNotifier.notify(); imageChangeNotifier.notify();
} }
} }
@ -735,13 +685,13 @@ class AvesEntry {
Future<void> addToFavourites() async { Future<void> addToFavourites() async {
if (!isFavourite) { if (!isFavourite) {
await favourites.add([this]); await favourites.add({this});
} }
} }
Future<void> removeFromFavourites() async { Future<void> removeFromFavourites() async {
if (isFavourite) { 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 ?? ''); 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); static final _epoch = DateTime.fromMillisecondsSinceEpoch(0);
// compare by: // compare by:
@ -816,4 +758,20 @@ class AvesEntry {
if (c != 0) return c; if (c != 0) return c;
return compareByName(b, a); 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);
}
} }

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'package:aves/image_providers/thumbnail_provider.dart'; import 'package:aves/image_providers/thumbnail_provider.dart';
import 'package:aves/image_providers/uri_image_provider.dart'; import 'package:aves/image_providers/uri_image_provider.dart';
import 'package:flutter/foundation.dart';
class EntryCache { class EntryCache {
// ordered descending // ordered descending
@ -19,9 +20,11 @@ class EntryCache {
String uri, String uri,
String mimeType, String mimeType,
int? dateModifiedSecs, int? dateModifiedSecs,
int oldRotationDegrees, int rotationDegrees,
bool oldIsFlipped, bool isFlipped,
) async { ) 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 // TODO TLAD provide pageId parameter for multi page items, if someday image editing features are added for them
int? pageId; int? pageId;
@ -30,8 +33,8 @@ class EntryCache {
uri: uri, uri: uri,
mimeType: mimeType, mimeType: mimeType,
pageId: pageId, pageId: pageId,
rotationDegrees: oldRotationDegrees, rotationDegrees: rotationDegrees,
isFlipped: oldIsFlipped, isFlipped: isFlipped,
).evict(); ).evict();
// evict low quality thumbnail (without specified extents) // evict low quality thumbnail (without specified extents)
@ -40,8 +43,8 @@ class EntryCache {
mimeType: mimeType, mimeType: mimeType,
pageId: pageId, pageId: pageId,
dateModifiedSecs: dateModifiedSecs ?? 0, dateModifiedSecs: dateModifiedSecs ?? 0,
rotationDegrees: oldRotationDegrees, rotationDegrees: rotationDegrees,
isFlipped: oldIsFlipped, isFlipped: isFlipped,
)).evict(); )).evict();
await Future.forEach<double>( await Future.forEach<double>(
@ -51,8 +54,8 @@ class EntryCache {
mimeType: mimeType, mimeType: mimeType,
pageId: pageId, pageId: pageId,
dateModifiedSecs: dateModifiedSecs ?? 0, dateModifiedSecs: dateModifiedSecs ?? 0,
rotationDegrees: oldRotationDegrees, rotationDegrees: rotationDegrees,
isFlipped: oldIsFlipped, isFlipped: isFlipped,
extent: extent, extent: extent,
)).evict()); )).evict());
} }

View file

@ -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<Set<EntryDataType>> editDate(DateModifier userModifier) async {
final Set<EntryDataType> 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<Set<EntryDataType>> _changeOrientation(Future<Map<String, dynamic>> Function() apply) async {
final Set<EntryDataType> 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<Set<EntryDataType>> rotate({required bool clockwise}) {
return _changeOrientation(() => metadataEditService.rotate(this, clockwise: clockwise));
}
Future<Set<EntryDataType>> flip() {
return _changeOrientation(() => metadataEditService.flip(this));
}
// write:
// - IPTC / keywords, if IPTC exists
// - XMP / dc:subject
Future<Set<EntryDataType>> editTags(Set<String> tags) async {
final Set<EntryDataType> dataTypes = {};
final Map<MetadataType, dynamic> 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<Set<EntryDataType>> editRating(int? rating) async {
final Set<EntryDataType> dataTypes = {};
final Map<MetadataType, dynamic> 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<Set<EntryDataType>> removeMetadata(Set<MetadataType> types) async {
final newFields = await metadataEditService.removeTypes(this, types);
return newFields.isEmpty
? {}
: {
EntryDataType.basic,
EntryDataType.catalog,
EntryDataType.address,
};
}
@visibleForTesting
static void editCreateDateXmp(List<XmlNode> 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<Map<String, dynamic>> iptc, Set<String> 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<XmlNode> descriptions, Set<String> tags) {
XMP.setStringBag(
descriptions,
XMP.dcSubject,
tags,
namespace: Namespaces.dc,
strat: XmpEditStrategy.always,
);
}
@visibleForTesting
static void editRatingXmp(List<XmlNode> 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<DateTime?> _missingDateCheckAndExifEdit(Set<EntryDataType> 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<DateModifier?> _applyDateModifierToEntry(DateModifier modifier) async {
Set<MetadataField> 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<Map<String, String?>> _editXmp(void Function(List<XmlNode> 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,
};
}
}

View file

@ -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<Set<EntryDataType>> editTags(Set<String> 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<void> _setIptcKeywords(List<Map<String, dynamic>> iptc, Set<String> 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<String, String> namespaces) => namespaces.forEach((uri, prefix) => node.setAttribute('$xmlnsPrefix:$prefix', uri));
void _setStringBag(XmlNode node, String name, Set<String> 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<Object?> get props => [xmpString, extendedXmpString];
const AvesXmp({
required this.xmpString,
this.extendedXmpString,
});
static AvesXmp? fromList(List<String> xmpStrings) {
switch (xmpStrings.length) {
case 0:
return null;
case 1:
return AvesXmp(xmpString: xmpStrings.single);
default:
final byExtending = groupBy<String, bool>(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);
}
}
}

View file

@ -21,7 +21,7 @@ class Favourites with ChangeNotifier {
FavouriteRow _entryToRow(AvesEntry entry) => FavouriteRow(contentId: entry.contentId!, path: entry.path!); FavouriteRow _entryToRow(AvesEntry entry) => FavouriteRow(contentId: entry.contentId!, path: entry.path!);
Future<void> add(Iterable<AvesEntry> entries) async { Future<void> add(Set<AvesEntry> entries) async {
final newRows = entries.map(_entryToRow); final newRows = entries.map(_entryToRow);
await metadataDb.addFavourites(newRows); await metadataDb.addFavourites(newRows);
@ -30,7 +30,7 @@ class Favourites with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> remove(Iterable<AvesEntry> entries) async { Future<void> remove(Set<AvesEntry> entries) async {
final contentIds = entries.map((entry) => entry.contentId).toSet(); final contentIds = entries.map((entry) => entry.contentId).toSet();
final removedRows = _rows.where((row) => contentIds.contains(row.contentId)).toSet(); final removedRows = _rows.where((row) => contentIds.contains(row.contentId)).toSet();

View file

@ -8,6 +8,7 @@ import 'package:aves/model/filters/location.dart';
import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/filters/path.dart'; import 'package:aves/model/filters/path.dart';
import 'package:aves/model/filters/query.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/tag.dart';
import 'package:aves/model/filters/type.dart'; import 'package:aves/model/filters/type.dart';
import 'package:aves/utils/color_utils.dart'; import 'package:aves/utils/color_utils.dart';
@ -26,6 +27,7 @@ abstract class CollectionFilter extends Equatable implements Comparable<Collecti
AlbumFilter.type, AlbumFilter.type,
LocationFilter.type, LocationFilter.type,
CoordinateFilter.type, CoordinateFilter.type,
RatingFilter.type,
TagFilter.type, TagFilter.type,
PathFilter.type, PathFilter.type,
]; ];
@ -52,6 +54,8 @@ abstract class CollectionFilter extends Equatable implements Comparable<Collecti
return PathFilter.fromMap(jsonMap); return PathFilter.fromMap(jsonMap);
case QueryFilter.type: case QueryFilter.type:
return QueryFilter.fromMap(jsonMap); return QueryFilter.fromMap(jsonMap);
case RatingFilter.type:
return RatingFilter.fromMap(jsonMap);
case TagFilter.type: case TagFilter.type:
return TagFilter.fromMap(jsonMap); return TagFilter.fromMap(jsonMap);
case TypeFilter.type: case TypeFilter.type:

View file

@ -69,7 +69,7 @@ class LocationFilter extends CollectionFilter {
); );
} }
} }
return Icon(_location.isEmpty ? AIcons.locationOff : AIcons.location, size: size); return Icon(_location.isEmpty ? AIcons.locationUnlocated : AIcons.location, size: size);
} }
@override @override

View file

@ -0,0 +1,64 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart';
class RatingFilter extends CollectionFilter {
static const type = 'rating';
final int rating;
@override
List<Object?> get props => [rating];
const RatingFilter(this.rating);
RatingFilter.fromMap(Map<String, dynamic> json)
: this(
json['rating'] ?? 0,
);
@override
Map<String, dynamic> 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;
}
}
}

View file

@ -44,7 +44,7 @@ class TagFilter extends CollectionFilter {
String getLabel(BuildContext context) => tag.isEmpty ? context.l10n.filterTagEmptyLabel : tag; String getLabel(BuildContext context) => tag.isEmpty ? context.l10n.filterTagEmptyLabel : tag;
@override @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 @override
String get category => type; String get category => type;

View file

@ -9,6 +9,7 @@ class CatalogMetadata {
final String? mimeType, xmpSubjects, xmpTitleDescription; final String? mimeType, xmpSubjects, xmpTitleDescription;
double? latitude, longitude; double? latitude, longitude;
Address? address; Address? address;
int rating;
static const double _precisionErrorTolerance = 1e-9; static const double _precisionErrorTolerance = 1e-9;
static const _isAnimatedMask = 1 << 0; static const _isAnimatedMask = 1 << 0;
@ -31,6 +32,7 @@ class CatalogMetadata {
this.xmpTitleDescription, this.xmpTitleDescription,
double? latitude, double? latitude,
double? longitude, double? longitude,
this.rating = 0,
}) { }) {
// Geocoder throws an `IllegalArgumentException` when a coordinate has a funky value like `1.7056881853375E7` // 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}), // 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, xmpTitleDescription: xmpTitleDescription,
latitude: latitude, latitude: latitude,
longitude: longitude, longitude: longitude,
rating: rating,
); );
} }
@ -87,6 +90,7 @@ class CatalogMetadata {
xmpTitleDescription: map['xmpTitleDescription'] ?? '', xmpTitleDescription: map['xmpTitleDescription'] ?? '',
latitude: map['latitude'], latitude: map['latitude'],
longitude: map['longitude'], longitude: map['longitude'],
rating: map['rating'] ?? 0,
); );
} }
@ -100,8 +104,9 @@ class CatalogMetadata {
'xmpTitleDescription': xmpTitleDescription, 'xmpTitleDescription': xmpTitleDescription,
'latitude': latitude, 'latitude': latitude,
'longitude': longitude, 'longitude': longitude,
'rating': rating,
}; };
@override @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}';
} }

View file

@ -4,17 +4,45 @@ import 'package:flutter/widgets.dart';
@immutable @immutable
class DateModifier { class DateModifier {
static const allDateFields = [ static const writableDateFields = [
MetadataField.exifDate, MetadataField.exifDate,
MetadataField.exifDateOriginal, MetadataField.exifDateOriginal,
MetadataField.exifDateDigitized, MetadataField.exifDateDigitized,
MetadataField.exifGpsDate, MetadataField.exifGpsDate,
MetadataField.xmpCreateDate,
]; ];
final DateEditAction action; final DateEditAction action;
final Set<MetadataField> fields; final Set<MetadataField> fields;
final DateTime? dateTime; final DateTime? setDateTime;
final DateFieldSource? copyFieldSource;
final int? shiftMinutes; 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<MetadataField> fields, DateTime dateTime) {
return DateModifier._private(DateEditAction.setCustom, fields, setDateTime: dateTime);
}
factory DateModifier.copyField(Set<MetadataField> fields, DateFieldSource copyFieldSource) {
return DateModifier._private(DateEditAction.copyField, fields, copyFieldSource: copyFieldSource);
}
factory DateModifier.extractFromTitle(Set<MetadataField> fields) {
return DateModifier._private(DateEditAction.extractFromTitle, fields);
}
factory DateModifier.shift(Set<MetadataField> fields, int shiftMinutes) {
return DateModifier._private(DateEditAction.shift, fields, shiftMinutes: shiftMinutes);
}
factory DateModifier.remove(Set<MetadataField> fields) {
return DateModifier._private(DateEditAction.remove, fields);
}
} }

View file

@ -3,13 +3,23 @@ enum MetadataField {
exifDateOriginal, exifDateOriginal,
exifDateDigitized, exifDateDigitized,
exifGpsDate, exifGpsDate,
xmpCreateDate,
} }
enum DateEditAction { enum DateEditAction {
set, setCustom,
shift, copyField,
extractFromTitle, extractFromTitle,
clear, shift,
remove,
}
enum DateFieldSource {
fileModifiedDate,
exifDate,
exifDateOriginal,
exifDateDigitized,
exifGpsDate,
} }
enum MetadataType { enum MetadataType {
@ -56,7 +66,7 @@ class MetadataTypes {
} }
extension ExtraMetadataType on MetadataType { extension ExtraMetadataType on MetadataType {
// match `ExifInterface` directory names // match `metadata-extractor` directory names
String getText() { String getText() {
switch (this) { switch (this) {
case MetadataType.comment: 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;
}
}
}

View file

@ -145,6 +145,7 @@ class SqfliteMetadataDb implements MetadataDb {
', xmpTitleDescription TEXT' ', xmpTitleDescription TEXT'
', latitude REAL' ', latitude REAL'
', longitude REAL' ', longitude REAL'
', rating INTEGER'
')'); ')');
await db.execute('CREATE TABLE $addressTable(' await db.execute('CREATE TABLE $addressTable('
'contentId INTEGER PRIMARY KEY' 'contentId INTEGER PRIMARY KEY'
@ -168,7 +169,7 @@ class SqfliteMetadataDb implements MetadataDb {
')'); ')');
}, },
onUpgrade: MetadataDbUpgrader.upgradeDb, onUpgrade: MetadataDbUpgrader.upgradeDb,
version: 5, version: 6,
); );
} }

View file

@ -25,6 +25,9 @@ class MetadataDbUpgrader {
case 4: case 4:
await _upgradeFrom4(db); await _upgradeFrom4(db);
break; break;
case 5:
await _upgradeFrom5(db);
break;
} }
oldVersion++; oldVersion++;
} }
@ -121,4 +124,9 @@ class MetadataDbUpgrader {
', resumeTimeMillis INTEGER' ', resumeTimeMillis INTEGER'
')'); ')');
} }
static Future<void> _upgradeFrom5(Database db) async {
debugPrint('upgrading DB from v5');
await db.execute('ALTER TABLE $metadataTable ADD COLUMN rating INTEGER;');
}
} }

View file

@ -43,8 +43,10 @@ class SettingsDefaults {
EntrySetAction.share, EntrySetAction.share,
EntrySetAction.delete, EntrySetAction.delete,
]; ];
static const showThumbnailFavourite = true;
static const showThumbnailLocation = true; static const showThumbnailLocation = true;
static const showThumbnailMotionPhoto = true; static const showThumbnailMotionPhoto = true;
static const showThumbnailRating = true;
static const showThumbnailRaw = true; static const showThumbnailRaw = true;
static const showThumbnailVideoDuration = true; static const showThumbnailVideoDuration = true;

View file

@ -61,8 +61,10 @@ class Settings extends ChangeNotifier {
static const collectionSortFactorKey = 'collection_sort_factor'; static const collectionSortFactorKey = 'collection_sort_factor';
static const collectionBrowsingQuickActionsKey = 'collection_browsing_quick_actions'; static const collectionBrowsingQuickActionsKey = 'collection_browsing_quick_actions';
static const collectionSelectionQuickActionsKey = 'collection_selection_quick_actions'; static const collectionSelectionQuickActionsKey = 'collection_selection_quick_actions';
static const showThumbnailFavouriteKey = 'show_thumbnail_favourite';
static const showThumbnailLocationKey = 'show_thumbnail_location'; static const showThumbnailLocationKey = 'show_thumbnail_location';
static const showThumbnailMotionPhotoKey = 'show_thumbnail_motion_photo'; static const showThumbnailMotionPhotoKey = 'show_thumbnail_motion_photo';
static const showThumbnailRatingKey = 'show_thumbnail_rating';
static const showThumbnailRawKey = 'show_thumbnail_raw'; static const showThumbnailRawKey = 'show_thumbnail_raw';
static const showThumbnailVideoDurationKey = 'show_thumbnail_video_duration'; static const showThumbnailVideoDurationKey = 'show_thumbnail_video_duration';
@ -302,6 +304,10 @@ class Settings extends ChangeNotifier {
set collectionSelectionQuickActions(List<EntrySetAction> newValue) => setAndNotify(collectionSelectionQuickActionsKey, newValue.map((v) => v.toString()).toList()); set collectionSelectionQuickActions(List<EntrySetAction> 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); bool get showThumbnailLocation => getBoolOrDefault(showThumbnailLocationKey, SettingsDefaults.showThumbnailLocation);
set showThumbnailLocation(bool newValue) => setAndNotify(showThumbnailLocationKey, newValue); set showThumbnailLocation(bool newValue) => setAndNotify(showThumbnailLocationKey, newValue);
@ -310,6 +316,10 @@ class Settings extends ChangeNotifier {
set showThumbnailMotionPhoto(bool newValue) => setAndNotify(showThumbnailMotionPhotoKey, newValue); 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); bool get showThumbnailRaw => getBoolOrDefault(showThumbnailRawKey, SettingsDefaults.showThumbnailRaw);
set showThumbnailRaw(bool newValue) => setAndNotify(showThumbnailRawKey, newValue); set showThumbnailRaw(bool newValue) => setAndNotify(showThumbnailRawKey, newValue);
@ -617,8 +627,10 @@ class Settings extends ChangeNotifier {
case isInstalledAppAccessAllowedKey: case isInstalledAppAccessAllowedKey:
case isErrorReportingAllowedKey: case isErrorReportingAllowedKey:
case mustBackTwiceToExitKey: case mustBackTwiceToExitKey:
case showThumbnailFavouriteKey:
case showThumbnailLocationKey: case showThumbnailLocationKey:
case showThumbnailMotionPhotoKey: case showThumbnailMotionPhotoKey:
case showThumbnailRatingKey:
case showThumbnailRawKey: case showThumbnailRawKey:
case showThumbnailVideoDurationKey: case showThumbnailVideoDurationKey:
case showOverlayOnOpeningKey: case showOverlayOnOpeningKey:

View file

@ -9,6 +9,7 @@ import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/location.dart';
import 'package:aves/model/filters/query.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/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/events.dart'; import 'package:aves/model/source/events.dart';
@ -108,15 +109,27 @@ class CollectionLens with ChangeNotifier {
} }
bool get showHeaders { 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;
switch (sortFactor) {
case EntrySortFactor.date:
switch (sectionFactor) {
case EntryGroupFactor.none:
return false;
case EntryGroupFactor.album:
return showAlbumHeaders();
case EntryGroupFactor.month:
return true; 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) { void addFilter(CollectionFilter filter) {
@ -181,12 +194,15 @@ class CollectionLens with ChangeNotifier {
case EntrySortFactor.date: case EntrySortFactor.date:
_filteredSortedEntries.sort(AvesEntry.compareByDate); _filteredSortedEntries.sort(AvesEntry.compareByDate);
break; break;
case EntrySortFactor.size:
_filteredSortedEntries.sort(AvesEntry.compareBySize);
break;
case EntrySortFactor.name: case EntrySortFactor.name:
_filteredSortedEntries.sort(AvesEntry.compareByName); _filteredSortedEntries.sort(AvesEntry.compareByName);
break; 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;
} }
break; break;
case EntrySortFactor.name:
final byAlbum = groupBy<AvesEntry, EntryAlbumSectionKey>(_filteredSortedEntries, (entry) => EntryAlbumSectionKey(entry.directory));
sections = SplayTreeMap<EntryAlbumSectionKey, List<AvesEntry>>.of(byAlbum, (a, b) => source.compareAlbumsByName(a.directory!, b.directory!));
break;
case EntrySortFactor.rating:
sections = groupBy<AvesEntry, EntryRatingSectionKey>(_filteredSortedEntries, (entry) => EntryRatingSectionKey(entry.rating));
break;
case EntrySortFactor.size: case EntrySortFactor.size:
sections = Map.fromEntries([ sections = Map.fromEntries([
MapEntry(const SectionKey(), _filteredSortedEntries), MapEntry(const SectionKey(), _filteredSortedEntries),
]); ]);
break; break;
case EntrySortFactor.name:
final byAlbum = groupBy<AvesEntry, EntryAlbumSectionKey>(_filteredSortedEntries, (entry) => EntryAlbumSectionKey(entry.directory));
sections = SplayTreeMap<EntryAlbumSectionKey, List<AvesEntry>>.of(byAlbum, (a, b) => source.compareAlbumsByName(a.directory!, b.directory!));
break;
} }
sections = Map.unmodifiable(sections); sections = Map.unmodifiable(sections);
_sortedEntries = null; _sortedEntries = null;

View file

@ -4,7 +4,7 @@ enum ChipSortFactor { date, name, count }
enum AlbumChipGroupFactor { none, importance, volume } enum AlbumChipGroupFactor { none, importance, volume }
enum EntrySortFactor { date, size, name } enum EntrySortFactor { date, name, rating, size }
enum EntryGroupFactor { none, album, month, day } enum EntryGroupFactor { none, album, month, day }

View file

@ -23,3 +23,12 @@ class EntryDateSectionKey extends SectionKey with EquatableMixin {
const EntryDateSectionKey(this.date); const EntryDateSectionKey(this.date);
} }
class EntryRatingSectionKey extends SectionKey with EquatableMixin {
final int rating;
@override
List<Object?> get props => [rating];
const EntryRatingSectionKey(this.rating);
}

View file

@ -22,7 +22,7 @@ import 'package:flutter/foundation.dart';
class VideoMetadataFormatter { class VideoMetadataFormatter {
static final _epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); 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 _durationPattern = RegExp(r'(\d+):(\d+):(\d+)(.\d+)');
static final _locationPattern = RegExp(r'([+-][.0-9]+)'); static final _locationPattern = RegExp(r'([+-][.0-9]+)');
static final Map<String, String> _codecNames = { static final Map<String, String> _codecNames = {
@ -112,9 +112,10 @@ class VideoMetadataFormatter {
return date.millisecondsSinceEpoch; return date.millisecondsSinceEpoch;
} }
// `DateTime` does not recognize: // `DateTime` does not recognize these values found in the wild:
// - `UTC 2021-05-30 19:14:21` // - `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); final match = _anotherDatePattern.firstMatch(dateString);
if (match != null) { if (match != null) {

View file

@ -2,6 +2,7 @@ class MimeTypes {
static const anyImage = 'image/*'; static const anyImage = 'image/*';
static const bmp = 'image/bmp'; static const bmp = 'image/bmp';
static const bmpX = 'image/x-ms-bmp';
static const gif = 'image/gif'; static const gif = 'image/gif';
static const heic = 'image/heic'; static const heic = 'image/heic';
static const heif = 'image/heif'; static const heif = 'image/heif';
@ -43,6 +44,8 @@ class MimeTypes {
static const avi = 'video/avi'; static const avi = 'video/avi';
static const aviVnd = 'video/vnd.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 mkv = 'video/x-matroska';
static const mov = 'video/quicktime'; static const mov = 'video/quicktime';
static const mp2t = 'video/mp2t'; // .m2ts, .ts static const mp2t = 'video/mp2t'; // .m2ts, .ts
@ -62,7 +65,7 @@ class MimeTypes {
// groups // groups
// formats that support transparency // formats that support transparency
static const Set<String> alphaImages = {bmp, gif, ico, png, svg, tiff, webp}; static const Set<String> alphaImages = {bmp, bmpX, gif, ico, png, svg, tiff, webp};
static const Set<String> rawImages = {arw, cr2, crw, dcr, dng, erf, k25, kdc, mrw, nef, nrw, orf, pef, raf, raw, rw2, sr2, srf, srw, x3f}; static const Set<String> 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<String> _knownOpaqueImages = {heic, heif, jpeg}; static const Set<String> _knownOpaqueImages = {heic, heif, jpeg};
static const Set<String> _knownVideos = {avi, aviVnd, mkv, mov, mp2t, mp2ts, mp4, mpeg, ogv, webm}; static const Set<String> _knownVideos = {avi, aviVnd, flv, flvX, mkv, mov, mp2t, mp2ts, mp4, mpeg, ogv, webm};
static final Set<String> knownMediaTypes = {..._knownOpaqueImages, ...alphaImages, ...rawImages, ...undecodableImages, ..._knownVideos}; static final Set<String> knownMediaTypes = {..._knownOpaqueImages, ...alphaImages, ...rawImages, ...undecodableImages, ..._knownVideos};
static bool isImage(String mimeType) => mimeType.startsWith('image'); static bool isImage(String mimeType) => mimeType.startsWith('image');
static bool isVideo(String mimeType) => mimeType.startsWith('video'); 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;
}
}
} }

View file

@ -1,52 +0,0 @@
class XMP {
static const propNamespaceSeparator = ':';
static const structFieldSeparator = '/';
// cf https://exiftool.org/TagNames/XMP.html
static const Map<String, String> 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',
};
}

View file

@ -11,6 +11,8 @@ abstract class DeviceService {
Future<List<Locale>> getLocales(); Future<List<Locale>> getLocales();
Future<int> getPerformanceClass(); Future<int> getPerformanceClass();
Future<bool> isSystemFilePickerEnabled();
} }
class PlatformDeviceService implements DeviceService { class PlatformDeviceService implements DeviceService {
@ -60,7 +62,6 @@ class PlatformDeviceService implements DeviceService {
@override @override
Future<int> getPerformanceClass() async { Future<int> getPerformanceClass() async {
try { try {
await platform.invokeMethod('getPerformanceClass');
final result = await platform.invokeMethod('getPerformanceClass'); final result = await platform.invokeMethod('getPerformanceClass');
if (result != null) return result as int; if (result != null) return result as int;
} on PlatformException catch (e, stack) { } on PlatformException catch (e, stack) {
@ -68,4 +69,15 @@ class PlatformDeviceService implements DeviceService {
} }
return 0; return 0;
} }
@override
Future<bool> 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;
}
} }

View file

@ -1,10 +1,10 @@
import 'dart:async'; import 'dart:async';
import 'package:aves/model/entry.dart'; 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/date_modifier.dart';
import 'package:aves/model/metadata/enums.dart'; import 'package:aves/model/metadata/enums.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:collection/collection.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
abstract class MetadataEditService { abstract class MetadataEditService {
@ -12,11 +12,9 @@ abstract class MetadataEditService {
Future<Map<String, dynamic>> flip(AvesEntry entry); Future<Map<String, dynamic>> flip(AvesEntry entry);
Future<Map<String, dynamic>> editDate(AvesEntry entry, DateModifier modifier); Future<Map<String, dynamic>> editExifDate(AvesEntry entry, DateModifier modifier);
Future<Map<String, dynamic>> setIptc(AvesEntry entry, List<Map<String, dynamic>>? iptc, {required bool postEditScan}); Future<Map<String, dynamic>> editMetadata(AvesEntry entry, Map<MetadataType, dynamic> modifier);
Future<Map<String, dynamic>> setXmp(AvesEntry entry, AvesXmp? xmp);
Future<Map<String, dynamic>> removeTypes(AvesEntry entry, Set<MetadataType> types); Future<Map<String, dynamic>> removeTypes(AvesEntry entry, Set<MetadataType> types);
} }
@ -73,13 +71,13 @@ class PlatformMetadataEditService implements MetadataEditService {
} }
@override @override
Future<Map<String, dynamic>> editDate(AvesEntry entry, DateModifier modifier) async { Future<Map<String, dynamic>> editExifDate(AvesEntry entry, DateModifier modifier) async {
try { try {
final result = await platform.invokeMethod('editDate', <String, dynamic>{ final result = await platform.invokeMethod('editDate', <String, dynamic>{
'entry': _toPlatformEntryMap(entry), 'entry': _toPlatformEntryMap(entry),
'dateMillis': modifier.dateTime?.millisecondsSinceEpoch, 'dateMillis': modifier.setDateTime?.millisecondsSinceEpoch,
'shiftMinutes': modifier.shiftMinutes, '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<String, dynamic>(); if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e, stack) { } on PlatformException catch (e, stack) {
@ -91,29 +89,11 @@ class PlatformMetadataEditService implements MetadataEditService {
} }
@override @override
Future<Map<String, dynamic>> setIptc(AvesEntry entry, List<Map<String, dynamic>>? iptc, {required bool postEditScan}) async { Future<Map<String, dynamic>> editMetadata(AvesEntry entry, Map<MetadataType, dynamic> metadata) async {
try { try {
final result = await platform.invokeMethod('setIptc', <String, dynamic>{ final result = await platform.invokeMethod('editMetadata', <String, dynamic>{
'entry': _toPlatformEntryMap(entry), 'entry': _toPlatformEntryMap(entry),
'iptc': iptc, 'metadata': metadata.map((type, value) => MapEntry(_toPlatformMetadataType(type), value)),
'postEditScan': postEditScan,
});
if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e, stack) {
if (!entry.isMissingAtPath) {
await reportService.recordError(e, stack);
}
}
return {};
}
@override
Future<Map<String, dynamic>> setXmp(AvesEntry entry, AvesXmp? xmp) async {
try {
final result = await platform.invokeMethod('setXmp', <String, dynamic>{
'entry': _toPlatformEntryMap(entry),
'xmp': xmp?.xmpString,
'extendedXmp': xmp?.extendedXmpString,
}); });
if (result != null) return (result as Map).cast<String, dynamic>(); if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e, stack) { } on PlatformException catch (e, stack) {
@ -140,19 +120,6 @@ class PlatformMetadataEditService implements MetadataEditService {
return {}; 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) { String _toPlatformMetadataType(MetadataType type) {
switch (type) { switch (type) {
case MetadataType.comment: case MetadataType.comment:

View file

@ -1,11 +1,12 @@
import 'package:aves/model/entry.dart'; 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/catalog.dart';
import 'package:aves/model/metadata/enums.dart';
import 'package:aves/model/metadata/overlay.dart'; import 'package:aves/model/metadata/overlay.dart';
import 'package:aves/model/multipage.dart'; import 'package:aves/model/multipage.dart';
import 'package:aves/model/panorama.dart'; import 'package:aves/model/panorama.dart';
import 'package:aves/services/common/service_policy.dart'; import 'package:aves/services/common/service_policy.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/services/metadata/xmp.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@ -28,6 +29,8 @@ abstract class MetadataFetchService {
Future<bool> hasContentResolverProp(String prop); Future<bool> hasContentResolverProp(String prop);
Future<String?> getContentResolverProp(AvesEntry entry, String prop); Future<String?> getContentResolverProp(AvesEntry entry, String prop);
Future<DateTime?> getDate(AvesEntry entry, MetadataField field);
} }
class PlatformMetadataFetchService implements MetadataFetchService { class PlatformMetadataFetchService implements MetadataFetchService {
@ -63,6 +66,7 @@ class PlatformMetadataFetchService implements MetadataFetchService {
// 'dateMillis': date taken in milliseconds since Epoch (long) // 'dateMillis': date taken in milliseconds since Epoch (long)
// 'isAnimated': animated gif/webp (bool) // 'isAnimated': animated gif/webp (bool)
// 'isFlipped': flipped according to EXIF orientation (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) // 'rotationDegrees': rotation degrees according to EXIF orientation or other metadata (int)
// 'latitude': latitude (double) // 'latitude': latitude (double)
// 'longitude': longitude (double) // 'longitude': longitude (double)
@ -223,4 +227,22 @@ class PlatformMetadataFetchService implements MetadataFetchService {
} }
return null; return null;
} }
@override
Future<DateTime?> getDate(AvesEntry entry, MetadataField field) async {
try {
final result = await platform.invokeMethod('getDate', <String, dynamic>{
'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;
}
} }

View file

@ -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<Object?> get props => [xmpString, extendedXmpString];
const AvesXmp({
required this.xmpString,
this.extendedXmpString,
});
static AvesXmp? fromList(List<String> xmpStrings) {
switch (xmpStrings.length) {
case 0:
return null;
case 1:
return AvesXmp(xmpString: xmpStrings.single);
default:
final byExtending = groupBy<String, bool>(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);
}
}
}

View file

@ -97,6 +97,7 @@ class DurationsProvider extends StatelessWidget {
class DurationsData { class DurationsData {
// common animations // common animations
final Duration expansionTileAnimation; final Duration expansionTileAnimation;
final Duration formTransition;
final Duration iconAnimation; final Duration iconAnimation;
final Duration staggeredAnimation; final Duration staggeredAnimation;
final Duration staggeredAnimationPageTarget; final Duration staggeredAnimationPageTarget;
@ -111,6 +112,7 @@ class DurationsData {
const DurationsData({ const DurationsData({
this.expansionTileAnimation = const Duration(milliseconds: 200), this.expansionTileAnimation = const Duration(milliseconds: 200),
this.formTransition = const Duration(milliseconds: 200),
this.iconAnimation = const Duration(milliseconds: 300), this.iconAnimation = const Duration(milliseconds: 300),
this.staggeredAnimation = const Duration(milliseconds: 375), this.staggeredAnimation = const Duration(milliseconds: 375),
this.staggeredAnimationPageTarget = const Duration(milliseconds: 800), this.staggeredAnimationPageTarget = const Duration(milliseconds: 800),
@ -123,6 +125,7 @@ class DurationsData {
return DurationsData( return DurationsData(
// as of Flutter v2.5.1, `ExpansionPanelList` throws if animation duration is zero // as of Flutter v2.5.1, `ExpansionPanelList` throws if animation duration is zero
expansionTileAnimation: const Duration(microseconds: 1), expansionTileAnimation: const Duration(microseconds: 1),
formTransition: Duration.zero,
iconAnimation: Duration.zero, iconAnimation: Duration.zero,
staggeredAnimation: Duration.zero, staggeredAnimation: Duration.zero,
staggeredAnimationPageTarget: Duration.zero, staggeredAnimationPageTarget: Duration.zero,

View file

@ -19,18 +19,22 @@ class AIcons {
static const IconData home = Icons.home_outlined; static const IconData home = Icons.home_outlined;
static const IconData language = Icons.translate_outlined; static const IconData language = Icons.translate_outlined;
static const IconData location = Icons.place_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 mainStorage = Icons.smartphone_outlined;
static const IconData privacy = MdiIcons.shieldAccountOutline; 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 raw = Icons.raw_on_outlined;
static const IconData shooting = Icons.camera_outlined; static const IconData shooting = Icons.camera_outlined;
static const IconData removableStorage = Icons.sd_storage_outlined; static const IconData removableStorage = Icons.sd_storage_outlined;
static const IconData sensorControl = Icons.explore_outlined; static const IconData sensorControlEnabled = Icons.explore_outlined;
static const IconData sensorControlOff = Icons.explore_off_outlined; static const IconData sensorControlDisabled = Icons.explore_off_outlined;
static const IconData settings = Icons.settings_outlined; static const IconData settings = Icons.settings_outlined;
static const IconData text = Icons.format_quote_outlined; static const IconData text = Icons.format_quote_outlined;
static const IconData tag = Icons.local_offer_outlined; static const IconData tag = Icons.local_offer_outlined;
static const IconData tagOff = MdiIcons.tagOffOutline; static const IconData tagUntagged = MdiIcons.tagOffOutline;
// view // view
static const IconData group = Icons.group_work_outlined; static const IconData group = Icons.group_work_outlined;
@ -40,7 +44,6 @@ class AIcons {
// actions // actions
static const IconData add = Icons.add_circle_outline; static const IconData add = Icons.add_circle_outline;
static const IconData addShortcut = Icons.add_to_home_screen_outlined; 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 cancel = Icons.cancel_outlined;
static const IconData replay10 = Icons.replay_10_outlined; static const IconData replay10 = Icons.replay_10_outlined;
static const IconData skip10 = Icons.forward_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 debug = Icons.whatshot_outlined;
static const IconData delete = Icons.delete_outlined; static const IconData delete = Icons.delete_outlined;
static const IconData edit = Icons.edit_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 export = MdiIcons.fileExportOutline;
static const IconData flip = Icons.flip_outlined; static const IconData flip = Icons.flip_outlined;
static const IconData favourite = Icons.favorite_border; static const IconData favourite = Icons.favorite_border;
@ -111,8 +116,8 @@ class AIcons {
static const IconData geo = Icons.language_outlined; static const IconData geo = Icons.language_outlined;
static const IconData motionPhoto = Icons.motion_photos_on_outlined; static const IconData motionPhoto = Icons.motion_photos_on_outlined;
static const IconData multiPage = Icons.burst_mode_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 threeSixty = Icons.threesixty_outlined;
static const IconData videoThumb = Icons.play_circle_outline;
static const IconData selected = Icons.check_circle_outline; static const IconData selected = Icons.check_circle_outline;
static const IconData unselected = Icons.radio_button_unchecked; static const IconData unselected = Icons.radio_button_unchecked;

View file

@ -34,9 +34,11 @@ class Themes {
fontFeatures: [FontFeature.enable('smcp')], fontFeatures: [FontFeature.enable('smcp')],
), ),
), ),
colorScheme: const ColorScheme.dark( colorScheme: ColorScheme.dark(
primary: _accentColor, primary: _accentColor,
secondary: _accentColor, secondary: _accentColor,
// surface color is used as background for the date picker header
surface: Colors.grey.shade800,
onPrimary: Colors.white, onPrimary: Colors.white,
onSecondary: Colors.white, onSecondary: Colors.white,
), ),

View file

@ -18,7 +18,9 @@ final _unixStampMillisPattern = RegExp(r'\d{13}');
final _unixStampSecPattern = RegExp(r'\d{10}'); final _unixStampSecPattern = RegExp(r'\d{10}');
final _plainPattern = RegExp(r'(\d{8})([_-\s](\d{6})([_-\s](\d{3}))?)?'); 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); var match = _unixStampMillisPattern.firstMatch(s);
if (match != null) { if (match != null) {
final stampString = match.group(0); final stampString = match.group(0);

289
lib/utils/xmp_utils.dart Normal file
View file

@ -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<XmlNode> 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<String, String> namespaces) => namespaces.forEach((uri, prefix) => node.setAttribute('$xmlnsPrefix:$prefix', uri));
// remove elements and attributes
static bool _removeElements(List<XmlNode> 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<XmlNode> 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<XmlNode> 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<XmlNode> nodes,
String name,
Set<String> 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<String?> edit(
String? xmpString,
Future<String> Function() toolkit,
void Function(List<XmlNode> 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<XmlNode> 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 }

View file

@ -8,6 +8,7 @@ class AboutCredits extends StatelessWidget {
static const translators = { static const translators = {
'Deutsch': 'JanWaldhorn', 'Deutsch': 'JanWaldhorn',
'Español (México)': 'n-berenice',
'Русский': 'D3ZOXY', 'Русский': 'D3ZOXY',
}; };

View file

@ -106,6 +106,12 @@ class _AvesAppState extends State<AvesApp> {
home: home, home: home,
navigatorObservers: _navigatorObservers, navigatorObservers: _navigatorObservers,
builder: (context, child) { 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) { if (!areAnimationsEnabled) {
child = Theme( child = Theme(
data: Theme.of(context).copyWith( data: Theme.of(context).copyWith(

View file

@ -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/app_bar_title.dart';
import 'package:aves/widgets/common/basic/menu.dart'; import 'package:aves/widgets/common/basic/menu.dart';
import 'package:aves/widgets/common/extensions/build_context.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/dialogs/tile_view_dialog.dart';
import 'package:aves/widgets/search/search_delegate.dart'; import 'package:aves/widgets/search/search_delegate.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -101,11 +103,8 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final appMode = context.watch<ValueNotifier<AppMode>>().value; final appMode = context.watch<ValueNotifier<AppMode>>().value;
return Selector<Selection<AvesEntry>, Tuple2<bool, int>>( final selection = context.watch<Selection<AvesEntry>>();
selector: (context, selection) => Tuple2(selection.isSelecting, selection.selectedItems.length), final isSelecting = selection.isSelecting;
builder: (context, s, child) {
final isSelecting = s.item1;
final selectedItemCount = s.item2;
_isSelectingNotifier.value = isSelecting; _isSelectingNotifier.value = isSelecting;
return AnimatedBuilder( return AnimatedBuilder(
animation: collection.filterChangeNotifier, animation: collection.filterChangeNotifier,
@ -116,11 +115,10 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
builder: (context, queryEnabled, child) { builder: (context, queryEnabled, child) {
return SliverAppBar( return SliverAppBar(
leading: appMode.hasDrawer ? _buildAppBarLeading(isSelecting) : null, leading: appMode.hasDrawer ? _buildAppBarLeading(isSelecting) : null,
title: _buildAppBarTitle(isSelecting), title: SliverAppBarTitleWrapper(
actions: _buildActions( child: _buildAppBarTitle(isSelecting),
isSelecting: isSelecting,
selectedItemCount: selectedItemCount,
), ),
actions: _buildActions(selection),
bottom: PreferredSize( bottom: PreferredSize(
preferredSize: Size.fromHeight(appBarBottomHeight), preferredSize: Size.fromHeight(appBarBottomHeight),
child: Column( child: Column(
@ -146,8 +144,6 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
); );
}, },
); );
},
);
} }
double get appBarBottomHeight { double get appBarBottomHeight {
@ -177,7 +173,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
); );
} }
Widget? _buildAppBarTitle(bool isSelecting) { Widget _buildAppBarTitle(bool isSelecting) {
final l10n = context.l10n; final l10n = context.l10n;
if (isSelecting) { if (isSelecting) {
@ -201,16 +197,15 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
} }
} }
List<Widget> _buildActions({ List<Widget> _buildActions(Selection<AvesEntry> selection) {
required bool isSelecting, final isSelecting = selection.isSelecting;
required int selectedItemCount, final selectedItemCount = selection.selectedItems.length;
}) {
final appMode = context.watch<ValueNotifier<AppMode>>().value; final appMode = context.watch<ValueNotifier<AppMode>>().value;
bool isVisible(EntrySetAction action) => _actionDelegate.isVisible( bool isVisible(EntrySetAction action) => _actionDelegate.isVisible(
action, action,
appMode: appMode, appMode: appMode,
isSelecting: isSelecting, isSelecting: isSelecting,
sortFactor: collection.sortFactor,
itemCount: collection.entryCount, itemCount: collection.entryCount,
selectedItemCount: selectedItemCount, selectedItemCount: selectedItemCount,
); );
@ -225,7 +220,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
final browsingQuickActions = settings.collectionBrowsingQuickActions; final browsingQuickActions = settings.collectionBrowsingQuickActions;
final selectionQuickActions = settings.collectionSelectionQuickActions; final selectionQuickActions = settings.collectionSelectionQuickActions;
final quickActionButtons = (isSelecting ? selectionQuickActions : browsingQuickActions).where(isVisible).map( final quickActionButtons = (isSelecting ? selectionQuickActions : browsingQuickActions).where(isVisible).map(
(action) => _toActionButton(action, enabled: canApply(action)), (action) => _toActionButton(action, enabled: canApply(action), selection: selection),
); );
return [ return [
@ -236,14 +231,14 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
key: const Key('appbar-menu-button'), key: const Key('appbar-menu-button'),
itemBuilder: (context) { itemBuilder: (context) {
final generalMenuItems = EntrySetActions.general.where(isVisible).map( 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 browsingMenuActions = EntrySetActions.browsing.where((v) => !browsingQuickActions.contains(v));
final selectionMenuActions = EntrySetActions.selection.where((v) => !selectionQuickActions.contains(v)); final selectionMenuActions = EntrySetActions.selection.where((v) => !selectionQuickActions.contains(v));
final contextualMenuItems = [ final contextualMenuItems = [
...(isSelecting ? selectionMenuActions : browsingMenuActions).where(isVisible).map( ...(isSelecting ? selectionMenuActions : browsingMenuActions).where(isVisible).map(
(action) => _toMenuItem(action, enabled: canApply(action)), (action) => _toMenuItem(action, enabled: canApply(action), selection: selection),
), ),
if (isSelecting) if (isSelecting)
PopupMenuItem<EntrySetAction>( PopupMenuItem<EntrySetAction>(
@ -257,9 +252,10 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
_buildRotateAndFlipMenuItems(context, canApply: canApply), _buildRotateAndFlipMenuItems(context, canApply: canApply),
...[ ...[
EntrySetAction.editDate, EntrySetAction.editDate,
EntrySetAction.editRating,
EntrySetAction.editTags, EntrySetAction.editTags,
EntrySetAction.removeMetadata, 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<CollectionAppBar> with SingleTickerPr
]; ];
} }
Set<AvesEntry> _getExpandedSelectedItems(Selection<AvesEntry> selection) {
return selection.selectedItems.expand((entry) => entry.burstEntries ?? {entry}).toSet();
}
// key is expected by test driver (e.g. 'menu-configureView', 'menu-map') // key is expected by test driver (e.g. 'menu-configureView', 'menu-map')
Key _getActionKey(EntrySetAction action) => Key('menu-${action.name}'); Key _getActionKey(EntrySetAction action) => Key('menu-${action.name}');
Widget _toActionButton(EntrySetAction action, {required bool enabled}) { Widget _toActionButton(EntrySetAction action, {required bool enabled, required Selection<AvesEntry> selection}) {
final onPressed = enabled ? () => _onActionSelected(action) : null; final onPressed = enabled ? () => _onActionSelected(action) : null;
switch (action) { switch (action) {
case EntrySetAction.toggleTitleSearch: case EntrySetAction.toggleTitleSearch:
@ -299,6 +299,11 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
); );
}, },
); );
case EntrySetAction.toggleFavourite:
return FavouriteToggler(
entries: _getExpandedSelectedItems(selection),
onPressed: onPressed,
);
default: default:
return IconButton( return IconButton(
key: _getActionKey(action), key: _getActionKey(action),
@ -309,7 +314,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
} }
} }
PopupMenuItem<EntrySetAction> _toMenuItem(EntrySetAction action, {required bool enabled}) { PopupMenuItem<EntrySetAction> _toMenuItem(EntrySetAction action, {required bool enabled, required Selection<AvesEntry> selection}) {
late Widget child; late Widget child;
switch (action) { switch (action) {
case EntrySetAction.toggleTitleSearch: case EntrySetAction.toggleTitleSearch:
@ -318,6 +323,12 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
isMenuItem: true, isMenuItem: true,
); );
break; break;
case EntrySetAction.toggleFavourite:
child = FavouriteToggler(
entries: _getExpandedSelectedItems(selection),
isMenuItem: true,
);
break;
default: default:
child = MenuRow(text: action.getText(context), icon: action.getIcon()); child = MenuRow(text: action.getText(context), icon: action.getIcon());
break; break;
@ -421,10 +432,12 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
case EntrySetAction.copy: case EntrySetAction.copy:
case EntrySetAction.move: case EntrySetAction.move:
case EntrySetAction.rescan: case EntrySetAction.rescan:
case EntrySetAction.toggleFavourite:
case EntrySetAction.rotateCCW: case EntrySetAction.rotateCCW:
case EntrySetAction.rotateCW: case EntrySetAction.rotateCW:
case EntrySetAction.flip: case EntrySetAction.flip:
case EntrySetAction.editDate: case EntrySetAction.editDate:
case EntrySetAction.editRating:
case EntrySetAction.editTags: case EntrySetAction.editTags:
case EntrySetAction.removeMetadata: case EntrySetAction.removeMetadata:
_actionDelegate.onActionSelected(context, action); _actionDelegate.onActionSelected(context, action);
@ -448,6 +461,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
EntrySortFactor.date: l10n.collectionSortDate, EntrySortFactor.date: l10n.collectionSortDate,
EntrySortFactor.size: l10n.collectionSortSize, EntrySortFactor.size: l10n.collectionSortSize,
EntrySortFactor.name: l10n.collectionSortName, EntrySortFactor.name: l10n.collectionSortName,
EntrySortFactor.rating: l10n.collectionSortRating,
}, },
groupOptions: { groupOptions: {
EntryGroupFactor.album: l10n.collectionGroupAlbum, EntryGroupFactor.album: l10n.collectionGroupAlbum,

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'package:aves/app_mode.dart'; import 'package:aves/app_mode.dart';
import 'package:aves/model/entry.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/favourite.dart';
import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
@ -103,13 +104,18 @@ class _CollectionGridContent extends StatelessWidget {
columnCount: columnCount, columnCount: columnCount,
spacing: tileSpacing, spacing: tileSpacing,
tileExtent: thumbnailExtent, tileExtent: thumbnailExtent,
tileBuilder: (entry) => InteractiveTile( tileBuilder: (entry) => AnimatedBuilder(
animation: favourites,
builder: (context, child) {
return InteractiveTile(
key: ValueKey(entry.contentId), key: ValueKey(entry.contentId),
collection: collection, collection: collection,
entry: entry, entry: entry,
thumbnailExtent: thumbnailExtent, thumbnailExtent: thumbnailExtent,
tileLayout: tileLayout, tileLayout: tileLayout,
isScrollingNotifier: _isScrollingNotifier, isScrollingNotifier: _isScrollingNotifier,
);
},
), ),
tileAnimationDelay: tileAnimationDelay, tileAnimationDelay: tileAnimationDelay,
child: child!, child: child!,

View file

@ -1,4 +1,5 @@
import 'package:aves/model/entry.dart'; 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_lens.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/enums.dart';
@ -47,6 +48,11 @@ class CollectionDraggableThumbLabel extends StatelessWidget {
if (_showAlbumName(context, entry)) _getAlbumName(context, entry), if (_showAlbumName(context, entry)) _getAlbumName(context, entry),
if (entry.bestTitle != null) entry.bestTitle!, if (entry.bestTitle != null) entry.bestTitle!,
]; ];
case EntrySortFactor.rating:
return [
RatingFilter.formatRating(context, entry.rating),
DraggableThumbLabel.formatMonthThumbLabel(context, entry.bestDate),
];
case EntrySortFactor.size: case EntrySortFactor.size:
return [ return [
if (entry.sizeBytes != null) formatFileSize(context.l10n.localeName, entry.sizeBytes!, round: 0), if (entry.sizeBytes != null) formatFileSize(context.l10n.localeName, entry.sizeBytes!, round: 0),

View file

@ -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/actions/move_type.dart';
import 'package:aves/model/device.dart'; import 'package:aves/model/device.dart';
import 'package:aves/model/entry.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/album.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/highlight.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/analysis_controller.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.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/image_op_events.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/services/media/enums.dart'; import 'package:aves/services/media/enums.dart';
@ -44,7 +44,6 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
EntrySetAction action, { EntrySetAction action, {
required AppMode appMode, required AppMode appMode,
required bool isSelecting, required bool isSelecting,
required EntrySortFactor sortFactor,
required int itemCount, required int itemCount,
required int selectedItemCount, required int selectedItemCount,
}) { }) {
@ -75,10 +74,12 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
case EntrySetAction.copy: case EntrySetAction.copy:
case EntrySetAction.move: case EntrySetAction.move:
case EntrySetAction.rescan: case EntrySetAction.rescan:
case EntrySetAction.toggleFavourite:
case EntrySetAction.rotateCCW: case EntrySetAction.rotateCCW:
case EntrySetAction.rotateCW: case EntrySetAction.rotateCW:
case EntrySetAction.flip: case EntrySetAction.flip:
case EntrySetAction.editDate: case EntrySetAction.editDate:
case EntrySetAction.editRating:
case EntrySetAction.editTags: case EntrySetAction.editTags:
case EntrySetAction.removeMetadata: case EntrySetAction.removeMetadata:
return appMode == AppMode.main && isSelecting; return appMode == AppMode.main && isSelecting;
@ -116,10 +117,12 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
case EntrySetAction.copy: case EntrySetAction.copy:
case EntrySetAction.move: case EntrySetAction.move:
case EntrySetAction.rescan: case EntrySetAction.rescan:
case EntrySetAction.toggleFavourite:
case EntrySetAction.rotateCCW: case EntrySetAction.rotateCCW:
case EntrySetAction.rotateCW: case EntrySetAction.rotateCW:
case EntrySetAction.flip: case EntrySetAction.flip:
case EntrySetAction.editDate: case EntrySetAction.editDate:
case EntrySetAction.editRating:
case EntrySetAction.editTags: case EntrySetAction.editTags:
case EntrySetAction.removeMetadata: case EntrySetAction.removeMetadata:
return hasSelection; return hasSelection;
@ -167,6 +170,9 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
case EntrySetAction.rescan: case EntrySetAction.rescan:
_rescan(context); _rescan(context);
break; break;
case EntrySetAction.toggleFavourite:
_toggleFavourite(context);
break;
case EntrySetAction.rotateCCW: case EntrySetAction.rotateCCW:
_rotate(context, clockwise: false); _rotate(context, clockwise: false);
break; break;
@ -179,6 +185,9 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
case EntrySetAction.editDate: case EntrySetAction.editDate:
_editDate(context); _editDate(context);
break; break;
case EntrySetAction.editRating:
_editRating(context);
break;
case EntrySetAction.editTags: case EntrySetAction.editTags:
_editTags(context); _editTags(context);
break; break;
@ -211,6 +220,18 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
selection.browse(); selection.browse();
} }
Future<void> _toggleFavourite(BuildContext context) async {
final selection = context.read<Selection<AvesEntry>>();
final selectedItems = _getExpandedSelectedItems(selection);
if (selectedItems.every((entry) => entry.isFavourite)) {
await favourites.remove(selectedItems);
} else {
await favourites.add(selectedItems);
}
selection.browse();
}
Future<void> _delete(BuildContext context) async { Future<void> _delete(BuildContext context) async {
final source = context.read<CollectionSource>(); final source = context.read<CollectionSource>();
final selection = context.read<Selection<AvesEntry>>(); final selection = context.read<Selection<AvesEntry>>();
@ -489,7 +510,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canRotateAndFlip); final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canRotateAndFlip);
if (todoItems == null || todoItems.isEmpty) return; 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<void> _flip(BuildContext context) async { Future<void> _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); final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canRotateAndFlip);
if (todoItems == null || todoItems.isEmpty) return; 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<void> _editDate(BuildContext context) async { Future<void> _editDate(BuildContext context) async {
@ -515,6 +536,19 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
await _edit(context, selection, todoItems, (entry) => entry.editDate(modifier)); await _edit(context, selection, todoItems, (entry) => entry.editDate(modifier));
} }
Future<void> _editRating(BuildContext context) async {
final selection = context.read<Selection<AvesEntry>>();
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<void> _editTags(BuildContext context) async { Future<void> _editTags(BuildContext context) async {
final selection = context.read<Selection<AvesEntry>>(); final selection = context.read<Selection<AvesEntry>>();
final selectedItems = _getExpandedSelectedItems(selection); final selectedItems = _getExpandedSelectedItems(selection);

View file

@ -7,6 +7,7 @@ import 'package:aves/model/source/enums.dart';
import 'package:aves/model/source/section_keys.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/album.dart';
import 'package:aves/widgets/collection/grid/headers/date.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:aves/widgets/common/grid/header.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -49,6 +50,8 @@ class CollectionSectionHeader extends StatelessWidget {
break; break;
case EntrySortFactor.name: case EntrySortFactor.name:
return _buildAlbumHeader(context); return _buildAlbumHeader(context);
case EntrySortFactor.rating:
return RatingSectionHeader<AvesEntry>(key: ValueKey(sectionKey), rating: (sectionKey as EntryRatingSectionKey).rating);
case EntrySortFactor.size: case EntrySortFactor.size:
break; break;
} }

View file

@ -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<T> extends StatelessWidget {
final int rating;
const RatingSectionHeader({
Key? key,
required this.rating,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return SectionHeader<T>(
sectionKey: EntryRatingSectionKey(rating),
title: RatingFilter.formatRating(context, rating),
);
}
}

View file

@ -5,6 +5,7 @@ import 'package:aves/ref/mime_types.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_dialog.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_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/edit_entry_tags_dialog.dart';
import 'package:aves/widgets/dialogs/entry_editors/remove_entry_metadata_dialog.dart'; import 'package:aves/widgets/dialogs/entry_editors/remove_entry_metadata_dialog.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -22,6 +23,18 @@ mixin EntryEditorMixin {
return modifier; return modifier;
} }
Future<int?> selectRating(BuildContext context, Set<AvesEntry> entries) async {
if (entries.isEmpty) return null;
final rating = await showDialog<int?>(
context: context,
builder: (context) => EditEntryRatingDialog(
entry: entries.first,
),
);
return rating;
}
Future<Map<AvesEntry, Set<String>>?> selectTags(BuildContext context, Set<AvesEntry> entries) async { Future<Map<AvesEntry, Set<String>>?> selectTags(BuildContext context, Set<AvesEntry> entries) async {
if (entries.isEmpty) return null; if (entries.isEmpty) return null;

View file

@ -47,11 +47,12 @@ mixin PermissionAwareMixin {
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (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); final volume = dir.getVolumeDescription(context);
return AvesDialog( return AvesDialog(
title: context.l10n.storageAccessDialogTitle, title: l10n.storageAccessDialogTitle,
content: Text(context.l10n.storageAccessDialogMessage(directory, volume)), content: Text(l10n.storageAccessDialogMessage(directory, volume)),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
@ -68,6 +69,26 @@ mixin PermissionAwareMixin {
// abort if the user cancels in Flutter // abort if the user cancels in Flutter
if (confirmed == null || !confirmed) return false; 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); final granted = await storageService.requestDirectoryAccess(dir.volumePath);
if (!granted) { if (!granted) {
// abort if the user denies access from the native dialog // abort if the user denies access from the native dialog

View file

@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
class WheelSelector<T> extends StatefulWidget {
final ValueNotifier<T> valueNotifier;
final List<T> 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<T>();
}
class _WheelSelectorState<T> extends State<WheelSelector<T>> {
late final ScrollController _controller;
static const itemSize = Size(40, 40);
ValueNotifier<T> get valueNotifier => widget.valueNotifier;
List<T> 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(),
),
),
),
);
}
}

View file

@ -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<AvesEntry> 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<FavouriteToggler> {
final ValueNotifier<bool> isFavouriteNotifier = ValueNotifier(false);
Set<AvesEntry> 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<bool>(
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);
}
}

View file

@ -316,7 +316,6 @@ class _ScaleOverlayState extends State<_ScaleOverlay> {
colors: const [ colors: const [
Colors.black, Colors.black,
Colors.black54, Colors.black54,
// Colors.amber,
], ],
), ),
) )

View file

@ -28,8 +28,10 @@ class GridTheme extends StatelessWidget {
iconSize: iconSize, iconSize: iconSize,
fontSize: fontSize, fontSize: fontSize,
highlightBorderWidth: highlightBorderWidth, highlightBorderWidth: highlightBorderWidth,
showFavourite: settings.showThumbnailFavourite,
showLocation: showLocation ?? settings.showThumbnailLocation, showLocation: showLocation ?? settings.showThumbnailLocation,
showMotionPhoto: settings.showThumbnailMotionPhoto, showMotionPhoto: settings.showThumbnailMotionPhoto,
showRating: settings.showThumbnailRating,
showRaw: settings.showThumbnailRaw, showRaw: settings.showThumbnailRaw,
showVideoDuration: settings.showThumbnailVideoDuration, showVideoDuration: settings.showThumbnailVideoDuration,
); );
@ -41,14 +43,16 @@ class GridTheme extends StatelessWidget {
class GridThemeData { class GridThemeData {
final double iconSize, fontSize, highlightBorderWidth; final double iconSize, fontSize, highlightBorderWidth;
final bool showLocation, showMotionPhoto, showRaw, showVideoDuration; final bool showFavourite, showLocation, showMotionPhoto, showRating, showRaw, showVideoDuration;
const GridThemeData({ const GridThemeData({
required this.iconSize, required this.iconSize,
required this.fontSize, required this.fontSize,
required this.highlightBorderWidth, required this.highlightBorderWidth,
required this.showFavourite,
required this.showLocation, required this.showLocation,
required this.showMotionPhoto, required this.showMotionPhoto,
required this.showRating,
required this.showRaw, required this.showRaw,
required this.showVideoDuration, required this.showVideoDuration,
}); });

View file

@ -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 { class GpsIcon extends StatelessWidget {
const GpsIcon({Key? key}) : super(key: key); 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<GridThemeData>();
return DefaultTextStyle(
style: TextStyle(
color: Colors.grey.shade200,
fontSize: gridTheme.fontSize,
),
child: OverlayIcon(
icon: AIcons.rating,
text: '${entry.rating}',
),
);
}
}
class OverlayIcon extends StatelessWidget { class OverlayIcon extends StatelessWidget {
final IconData icon; final IconData icon;
final String? text; final String? text;

View file

@ -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<FlexibleSpaceBarSettings>()!.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,
);
}
}

View file

@ -19,11 +19,11 @@ class ThumbnailEntryOverlay extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final children = [ final children = [
if (entry.isFavourite && context.select<GridThemeData, bool>((t) => t.showFavourite)) const FavouriteIcon(),
if (entry.hasGps && context.select<GridThemeData, bool>((t) => t.showLocation)) const GpsIcon(), if (entry.hasGps && context.select<GridThemeData, bool>((t) => t.showLocation)) const GpsIcon(),
if (entry.rating != 0 && context.select<GridThemeData, bool>((t) => t.showRating)) RatingIcon(entry: entry),
if (entry.isVideo) if (entry.isVideo)
VideoIcon( VideoIcon(entry: entry)
entry: entry,
)
else if (entry.isAnimated) else if (entry.isAnimated)
const AnimatedImageIcon() const AnimatedImageIcon()
else ...[ else ...[

View file

@ -1,6 +1,7 @@
import 'package:aves/image_providers/app_icon_image_provider.dart'; import 'package:aves/image_providers/app_icon_image_provider.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/utils/android_file_utils.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/common/identity/aves_expansion_tile.dart';
import 'package:aves/widgets/viewer/info/common.dart'; import 'package:aves/widgets/viewer/info/common.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
@ -15,6 +16,7 @@ class DebugAndroidAppSection extends StatefulWidget {
class _DebugAndroidAppSectionState extends State<DebugAndroidAppSection> with AutomaticKeepAliveClientMixin { class _DebugAndroidAppSectionState extends State<DebugAndroidAppSection> with AutomaticKeepAliveClientMixin {
late Future<Set<Package>> _loader; late Future<Set<Package>> _loader;
final ValueNotifier<String> _queryNotifier = ValueNotifier('');
static const iconSize = 20.0; static const iconSize = 20.0;
@ -43,7 +45,15 @@ class _DebugAndroidAppSectionState extends State<DebugAndroidAppSection> with Au
final disabledTheme = enabledTheme.merge(const IconThemeData(opacity: .2)); final disabledTheme = enabledTheme.merge(const IconThemeData(opacity: .2));
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: packages.map((package) { children: [
QueryBar(queryNotifier: _queryNotifier),
...packages.map((package) {
return ValueListenableBuilder<String>(
valueListenable: _queryNotifier,
builder: (context, query, child) {
if ({package.packageName, ...package.potentialDirs}.none((v) => v.toLowerCase().contains(query.toLowerCase()))) {
return const SizedBox();
}
return Text.rich( return Text.rich(
TextSpan( TextSpan(
children: [ children: [
@ -89,7 +99,10 @@ class _DebugAndroidAppSectionState extends State<DebugAndroidAppSection> with Au
], ],
), ),
); );
}).toList(), },
);
})
],
); );
}, },
), ),

View file

@ -4,13 +4,13 @@ import 'package:aves/model/metadata/enums.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/theme/format.dart'; import 'package:aves/theme/format.dart';
import 'package:aves/theme/icons.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/extensions/build_context.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.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:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../aves_dialog.dart';
class EditEntryDateDialog extends StatefulWidget { class EditEntryDateDialog extends StatefulWidget {
final AvesEntry entry; final AvesEntry entry;
@ -24,144 +24,78 @@ class EditEntryDateDialog extends StatefulWidget {
} }
class _EditEntryDateDialogState extends State<EditEntryDateDialog> { class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
DateEditAction _action = DateEditAction.set; DateEditAction _action = DateEditAction.setCustom;
late Set<MetadataField> _fields; DateFieldSource _copyFieldSource = DateFieldSource.fileModifiedDate;
late DateTime _dateTime; late DateTime _setDateTime;
int _shiftMinutes = 60; late ValueNotifier<int> _shiftHour, _shiftMinute;
late ValueNotifier<String> _shiftSign;
bool _showOptions = false; bool _showOptions = false;
final Set<MetadataField> _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 @override
void initState() { void initState() {
super.initState(); super.initState();
_fields = { _initSet();
MetadataField.exifDate, _initShift(60);
MetadataField.exifDateDigitized, }
MetadataField.exifDateOriginal,
}; void _initSet() {
_dateTime = entry.bestDate ?? DateTime.now(); _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MediaQueryDataProvider( return MediaQueryDataProvider(
child: Builder( child: TooltipTheme(
builder: (context) { data: TooltipTheme.of(context).copyWith(
preferBelow: false,
),
child: Builder(builder: (context) {
final l10n = context.l10n; final l10n = context.l10n;
final locale = l10n.localeName;
final use24hour = context.select<MediaQueryData, bool>((v) => v.alwaysUse24HourFormat);
void _updateAction(DateEditAction? action) { return AvesDialog(
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<DateEditAction>(
value: DateEditAction.set,
groupValue: _action,
onChanged: _updateAction,
title: _tileText(l10n.editEntryDateDialogSet),
subtitle: Text(formatDateTime(_dateTime, locale, use24hour)),
),
),
Padding(
padding: const EdgeInsetsDirectional.only(end: 12),
child: IconButton(
icon: const Icon(AIcons.edit),
onPressed: _action == DateEditAction.set ? _editDate : null,
tooltip: l10n.changeTooltip,
),
),
],
);
final shiftTile = Row(
children: [
Expanded(
child: RadioListTile<DateEditAction>(
value: DateEditAction.shift,
groupValue: _action,
onChanged: _updateAction,
title: _tileText(l10n.editEntryDateDialogShift),
subtitle: Text(_formatShiftDuration()),
),
),
Padding(
padding: const EdgeInsetsDirectional.only(end: 12),
child: IconButton(
icon: const Icon(AIcons.edit),
onPressed: _action == DateEditAction.shift ? _editShift : null,
tooltip: l10n.changeTooltip,
),
),
],
);
final extractFromTitleTile = RadioListTile<DateEditAction>(
value: DateEditAction.extractFromTitle,
groupValue: _action,
onChanged: _updateAction,
title: _tileText(l10n.editEntryDateDialogExtractFromTitle),
);
final clearTile = RadioListTile<DateEditAction>(
value: DateEditAction.clear,
groupValue: _action,
onChanged: _updateAction,
title: _tileText(l10n.editEntryDateDialogClear),
);
final animationDuration = context.select<DurationsData, Duration>((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, title: l10n.editEntryDateDialogTitle,
scrollableContent: [ scrollableContent: [
setTile,
shiftTile,
extractFromTitleTile,
clearTile,
Padding( Padding(
padding: const EdgeInsets.only(bottom: 1), padding: const EdgeInsets.only(left: 16, top: 8, right: 16),
child: ExpansionPanelList( child: DropdownButton<DateEditAction>(
expansionCallback: (index, isExpanded) { items: DateEditAction.values
setState(() => _showOptions = !isExpanded); .map((v) => DropdownMenuItem<DateEditAction>(
}, value: v,
animationDuration: animationDuration, child: Text(_actionText(context, v)),
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(), .toList(),
value: _action,
onChanged: (v) => setState(() => _action = v!),
isExpanded: true,
dropdownColor: dropdownColor,
), ),
isExpanded: _showOptions,
canTapOnHeader: true,
backgroundColor: Theme.of(context).dialogBackgroundColor,
), ),
AnimatedSwitcher(
duration: context.read<DurationsData>().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),
], ],
), ),
), ),
@ -176,122 +110,72 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
child: Text(l10n.applyButtonLabel), child: Text(l10n.applyButtonLabel),
), ),
], ],
),
); );
}, }),
), ),
); );
} }
String _formatShiftDuration() { Widget _formTransitionBuilder(Widget child, Animation<double> animation) => FadeTransition(
final abs = _shiftMinutes.abs(); opacity: animation,
final h = abs ~/ 60; child: SizeTransition(
final m = abs % 60; sizeFactor: animation,
return '${_shiftMinutes.isNegative ? '-' : '+'}$h:${m.toString().padLeft(2, '0')}'; axisAlignment: -1,
} child: child,
String _fieldTitle(MetadataField field) {
switch (field) {
case MetadataField.exifDate:
return 'Exif date';
case MetadataField.exifDateOriginal:
return 'Exif original date';
case MetadataField.exifDateDigitized:
return 'Exif digitized date';
case MetadataField.exifGpsDate:
return 'Exif GPS date';
}
}
Future<void> _editDate() async {
final _date = await showDatePicker(
context: context,
initialDate: _dateTime,
firstDate: DateTime(0),
lastDate: DateTime.now(),
confirmText: context.l10n.nextButtonLabel,
);
if (_date == null) return;
final _time = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(_dateTime),
);
if (_time == null) return;
setState(() => _dateTime = DateTime(
_date.year,
_date.month,
_date.day,
_time.hour,
_time.minute,
));
}
void _editShift() async {
final picked = await showDialog<int>(
context: context,
builder: (context) => TimeShiftDialog(
initialShiftMinutes: _shiftMinutes,
), ),
); );
if (picked == null) return;
setState(() => _shiftMinutes = picked); Widget _buildSetCustomContent(BuildContext context) {
final l10n = context.l10n;
final locale = l10n.localeName;
final use24hour = context.select<MediaQueryData, bool>((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,
),
],
),
);
} }
void _submit(BuildContext context) { Widget _buildCopyFieldContent(BuildContext context) {
late DateModifier modifier; return Padding(
switch (_action) { padding: const EdgeInsets.only(left: 16, top: 0, right: 16),
case DateEditAction.set: child: DropdownButton<DateFieldSource>(
modifier = DateModifier(_action, _fields, dateTime: _dateTime); items: DateFieldSource.values
break; .map((v) => DropdownMenuItem<DateFieldSource>(
case DateEditAction.shift: value: v,
modifier = DateModifier(_action, _fields, shiftMinutes: _shiftMinutes); child: Text(_setSourceText(context, v)),
break; ))
case DateEditAction.extractFromTitle: .toList(),
case DateEditAction.clear: selectedItemBuilder: (context) => DateFieldSource.values
modifier = DateModifier(_action, _fields); .map((v) => DropdownMenuItem<DateFieldSource>(
break; value: v,
} child: Text(
Navigator.pop(context, modifier); _setSourceText(context, v),
} softWrap: false,
overflow: TextOverflow.fade,
),
))
.toList(),
value: _copyFieldSource,
onChanged: (v) => setState(() => _copyFieldSource = v!),
isExpanded: true,
dropdownColor: dropdownColor,
),
);
} }
class TimeShiftDialog extends StatefulWidget { Widget _buildShiftContent(BuildContext context) {
final int initialShiftMinutes;
const TimeShiftDialog({
Key? key,
required this.initialShiftMinutes,
}) : super(key: key);
@override
_TimeShiftDialogState createState() => _TimeShiftDialogState();
}
class _TimeShiftDialogState extends State<TimeShiftDialog> {
late ValueNotifier<int> _hour, _minute;
late ValueNotifier<String> _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); const textStyle = TextStyle(fontSize: 34);
return AvesDialog( return Center(
scrollableContent: [
Center(
child: Padding(
padding: const EdgeInsets.only(top: 8),
child: Table( child: Table(
children: [ children: [
TableRow( TableRow(
@ -304,16 +188,16 @@ class _TimeShiftDialogState extends State<TimeShiftDialog> {
), ),
TableRow( TableRow(
children: [ children: [
_Wheel( WheelSelector(
valueNotifier: _sign, valueNotifier: _shiftSign,
values: const ['+', '-'], values: const ['+', '-'],
textStyle: textStyle, textStyle: textStyle,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
Align( Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: _Wheel( child: WheelSelector(
valueNotifier: _hour, valueNotifier: _shiftHour,
values: List.generate(24, (i) => i), values: List.generate(24, (i) => i),
textStyle: textStyle, textStyle: textStyle,
textAlign: TextAlign.end, textAlign: TextAlign.end,
@ -328,8 +212,8 @@ class _TimeShiftDialogState extends State<TimeShiftDialog> {
), ),
Align( Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: _Wheel( child: WheelSelector(
valueNotifier: _minute, valueNotifier: _shiftMinute,
values: List.generate(60, (i) => i), values: List.generate(60, (i) => i),
textStyle: textStyle, textStyle: textStyle,
textAlign: TextAlign.end, textAlign: TextAlign.end,
@ -341,101 +225,133 @@ class _TimeShiftDialogState extends State<TimeShiftDialog> {
defaultColumnWidth: const IntrinsicColumnWidth(), defaultColumnWidth: const IntrinsicColumnWidth(),
defaultVerticalAlignment: TableCellVerticalAlignment.middle, 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<T> extends StatefulWidget {
final ValueNotifier<T> valueNotifier;
final List<T> 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<T>();
}
class _WheelState<T> extends State<_Wheel<T>> {
late final ScrollController _controller;
static const itemSize = Size(40, 40);
ValueNotifier<T> get valueNotifier => widget.valueNotifier;
List<T> get values => widget.values;
@override
void initState() {
super.initState();
var indexOf = values.indexOf(valueNotifier.value);
_controller = FixedExtentScrollController(
initialItem: indexOf,
); );
} }
@override Widget _buildDestinationFields(BuildContext context) {
Widget build(BuildContext context) {
final background = Theme.of(context).dialogBackgroundColor;
final foreground = DefaultTextStyle.of(context).style.color!;
return Padding( return Padding(
padding: const EdgeInsets.all(8), // small padding as a workaround to show dialog action divider
child: SizedBox( padding: const EdgeInsets.only(bottom: 1),
width: itemSize.width, child: ExpansionPanelList(
height: itemSize.height * 3, expansionCallback: (index, isExpanded) {
child: ShaderMask( setState(() => _showOptions = !isExpanded);
shaderCallback: LinearGradient( },
begin: Alignment.topCenter, animationDuration: context.read<DurationsData>().expansionTileAnimation,
end: Alignment.bottomCenter, expandedHeaderPadding: EdgeInsets.zero,
colors: [ elevation: 0,
background, children: [
foreground, ExpansionPanel(
foreground, headerBuilder: (context, isExpanded) => ListTile(
background, title: Text(context.l10n.editEntryDateDialogTargetFieldsHeader),
],
).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,
), ),
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(), .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) {
switch (field) {
case MetadataField.exifDate:
return 'Exif date';
case MetadataField.exifDateOriginal:
return 'Exif original date';
case MetadataField.exifDateDigitized:
return 'Exif digitized date';
case MetadataField.exifGpsDate:
return 'Exif GPS date';
case MetadataField.xmpCreateDate:
return 'XMP xmp:CreateDate';
}
}
Future<void> _editDate() async {
final _date = await showDatePicker(
context: context,
initialDate: _setDateTime,
firstDate: DateTime(0),
lastDate: DateTime.now(),
confirmText: context.l10n.nextButtonLabel,
);
if (_date == null) return;
final _time = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(_setDateTime),
);
if (_time == null) return;
setState(() => _setDateTime = DateTime(
_date.year,
_date.month,
_date.day,
_time.hour,
_time.minute,
));
}
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.setCustom:
return DateModifier.setCustom(const {}, _setDateTime);
case DateEditAction.copyField:
return DateModifier.copyField(const {}, _copyFieldSource);
case DateEditAction.extractFromTitle:
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);
}
}
void _submit(BuildContext context) => Navigator.pop(context, _getModifier());
} }

View file

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

View file

@ -122,7 +122,7 @@ class _TagEditorPageState extends State<TagEditorPage> {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(AIcons.tagOff, color: untaggedColor), const Icon(AIcons.tagUntagged, color: untaggedColor),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
l10n.filterTagEmptyLabel, l10n.filterTagEmptyLabel,

View file

@ -69,7 +69,7 @@ class _RemoveEntryMetadataDialogState extends State<RemoveEntryMetadataDialog> {
), ),
isExpanded: _showMore, isExpanded: _showMore,
canTapOnHeader: true, canTapOnHeader: true,
backgroundColor: Theme.of(context).dialogBackgroundColor, backgroundColor: Colors.transparent,
), ),
], ],
), ),

View file

@ -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/aves_filter_chip.dart';
import 'package:aves/widgets/common/identity/empty.dart'; import 'package:aves/widgets/common/identity/empty.dart';
import 'package:aves/widgets/common/providers/selection_provider.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/dialogs/filter_editors/create_album_dialog.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart';
import 'package:aves/widgets/filter_grids/common/action_delegates/album_set.dart'; import 'package:aves/widgets/filter_grids/common/action_delegates/album_set.dart';
@ -141,10 +142,12 @@ class _AlbumPickAppBar extends StatelessWidget {
return SliverAppBar( return SliverAppBar(
leading: const BackButton(), leading: const BackButton(),
title: SourceStateAwareAppBarTitle( title: SliverAppBarTitleWrapper(
child: SourceStateAwareAppBarTitle(
title: Text(title()), title: Text(title()),
source: source, source: source,
), ),
),
bottom: _AlbumQueryBar( bottom: _AlbumQueryBar(
queryNotifier: queryNotifier, queryNotifier: queryNotifier,
), ),

View file

@ -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/app_bar_title.dart';
import 'package:aves/widgets/common/basic/menu.dart'; import 'package:aves/widgets/common/basic/menu.dart';
import 'package:aves/widgets/common/extensions/build_context.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/filter_grids/common/action_delegates/chip_set.dart';
import 'package:aves/widgets/search/search_delegate.dart'; import 'package:aves/widgets/search/search_delegate.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -74,7 +75,9 @@ class _FilterGridAppBarState<T extends CollectionFilter> extends State<FilterGri
_isSelectingNotifier.value = isSelecting; _isSelectingNotifier.value = isSelecting;
return SliverAppBar( return SliverAppBar(
leading: appMode.hasDrawer ? _buildAppBarLeading(isSelecting) : null, leading: appMode.hasDrawer ? _buildAppBarLeading(isSelecting) : null,
title: _buildAppBarTitle(isSelecting), title: SliverAppBarTitleWrapper(
child: _buildAppBarTitle(isSelecting),
),
actions: _buildActions(appMode, selection), actions: _buildActions(appMode, selection),
titleSpacing: 0, titleSpacing: 0,
floating: true, floating: true,
@ -103,7 +106,7 @@ class _FilterGridAppBarState<T extends CollectionFilter> extends State<FilterGri
); );
} }
Widget? _buildAppBarTitle(bool isSelecting) { Widget _buildAppBarTitle(bool isSelecting) {
if (isSelecting) { if (isSelecting) {
return Selector<Selection<FilterGridItem<T>>, int>( return Selector<Selection<FilterGridItem<T>>, int>(
selector: (context, selection) => selection.selectedItems.length, selector: (context, selection) => selection.selectedItems.length,

View file

@ -2,11 +2,13 @@ import 'dart:async';
import 'package:aves/app_mode.dart'; import 'package:aves/app_mode.dart';
import 'package:aves/model/entry.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/filters/filters.dart';
import 'package:aves/model/settings/home_page.dart'; import 'package:aves/model/settings/home_page.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.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/analysis_service.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/services/global_search.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_delegate.dart';
import 'package:aves/widgets/search/search_page.dart'; import 'package:aves/widgets/search/search_page.dart';
import 'package:aves/widgets/viewer/entry_viewer_page.dart'; import 'package:aves/widgets/viewer/entry_viewer_page.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
@ -114,7 +117,7 @@ class _HomePageState extends State<HomePage> {
context.read<ValueNotifier<AppMode>>().value = appMode; context.read<ValueNotifier<AppMode>>().value = appMode;
unawaited(reportService.setCustomKey('app_mode', appMode.toString())); 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'); debugPrint('Storage check complete in ${stopwatch.elapsed.inMilliseconds}ms');
unawaited(GlobalSearch.registerCallback()); unawaited(GlobalSearch.registerCallback());
unawaited(AnalysisService.registerCallback()); unawaited(AnalysisService.registerCallback());
@ -127,11 +130,13 @@ class _HomePageState extends State<HomePage> {
// e.g. when opening the viewer in `view` mode should replace a viewer in `main` mode // e.g. when opening the viewer in `view` mode should replace a viewer in `main` mode
unawaited(Navigator.pushAndRemoveUntil( unawaited(Navigator.pushAndRemoveUntil(
context, context,
_getRedirectRoute(appMode), await _getRedirectRoute(appMode),
(route) => false, (route) => false,
)); ));
} }
bool _isViewerSourceable(AvesEntry viewerEntry) => viewerEntry.directory != null && !settings.hiddenFilters.any((filter) => filter.test(viewerEntry));
Future<AvesEntry?> _initViewerEntry({required String uri, required String? mimeType}) async { Future<AvesEntry?> _initViewerEntry({required String uri, required String? mimeType}) async {
if (uri.startsWith('/')) { if (uri.startsWith('/')) {
// convert this file path to a proper URI // convert this file path to a proper URI
@ -145,13 +150,50 @@ class _HomePageState extends State<HomePage> {
return entry; return entry;
} }
Route _getRedirectRoute(AppMode appMode) { Future<Route> _getRedirectRoute(AppMode appMode) async {
if (appMode == AppMode.view) { if (appMode == AppMode.view) {
AvesEntry viewerEntry = _viewerEntry!;
CollectionLens? collection;
final source = context.read<CollectionSource>();
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( return DirectMaterialPageRoute(
settings: const RouteSettings(name: EntryViewerPage.routeName), settings: const RouteSettings(name: EntryViewerPage.routeName),
builder: (_) => EntryViewerPage( builder: (_) {
initialEntry: _viewerEntry!, return EntryViewerPage(
), collection: collection,
initialEntry: viewerEntry,
);
},
); );
} }

View file

@ -4,6 +4,7 @@ import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/location.dart';
import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/filters/query.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/tag.dart';
import 'package:aves/model/filters/type.dart'; import 'package:aves/model/filters/type.dart';
import 'package:aves/model/settings/settings.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(),
),
], ],
); );
}); });

View file

@ -51,6 +51,8 @@ class LocaleTile extends StatelessWidget {
return 'Deutsch'; return 'Deutsch';
case 'en': case 'en':
return 'English'; return 'English';
case 'es':
return 'Español (México)';
case 'fr': case 'fr':
return 'Français'; return 'Français';
case 'ko': case 'ko':

View file

@ -43,7 +43,7 @@ class _DrawerAlbumTabState extends State<DrawerAlbumTab> {
onPressed: () { onPressed: () {
setState(() => widget.items.remove(album)); setState(() => widget.items.remove(album));
}, },
tooltip: context.l10n.removeTooltip, tooltip: context.l10n.actionRemove,
), ),
); );
}, },

View file

@ -154,7 +154,7 @@ class _HiddenPaths extends StatelessWidget {
onPressed: () { onPressed: () {
context.read<CollectionSource>().changeFilterVisibility({pathFilter}, true); context.read<CollectionSource>().changeFilterVisibility({pathFilter}, true);
}, },
tooltip: context.l10n.removeTooltip, tooltip: context.l10n.actionRemove,
), ),
)), )),
], ],

View file

@ -20,11 +20,6 @@ class ThumbnailsSection extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final currentShowThumbnailLocation = context.select<Settings, bool>((s) => s.showThumbnailLocation);
final currentShowThumbnailMotionPhoto = context.select<Settings, bool>((s) => s.showThumbnailMotionPhoto);
final currentShowThumbnailRaw = context.select<Settings, bool>((s) => s.showThumbnailRaw);
final currentShowThumbnailVideoDuration = context.select<Settings, bool>((s) => s.showThumbnailVideoDuration);
final iconSize = IconTheme.of(context).size! * MediaQuery.textScaleFactorOf(context); final iconSize = IconTheme.of(context).size! * MediaQuery.textScaleFactorOf(context);
double opacityFor(bool enabled) => enabled ? 1 : .2; double opacityFor(bool enabled) => enabled ? 1 : .2;
@ -38,14 +33,39 @@ class ThumbnailsSection extends StatelessWidget {
showHighlight: false, showHighlight: false,
children: [ children: [
const CollectionActionsTile(), const CollectionActionsTile(),
SwitchListTile( Selector<Settings, bool>(
value: currentShowThumbnailLocation, 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,
),
),
),
],
),
),
),
Selector<Settings, bool>(
selector: (context, s) => s.showThumbnailLocation,
builder: (context, current, child) => SwitchListTile(
value: current,
onChanged: (v) => settings.showThumbnailLocation = v, onChanged: (v) => settings.showThumbnailLocation = v,
title: Row( title: Row(
children: [ children: [
Expanded(child: Text(context.l10n.settingsThumbnailShowLocationIcon)), Expanded(child: Text(context.l10n.settingsThumbnailShowLocationIcon)),
AnimatedOpacity( AnimatedOpacity(
opacity: opacityFor(currentShowThumbnailLocation), opacity: opacityFor(current),
duration: Durations.toggleableTransitionAnimation, duration: Durations.toggleableTransitionAnimation,
child: Icon( child: Icon(
AIcons.location, AIcons.location,
@ -55,14 +75,17 @@ class ThumbnailsSection extends StatelessWidget {
], ],
), ),
), ),
SwitchListTile( ),
value: currentShowThumbnailMotionPhoto, Selector<Settings, bool>(
selector: (context, s) => s.showThumbnailMotionPhoto,
builder: (context, current, child) => SwitchListTile(
value: current,
onChanged: (v) => settings.showThumbnailMotionPhoto = v, onChanged: (v) => settings.showThumbnailMotionPhoto = v,
title: Row( title: Row(
children: [ children: [
Expanded(child: Text(context.l10n.settingsThumbnailShowMotionPhotoIcon)), Expanded(child: Text(context.l10n.settingsThumbnailShowMotionPhotoIcon)),
AnimatedOpacity( AnimatedOpacity(
opacity: opacityFor(currentShowThumbnailMotionPhoto), opacity: opacityFor(current),
duration: Durations.toggleableTransitionAnimation, duration: Durations.toggleableTransitionAnimation,
child: Padding( child: Padding(
padding: EdgeInsets.symmetric(horizontal: iconSize * (1 - MotionPhotoIcon.scale) / 2), padding: EdgeInsets.symmetric(horizontal: iconSize * (1 - MotionPhotoIcon.scale) / 2),
@ -75,14 +98,37 @@ class ThumbnailsSection extends StatelessWidget {
], ],
), ),
), ),
SwitchListTile( ),
value: currentShowThumbnailRaw, Selector<Settings, bool>(
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<Settings, bool>(
selector: (context, s) => s.showThumbnailRaw,
builder: (context, current, child) => SwitchListTile(
value: current,
onChanged: (v) => settings.showThumbnailRaw = v, onChanged: (v) => settings.showThumbnailRaw = v,
title: Row( title: Row(
children: [ children: [
Expanded(child: Text(context.l10n.settingsThumbnailShowRawIcon)), Expanded(child: Text(context.l10n.settingsThumbnailShowRawIcon)),
AnimatedOpacity( AnimatedOpacity(
opacity: opacityFor(currentShowThumbnailRaw), opacity: opacityFor(current),
duration: Durations.toggleableTransitionAnimation, duration: Durations.toggleableTransitionAnimation,
child: Icon( child: Icon(
AIcons.raw, AIcons.raw,
@ -92,11 +138,15 @@ class ThumbnailsSection extends StatelessWidget {
], ],
), ),
), ),
SwitchListTile( ),
value: currentShowThumbnailVideoDuration, Selector<Settings, bool>(
selector: (context, s) => s.showThumbnailVideoDuration,
builder: (context, current, child) => SwitchListTile(
value: current,
onChanged: (v) => settings.showThumbnailVideoDuration = v, onChanged: (v) => settings.showThumbnailVideoDuration = v,
title: Text(context.l10n.settingsThumbnailShowVideoDuration), title: Text(context.l10n.settingsThumbnailShowVideoDuration),
), ),
),
], ],
); );
} }

View file

@ -2,15 +2,16 @@ import 'package:aves/model/filters/filters.dart';
import 'package:aves/utils/color_utils.dart'; import 'package:aves/utils/color_utils.dart';
import 'package:aves/utils/constants.dart'; import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:percent_indicator/linear_percent_indicator.dart'; import 'package:percent_indicator/linear_percent_indicator.dart';
class FilterTable extends StatelessWidget { class FilterTable<T extends Comparable> extends StatelessWidget {
final int totalEntryCount; final int totalEntryCount;
final Map<String, int> entryCountMap; final Map<T, int> entryCountMap;
final CollectionFilter Function(String key) filterBuilder; final CollectionFilter Function(T key) filterBuilder;
final bool sortByCount;
final int? maxRowCount;
final FilterCallback onFilterSelection; final FilterCallback onFilterSelection;
const FilterTable({ const FilterTable({
@ -18,6 +19,8 @@ class FilterTable extends StatelessWidget {
required this.totalEntryCount, required this.totalEntryCount,
required this.entryCountMap, required this.entryCountMap,
required this.filterBuilder, required this.filterBuilder,
required this.sortByCount,
required this.maxRowCount,
required this.onFilterSelection, required this.onFilterSelection,
}) : super(key: key); }) : super(key: key);
@ -27,11 +30,13 @@ class FilterTable extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final sortedEntries = entryCountMap.entries.toList() final sortedEntries = entryCountMap.entries.toList();
..sort((kv1, kv2) { if (sortByCount) {
sortedEntries.sort((kv1, kv2) {
final c = kv2.value.compareTo(kv1.value); 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 textScaleFactor = MediaQuery.textScaleFactorOf(context);
final lineHeight = 16 * textScaleFactor; final lineHeight = 16 * textScaleFactor;
@ -41,8 +46,9 @@ class FilterTable extends StatelessWidget {
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final showPercentIndicator = constraints.maxWidth - (chipWidth + countWidth) > percentIndicatorMinWidth; final showPercentIndicator = constraints.maxWidth - (chipWidth + countWidth) > percentIndicatorMinWidth;
final displayedEntries = maxRowCount != null ? sortedEntries.take(maxRowCount!) : sortedEntries;
return Table( return Table(
children: sortedEntries.take(5).map((kv) { children: displayedEntries.map((kv) {
final filter = filterBuilder(kv.key); final filter = filterBuilder(kv.key);
final label = filter.getLabel(context); final label = filter.getLabel(context);
final count = kv.value; final count = kv.value;

View file

@ -4,6 +4,7 @@ import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/location.dart';
import 'package:aves/model/filters/mime.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/tag.dart';
import 'package:aves/model/settings/accessibility_animations.dart'; import 'package:aves/model/settings/accessibility_animations.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
@ -33,6 +34,7 @@ class StatsPage extends StatelessWidget {
final CollectionLens? parentCollection; final CollectionLens? parentCollection;
final Set<AvesEntry> entries; final Set<AvesEntry> entries;
final Map<String, int> entryCountPerCountry = {}, entryCountPerPlace = {}, entryCountPerTag = {}; final Map<String, int> entryCountPerCountry = {}, entryCountPerPlace = {}, entryCountPerTag = {};
final Map<int, int> entryCountPerRating = Map.fromEntries(List.generate(7, (i) => MapEntry(5 - i, 0)));
static const mimeDonutMinWidth = 124.0; static const mimeDonutMinWidth = 124.0;
@ -55,9 +57,13 @@ class StatsPage extends StatelessWidget {
entryCountPerPlace[place] = (entryCountPerPlace[place] ?? 0) + 1; entryCountPerPlace[place] = (entryCountPerPlace[place] ?? 0) + 1;
} }
} }
entry.tags.forEach((tag) { entry.tags.forEach((tag) {
entryCountPerTag[tag] = (entryCountPerTag[tag] ?? 0) + 1; 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( child = ListView(
children: [ children: [
mimeDonuts, mimeDonuts,
locationIndicator, locationIndicator,
..._buildTopFilters(context, context.l10n.statsTopCountries, entryCountPerCountry, (s) => LocationFilter(LocationLevel.country, s)), ..._buildFilterSection<String>(context, context.l10n.statsTopCountries, entryCountPerCountry, (v) => LocationFilter(LocationLevel.country, v)),
..._buildTopFilters(context, context.l10n.statsTopPlaces, entryCountPerPlace, (s) => LocationFilter(LocationLevel.place, s)), ..._buildFilterSection<String>(context, context.l10n.statsTopPlaces, entryCountPerPlace, (v) => LocationFilter(LocationLevel.place, v)),
..._buildTopFilters(context, context.l10n.statsTopTags, entryCountPerTag, (s) => TagFilter(s)), ..._buildFilterSection<String>(context, context.l10n.statsTopTags, entryCountPerTag, (v) => TagFilter(v)),
if (showRatings) ..._buildFilterSection<int>(context, context.l10n.searchSectionRating, entryCountPerRating, (v) => RatingFilter(v), sortByCount: false, maxRowCount: null),
], ],
); );
} }
@ -243,12 +251,14 @@ class StatsPage extends StatelessWidget {
}); });
} }
List<Widget> _buildTopFilters( List<Widget> _buildFilterSection<T extends Comparable>(
BuildContext context, BuildContext context,
String title, String title,
Map<String, int> entryCountMap, Map<T, int> entryCountMap,
CollectionFilter Function(String key) filterBuilder, CollectionFilter Function(T key) filterBuilder, {
) { bool sortByCount = true,
int? maxRowCount = 5,
}) {
if (entryCountMap.isEmpty) return []; if (entryCountMap.isEmpty) return [];
return [ return [
@ -263,6 +273,8 @@ class StatsPage extends StatelessWidget {
totalEntryCount: entries.length, totalEntryCount: entries.length,
entryCountMap: entryCountMap, entryCountMap: entryCountMap,
filterBuilder: filterBuilder, filterBuilder: filterBuilder,
sortByCount: sortByCount,
maxRowCount: maxRowCount,
onFilterSelection: (filter) => _onFilterSelection(context, filter), onFilterSelection: (filter) => _onFilterSelection(context, filter),
), ),
]; ];

View file

@ -6,6 +6,7 @@ import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/actions/move_type.dart'; import 'package:aves/model/actions/move_type.dart';
import 'package:aves/model/device.dart'; import 'package:aves/model/device.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/entry_metadata_edition.dart';
import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/album.dart';
import 'package:aves/model/highlight.dart'; import 'package:aves/model/highlight.dart';
import 'package:aves/model/source/collection_lens.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/entry_editors/rename_entry_dialog.dart';
import 'package:aves/widgets/dialogs/export_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/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/debug/debug_page.dart';
import 'package:aves/widgets/viewer/info/notifications.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:aves/widgets/viewer/source_viewer_page.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';
class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin, SingleEntryEditorMixin {
void onActionSelected(BuildContext context, AvesEntry entry, EntryAction action) { @override
final AvesEntry entry;
EntryActionDelegate(this.entry);
void onActionSelected(BuildContext context, EntryAction action) {
switch (action) { switch (action) {
case EntryAction.addShortcut: case EntryAction.addShortcut:
_addShortcut(context, entry); _addShortcut(context);
break; break;
case EntryAction.copyToClipboard: case EntryAction.copyToClipboard:
androidAppService.copyToClipboard(entry.uri, entry.bestTitle).then((success) { androidAppService.copyToClipboard(entry.uri, entry.bestTitle).then((success) {
@ -45,10 +52,10 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
}); });
break; break;
case EntryAction.delete: case EntryAction.delete:
_delete(context, entry); _delete(context);
break; break;
case EntryAction.export: case EntryAction.export:
_export(context, entry); _export(context);
break; break;
case EntryAction.info: case EntryAction.info:
ShowInfoNotification().dispatch(context); ShowInfoNotification().dispatch(context);
@ -57,7 +64,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
EntryPrinter(entry).print(context); EntryPrinter(entry).print(context);
break; break;
case EntryAction.rename: case EntryAction.rename:
_rename(context, entry); _rename(context);
break; break;
case EntryAction.share: case EntryAction.share:
androidAppService.shareEntries({entry}).then((success) { androidAppService.shareEntries({entry}).then((success) {
@ -69,17 +76,17 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
break; break;
// raster // raster
case EntryAction.rotateCCW: case EntryAction.rotateCCW:
_rotate(context, entry, clockwise: false); _rotate(context, clockwise: false);
break; break;
case EntryAction.rotateCW: case EntryAction.rotateCW:
_rotate(context, entry, clockwise: true); _rotate(context, clockwise: true);
break; break;
case EntryAction.flip: case EntryAction.flip:
_flip(context, entry); _flip(context);
break; break;
// vector // vector
case EntryAction.viewSource: case EntryAction.viewSource:
_goToSourceViewer(context, entry); _goToSourceViewer(context);
break; break;
// external // external
case EntryAction.edit: case EntryAction.edit:
@ -108,12 +115,12 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
break; break;
// debug // debug
case EntryAction.debug: case EntryAction.debug:
_goToDebug(context, entry); _goToDebug(context);
break; break;
} }
} }
Future<void> _addShortcut(BuildContext context, AvesEntry entry) async { Future<void> _addShortcut(BuildContext context) async {
final result = await showDialog<Tuple2<AvesEntry?, String>>( final result = await showDialog<Tuple2<AvesEntry?, String>>(
context: context, context: context,
builder: (context) => AddShortcutDialog( builder: (context) => AddShortcutDialog(
@ -131,18 +138,12 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
} }
} }
Future<void> _flip(BuildContext context, AvesEntry entry) async { Future<void> _flip(BuildContext context) async {
if (!await checkStoragePermission(context, {entry})) return; await edit(context, entry.flip);
final dataTypes = await entry.flip(persist: _isMainMode(context));
if (dataTypes.isEmpty) showFeedback(context, context.l10n.genericFailureFeedback);
} }
Future<void> _rotate(BuildContext context, AvesEntry entry, {required bool clockwise}) async { Future<void> _rotate(BuildContext context, {required bool clockwise}) async {
if (!await checkStoragePermission(context, {entry})) return; await edit(context, () => entry.rotate(clockwise: clockwise));
final dataTypes = await entry.rotate(clockwise: clockwise, persist: _isMainMode(context));
if (dataTypes.isEmpty) showFeedback(context, context.l10n.genericFailureFeedback);
} }
Future<void> _rotateScreen(BuildContext context) async { Future<void> _rotateScreen(BuildContext context) async {
@ -156,7 +157,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
} }
} }
Future<void> _delete(BuildContext context, AvesEntry entry) async { Future<void> _delete(BuildContext context) async {
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (context) { builder: (context) {
@ -190,7 +191,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
} }
} }
Future<void> _export(BuildContext context, AvesEntry entry) async { Future<void> _export(BuildContext context) async {
final source = context.read<CollectionSource>(); final source = context.read<CollectionSource>();
if (!source.initialized) { if (!source.initialized) {
await source.init(); await source.init();
@ -291,7 +292,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
); );
} }
Future<void> _rename(BuildContext context, AvesEntry entry) async { Future<void> _rename(BuildContext context) async {
final newName = await showDialog<String>( final newName = await showDialog<String>(
context: context, context: context,
builder: (context) => RenameEntryDialog(entry: entry), builder: (context) => RenameEntryDialog(entry: entry),
@ -311,7 +312,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
bool _isMainMode(BuildContext context) => context.read<ValueNotifier<AppMode>>().value == AppMode.main; bool _isMainMode(BuildContext context) => context.read<ValueNotifier<AppMode>>().value == AppMode.main;
void _goToSourceViewer(BuildContext context, AvesEntry entry) { void _goToSourceViewer(BuildContext context) {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
@ -323,7 +324,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
); );
} }
void _goToDebug(BuildContext context, AvesEntry entry) { void _goToDebug(BuildContext context) {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(

View file

@ -1,22 +1,18 @@
import 'dart:async'; import 'dart:async';
import 'package:aves/app_mode.dart';
import 'package:aves/model/actions/entry_info_actions.dart'; import 'package:aves/model/actions/entry_info_actions.dart';
import 'package:aves/model/actions/events.dart'; import 'package:aves/model/actions/events.dart';
import 'package:aves/model/entry.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/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/entry_editor.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/feedback.dart';
import 'package:aves/widgets/common/action_mixins/permission_aware.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:aves/widgets/viewer/embedded/notifications.dart';
import 'package:flutter/material.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 AvesEntry entry;
final StreamController<ActionEvent<EntryInfoAction>> _eventStreamController = StreamController<ActionEvent<EntryInfoAction>>.broadcast(); final StreamController<ActionEvent<EntryInfoAction>> _eventStreamController = StreamController<ActionEvent<EntryInfoAction>>.broadcast();
@ -29,6 +25,7 @@ class EntryInfoActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAw
switch (action) { switch (action) {
// general // general
case EntryInfoAction.editDate: case EntryInfoAction.editDate:
case EntryInfoAction.editRating:
case EntryInfoAction.editTags: case EntryInfoAction.editTags:
case EntryInfoAction.removeMetadata: case EntryInfoAction.removeMetadata:
return true; return true;
@ -43,6 +40,8 @@ class EntryInfoActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAw
// general // general
case EntryInfoAction.editDate: case EntryInfoAction.editDate:
return entry.canEditDate; return entry.canEditDate;
case EntryInfoAction.editRating:
return entry.canEditRating;
case EntryInfoAction.editTags: case EntryInfoAction.editTags:
return entry.canEditTags; return entry.canEditTags;
case EntryInfoAction.removeMetadata: case EntryInfoAction.removeMetadata:
@ -60,6 +59,9 @@ class EntryInfoActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAw
case EntryInfoAction.editDate: case EntryInfoAction.editDate:
await _editDate(context); await _editDate(context);
break; break;
case EntryInfoAction.editRating:
await _editRating(context);
break;
case EntryInfoAction.editTags: case EntryInfoAction.editTags:
await _editTags(context); await _editTags(context);
break; break;
@ -74,43 +76,18 @@ class EntryInfoActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAw
_eventStreamController.add(ActionEndedEvent(action)); _eventStreamController.add(ActionEndedEvent(action));
} }
bool _isMainMode(BuildContext context) => context.read<ValueNotifier<AppMode>>().value == AppMode.main;
Future<void> _edit(BuildContext context, Future<Set<EntryDataType>> 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<CollectionSource?>();
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<void> _editDate(BuildContext context) async { Future<void> _editDate(BuildContext context) async {
final modifier = await selectDateModifier(context, {entry}); final modifier = await selectDateModifier(context, {entry});
if (modifier == null) return; if (modifier == null) return;
await _edit(context, () => entry.editDate(modifier)); await edit(context, () => entry.editDate(modifier));
}
Future<void> _editRating(BuildContext context) async {
final rating = await selectRating(context, {entry});
if (rating == null) return;
await edit(context, () => entry.editRating(rating));
} }
Future<void> _editTags(BuildContext context) async { Future<void> _editTags(BuildContext context) async {
@ -121,13 +98,13 @@ class EntryInfoActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAw
final currentTags = entry.tags; final currentTags = entry.tags;
if (newTags.length == currentTags.length && newTags.every(currentTags.contains)) return; if (newTags.length == currentTags.length && newTags.every(currentTags.contains)) return;
await _edit(context, () => entry.editTags(newTags)); await edit(context, () => entry.editTags(newTags));
} }
Future<void> _removeMetadata(BuildContext context) async { Future<void> _removeMetadata(BuildContext context) async {
final types = await selectMetadataToRemove(context, {entry}); final types = await selectMetadataToRemove(context, {entry});
if (types == null) return; if (types == null) return;
await _edit(context, () => entry.removeMetadata(types)); await edit(context, () => entry.removeMetadata(types));
} }
} }

View file

@ -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<ValueNotifier<AppMode>>().value == AppMode.main;
Future<void> edit(BuildContext context, Future<Set<EntryDataType>> 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<CollectionSource?>();
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();
}
}

View file

@ -123,6 +123,7 @@ class _DbTabState extends State<DbTab> {
'longitude': '${data.longitude}', 'longitude': '${data.longitude}',
'xmpSubjects': data.xmpSubjects ?? '', 'xmpSubjects': data.xmpSubjects ?? '',
'xmpTitleDescription': data.xmpTitleDescription ?? '', 'xmpTitleDescription': data.xmpTitleDescription ?? '',
'rating': '${data.rating}',
}, },
), ),
], ],

View file

@ -4,16 +4,16 @@ import 'package:aves/model/favourites.dart';
import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/mime.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/tag.dart';
import 'package:aves/model/filters/type.dart'; import 'package:aves/model/filters/type.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/theme/format.dart'; import 'package:aves/theme/format.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/file_utils.dart'; import 'package:aves/utils/file_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/common/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/common.dart';
import 'package:aves/widgets/viewer/info/entry_info_action_delegate.dart';
import 'package:aves/widgets/viewer/info/owner.dart'; import 'package:aves/widgets/viewer/info/owner.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -23,7 +23,7 @@ class BasicSection extends StatelessWidget {
final AvesEntry entry; final AvesEntry entry;
final CollectionLens? collection; final CollectionLens? collection;
final EntryInfoActionDelegate actionDelegate; final EntryInfoActionDelegate actionDelegate;
final ValueNotifier<bool> isEditingTagNotifier; final ValueNotifier<EntryInfoAction?> isEditingMetadataNotifier;
final FilterCallback onFilter; final FilterCallback onFilter;
const BasicSection({ const BasicSection({
@ -31,7 +31,7 @@ class BasicSection extends StatelessWidget {
required this.entry, required this.entry,
this.collection, this.collection,
required this.actionDelegate, required this.actionDelegate,
required this.isEditingTagNotifier, required this.isEditingMetadataNotifier,
required this.onFilter, required this.onFilter,
}) : super(key: key); }) : super(key: key);
@ -74,10 +74,9 @@ class BasicSection extends StatelessWidget {
if (path != null) l10n.viewerInfoLabelPath: path, if (path != null) l10n.viewerInfoLabelPath: path,
}, },
), ),
OwnerProp( OwnerProp(entry: entry),
entry: entry,
),
_buildChips(context), _buildChips(context),
_buildEditButtons(context),
], ],
); );
}); });
@ -96,6 +95,7 @@ class BasicSection extends StatelessWidget {
if (entry.isVideo && entry.is360) TypeFilter.sphericalVideo, if (entry.isVideo && entry.is360) TypeFilter.sphericalVideo,
if (entry.isVideo && !entry.is360) MimeFilter.video, if (entry.isVideo && !entry.is360) MimeFilter.video,
if (album != null) AlbumFilter(album, collection?.source.getAlbumDisplayName(context, album)), if (album != null) AlbumFilter(album, collection?.source.getAlbumDisplayName(context, album)),
if (entry.rating != 0) RatingFilter(entry.rating),
...tags.map((tag) => TagFilter(tag)), ...tags.map((tag) => TagFilter(tag)),
}; };
return AnimatedBuilder( return AnimatedBuilder(
@ -106,58 +106,78 @@ class BasicSection extends StatelessWidget {
if (entry.isFavourite) FavouriteFilter.instance, if (entry.isFavourite) FavouriteFilter.instance,
]..sort(); ]..sort();
final children = <Widget>[ return Padding(
...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), padding: const EdgeInsets.symmetric(horizontal: AvesFilterChip.outlineWidth / 2) + const EdgeInsets.only(top: 8),
child: Wrap( child: Wrap(
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
children: children, children: effectiveFilters
.map((filter) => AvesFilterChip(
filter: filter,
onTap: onFilter,
))
.toList(),
), ),
); );
}, },
); );
} }
Widget _buildEditTagButton(BuildContext context) { Widget _buildEditButtons(BuildContext context) {
const action = EntryInfoAction.editTags; final children = [
return ValueListenableBuilder<bool>( EntryInfoAction.editRating,
valueListenable: isEditingTagNotifier, EntryInfoAction.editTags,
builder: (context, isEditing, child) { ].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<EntryInfoAction?>(
valueListenable: isEditingMetadataNotifier,
builder: (context, editingAction, child) {
final isEditing = editingAction != null;
return Stack( return Stack(
children: [ children: [
DecoratedBox( DecoratedBox(
decoration: const BoxDecoration( decoration: BoxDecoration(
border: Border.fromBorderSide(BorderSide( border: Border.fromBorderSide(BorderSide(
color: AvesFilterChip.defaultOutlineColor, color: isEditing ? Theme.of(context).disabledColor : AvesFilterChip.defaultOutlineColor,
width: AvesFilterChip.outlineWidth, width: AvesFilterChip.outlineWidth,
)), )),
borderRadius: BorderRadius.all(Radius.circular(AvesFilterChip.defaultRadius)), borderRadius: const BorderRadius.all(Radius.circular(AvesFilterChip.defaultRadius)),
), ),
child: IconButton( child: IconButton(
icon: const Icon(AIcons.addTag), icon: action.getIcon(),
onPressed: isEditing ? null : () => actionDelegate.onActionSelected(context, action), onPressed: isEditing ? null : () => actionDelegate.onActionSelected(context, action),
tooltip: action.getText(context), tooltip: action.getText(context),
), ),
), ),
if (isEditing) Positioned.fill(
const Positioned.fill( child: Visibility(
child: Padding( visible: editingAction == action,
child: const Padding(
padding: EdgeInsets.all(1.0), padding: EdgeInsets.all(1.0),
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: AvesFilterChip.outlineWidth, strokeWidth: AvesFilterChip.outlineWidth,
), ),
), ),
), ),
),
], ],
); );
}, },

View file

@ -5,7 +5,8 @@ import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/app_bar_title.dart'; import 'package:aves/widgets/common/app_bar_title.dart';
import 'package:aves/widgets/common/basic/menu.dart'; import 'package:aves/widgets/common/basic/menu.dart';
import 'package:aves/widgets/common/extensions/build_context.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/info_search.dart';
import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart'; import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -37,10 +38,12 @@ class InfoAppBar extends StatelessWidget {
onPressed: onBackPressed, onPressed: onBackPressed,
tooltip: context.l10n.viewerInfoBackToViewerTooltip, tooltip: context.l10n.viewerInfoBackToViewerTooltip,
), ),
title: InteractiveAppBarTitle( title: SliverAppBarTitleWrapper(
child: InteractiveAppBarTitle(
onTap: () => _goToSearch(context), onTap: () => _goToSearch(context),
child: Text(context.l10n.viewerInfoPageTitle), child: Text(context.l10n.viewerInfoPageTitle),
), ),
),
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(AIcons.search), icon: const Icon(AIcons.search),

View file

@ -8,9 +8,9 @@ import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/basic/insets.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/providers/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/embedded/embedded_data_opener.dart';
import 'package:aves/widgets/viewer/info/basic_section.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/info_app_bar.dart';
import 'package:aves/widgets/viewer/info/location_section.dart'; import 'package:aves/widgets/viewer/info/location_section.dart';
import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart'; import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart';
@ -150,7 +150,7 @@ class _InfoPageContentState extends State<_InfoPageContent> {
final List<StreamSubscription> _subscriptions = []; final List<StreamSubscription> _subscriptions = [];
late EntryInfoActionDelegate _actionDelegate; late EntryInfoActionDelegate _actionDelegate;
final ValueNotifier<Map<String, MetadataDirectory>> _metadataNotifier = ValueNotifier({}); final ValueNotifier<Map<String, MetadataDirectory>> _metadataNotifier = ValueNotifier({});
final ValueNotifier<bool> _isEditingTagNotifier = ValueNotifier(false); final ValueNotifier<EntryInfoAction?> _isEditingMetadataNotifier = ValueNotifier(null);
static const horizontalPadding = EdgeInsets.symmetric(horizontal: 8); static const horizontalPadding = EdgeInsets.symmetric(horizontal: 8);
@ -197,7 +197,7 @@ class _InfoPageContentState extends State<_InfoPageContent> {
entry: entry, entry: entry,
collection: collection, collection: collection,
actionDelegate: _actionDelegate, actionDelegate: _actionDelegate,
isEditingTagNotifier: _isEditingTagNotifier, isEditingMetadataNotifier: _isEditingMetadataNotifier,
onFilter: _goToCollection, onFilter: _goToCollection,
); );
final locationAtTop = widget.split && entry.hasGps; final locationAtTop = widget.split && entry.hasGps;
@ -255,16 +255,14 @@ class _InfoPageContentState extends State<_InfoPageContent> {
} }
void _onActionDelegateEvent(ActionEvent<EntryInfoAction> event) { void _onActionDelegateEvent(ActionEvent<EntryInfoAction> event) {
if (event.action == EntryInfoAction.editTags) {
Future.delayed(Durations.dialogTransitionAnimation).then((_) { Future.delayed(Durations.dialogTransitionAnimation).then((_) {
if (event is ActionStartedEvent) { if (event is ActionStartedEvent) {
_isEditingTagNotifier.value = true; _isEditingMetadataNotifier.value = event.action;
} else if (event is ActionEndedEvent) { } else if (event is ActionEndedEvent) {
_isEditingTagNotifier.value = false; _isEditingMetadataNotifier.value = null;
} }
}); });
} }
}
void _goToCollection(CollectionFilter filter) { void _goToCollection(CollectionFilter filter) {
if (collection == null) return; if (collection == null) return;

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