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="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
### Added
@ -37,7 +58,8 @@ All notable changes to this project will be documented in this file.
### Changed
- Settings: select hidden path directory with a custom file picker instead of the native SAF one
- Viewer: video cover (before playing the video) is now loaded at original resolution and can be zoomed
- Viewer: video cover (before playing the video) is now loaded at original resolution and can be
zoomed
### Fixed
@ -75,7 +97,8 @@ All notable changes to this project will be documented in this file.
### Changed
- use build flavors to match distribution channels: `play` (same as original) and `izzy` (no Crashlytics)
- use build flavors to match distribution channels: `play` (same as original) and `izzy` (no
Crashlytics)
- use 12/24 hour format settings from device to display times
- Privacy: consent request on first launch for installed app inventory access
- use File API to rename and delete items, when possible (primary storage, Android <11)

View file

@ -59,7 +59,7 @@ At this stage this project does *not* accept PRs, except for translations.
### Translations
If you want to translate this app in your language and share the result, feel free to open a PR or send the translation by [email](mailto:gallery.aves@gmail.com). You can find some localization notes in [pubspec.yaml](https://github.com/deckerst/aves/blob/develop/pubspec.yaml). English, Korean and French are already handled.
If you want to translate this app in your language and share the result, feel free to open a PR or send the translation by [email](mailto:gallery.aves@gmail.com). You can find some localization notes in [pubspec.yaml](https://github.com/deckerst/aves/blob/develop/pubspec.yaml). English, Korean and French are already handled by me. Russian, German and Spanish are handled by generous volunteers.
### Donations

View file

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

View file

@ -20,8 +20,7 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
"rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) }
"flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) }
"editDate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::editDate) }
"setIptc" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::setIptc) }
"setXmp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::setXmp) }
"editMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::editMetadata) }
"removeTypes" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::removeTypes) }
else -> result.notImplemented()
}
@ -99,12 +98,11 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
})
}
private fun setIptc(call: MethodCall, result: MethodChannel.Result) {
val iptc = call.argument<List<FieldMap>>("iptc")
private fun editMetadata(call: MethodCall, result: MethodChannel.Result) {
val metadata = call.argument<FieldMap>("metadata")
val entryMap = call.argument<FieldMap>("entry")
val postEditScan = call.argument<Boolean>("postEditScan")
if (entryMap == null || postEditScan == null) {
result.error("setIptc-args", "failed because of missing arguments", null)
if (entryMap == null || metadata == null) {
result.error("editMetadata-args", "failed because of missing arguments", null)
return
}
@ -112,48 +110,19 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
val path = entryMap["path"] as String?
val mimeType = entryMap["mimeType"] as String?
if (uri == null || path == null || mimeType == null) {
result.error("setIptc-args", "failed because entry fields are missing", null)
result.error("editMetadata-args", "failed because entry fields are missing", null)
return
}
val provider = getProvider(uri)
if (provider == null) {
result.error("setIptc-provider", "failed to find provider for uri=$uri", null)
result.error("editMetadata-provider", "failed to find provider for uri=$uri", null)
return
}
provider.setIptc(activity, path, uri, mimeType, postEditScan, iptc = iptc, callback = object : ImageOpCallback {
provider.editMetadata(activity, path, uri, mimeType, metadata, callback = object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("setIptc-failure", "failed to set IPTC for mimeType=$mimeType uri=$uri", throwable.message)
})
}
private fun setXmp(call: MethodCall, result: MethodChannel.Result) {
val xmp = call.argument<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)
override fun onFailure(throwable: Throwable) = result.error("editMetadata-failure", "failed to edit metadata 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.metadata.Tag
import com.drew.metadata.avi.AviDirectory
import com.drew.metadata.exif.*
import com.drew.metadata.exif.ExifDirectoryBase
import com.drew.metadata.exif.ExifIFD0Directory
import com.drew.metadata.exif.ExifSubIFDDirectory
import com.drew.metadata.exif.GpsDirectory
import com.drew.metadata.file.FileTypeDirectory
import com.drew.metadata.gif.GifAnimationDirectory
import com.drew.metadata.iptc.IptcDirectory
@ -78,6 +81,7 @@ import java.nio.charset.Charset
import java.nio.charset.StandardCharsets
import java.text.ParseException
import java.util.*
import kotlin.math.roundToInt
import kotlin.math.roundToLong
class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
@ -92,6 +96,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
"getXmp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getXmp) }
"hasContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::hasContentResolverProp) }
"getContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverProp) }
"getDate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getDate) }
else -> result.notImplemented()
}
}
@ -113,8 +118,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java)
foundXmp = metadata.containsDirectoryOfType(XmpDirectory::class.java)
foundExif = metadata.directories.any { it is ExifDirectoryBase && it.tagCount > 0 }
foundXmp = metadata.directories.any { it is XmpDirectory && it.tagCount > 0 }
val uuidDirCount = HashMap<String, Int>()
val dirByName = metadata.directories.filter {
it.tagCount > 0
@ -158,25 +163,16 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
// tags
val tags = dir.tags
if (mimeType == MimeTypes.TIFF && (dir is ExifIFD0Directory || dir is ExifThumbnailDirectory)) {
fun tagMapper(it: Tag): Pair<String, String> {
val name = if (it.hasTagName()) {
it.tagName
} else {
TiffTags.getTagName(it.tagType) ?: it.tagName
}
return Pair(name, it.description)
}
if (dir is ExifIFD0Directory && dir.isGeoTiff()) {
if (dir is ExifDirectoryBase) {
if (dir.isGeoTiff()) {
// split GeoTIFF tags in their own directory
val byGeoTiff = tags.groupBy { TiffTags.isGeoTiffTag(it.tagType) }
val byGeoTiff = tags.groupBy { ExifTags.isGeoTiffTag(it.tagType) }
metadataMap["GeoTIFF"] = HashMap<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 {
dirMap.putAll(tags.map { tagMapper(it) })
dirMap.putAll(tags.map { exifTagMapper(it) })
}
} else if (dir.isPngTextDir()) {
metadataMap.remove(thisDirName)
@ -205,10 +201,15 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
val dirs = extractPngProfile(key, valueString)
if (dirs?.any() == true) {
dirs.forEach { profileDir ->
val profileDirName = profileDir.name
val profileDirName = "${dir.name}/${profileDir.name}"
val profileDirMap = metadataMap[profileDirName] ?: HashMap()
metadataMap[profileDirName] = profileDirMap
profileDirMap.putAll(profileDir.tags.map { Pair(it.tagName, it.description) })
val profileTags = profileDir.tags
if (profileDir is ExifDirectoryBase) {
profileDirMap.putAll(profileTags.map { exifTagMapper(it) })
} else {
profileDirMap.putAll(profileTags.map { Pair(it.tagName, it.description) })
}
}
null
} else {
@ -357,22 +358,22 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
return dirMap
}
// legend: ME=MetadataExtractor, EI=ExifInterface, MMR=MediaMetadataRetriever
// set `KEY_DATE_MILLIS` from these fields (by precedence):
// - ME / Exif / DATETIME_ORIGINAL
// - ME / Exif / DATETIME
// - EI / Exif / DATETIME_ORIGINAL
// - EI / Exif / DATETIME
// - ME / XMP / xmp:CreateDate
// - ME / XMP / photoshop:DateCreated
// - ME / PNG / TIME / LAST_MODIFICATION_TIME
// - MMR / METADATA_KEY_DATE
// - Exif / DATETIME_ORIGINAL
// - Exif / DATETIME
// - XMP / xmp:CreateDate
// - XMP / photoshop:DateCreated
// - PNG / TIME / LAST_MODIFICATION_TIME
// - Video / METADATA_KEY_DATE
// set `KEY_XMP_TITLE_DESCRIPTION` from these fields (by precedence):
// - ME / XMP / dc:title
// - ME / XMP / dc:description
// - XMP / dc:title
// - XMP / dc:description
// set `KEY_XMP_SUBJECTS` from these fields (by precedence):
// - ME / XMP / dc:subject
// - ME / IPTC / keywords
// - XMP / dc:subject
// - IPTC / keywords
// set `KEY_RATING` from these fields (by precedence):
// - XMP / xmp:Rating
// - XMP / MicrosoftPhoto:Rating
private fun getCatalogMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
@ -407,7 +408,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java)
foundExif = metadata.directories.any { it is ExifDirectoryBase && it.tagCount > 0 }
// File type
for (dir in metadata.getDirectoriesOfType(FileTypeDirectory::class.java)) {
@ -432,13 +433,13 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
// EXIF
for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) {
dir.getSafeDateMillis(ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL) { metadataMap[KEY_DATE_MILLIS] = it }
dir.getSafeDateMillis(ExifDirectoryBase.TAG_DATETIME_ORIGINAL) { metadataMap[KEY_DATE_MILLIS] = it }
}
for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) {
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
dir.getSafeDateMillis(ExifIFD0Directory.TAG_DATETIME) { metadataMap[KEY_DATE_MILLIS] = it }
dir.getSafeDateMillis(ExifDirectoryBase.TAG_DATETIME) { metadataMap[KEY_DATE_MILLIS] = it }
}
dir.getSafeInt(ExifIFD0Directory.TAG_ORIENTATION) {
dir.getSafeInt(ExifDirectoryBase.TAG_ORIENTATION) {
val orientation = it
if (isFlippedForExifCode(orientation)) flags = flags or MASK_IS_FLIPPED
metadataMap[KEY_ROTATION_DEGREES] = getRotationDegreesForExifCode(orientation)
@ -458,22 +459,31 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
val xmpMeta = dir.xmpMeta
try {
if (xmpMeta.doesPropertyExist(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME)) {
val count = xmpMeta.countArrayItems(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME)
val values = (1 until count + 1).map { xmpMeta.getArrayItem(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME, it).value }
if (xmpMeta.doesPropertyExist(XMP.DC_SCHEMA_NS, XMP.DC_SUBJECT_PROP_NAME)) {
val count = xmpMeta.countArrayItems(XMP.DC_SCHEMA_NS, XMP.DC_SUBJECT_PROP_NAME)
val values = (1 until count + 1).map { xmpMeta.getArrayItem(XMP.DC_SCHEMA_NS, XMP.DC_SUBJECT_PROP_NAME, it).value }
metadataMap[KEY_XMP_SUBJECTS] = values.joinToString(XMP_SUBJECTS_SEPARATOR)
}
xmpMeta.getSafeLocalizedText(XMP.DC_SCHEMA_NS, XMP.TITLE_PROP_NAME, acceptBlank = false) { metadataMap[KEY_XMP_TITLE_DESCRIPTION] = it }
xmpMeta.getSafeLocalizedText(XMP.DC_SCHEMA_NS, XMP.DC_TITLE_PROP_NAME, acceptBlank = false) { metadataMap[KEY_XMP_TITLE_DESCRIPTION] = it }
if (!metadataMap.containsKey(KEY_XMP_TITLE_DESCRIPTION)) {
xmpMeta.getSafeLocalizedText(XMP.DC_SCHEMA_NS, XMP.DESCRIPTION_PROP_NAME, acceptBlank = false) { metadataMap[KEY_XMP_TITLE_DESCRIPTION] = it }
xmpMeta.getSafeLocalizedText(XMP.DC_SCHEMA_NS, XMP.DC_DESCRIPTION_PROP_NAME, acceptBlank = false) { metadataMap[KEY_XMP_TITLE_DESCRIPTION] = it }
}
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
xmpMeta.getSafeDateMillis(XMP.XMP_SCHEMA_NS, XMP.CREATE_DATE_PROP_NAME) { metadataMap[KEY_DATE_MILLIS] = it }
xmpMeta.getSafeDateMillis(XMP.XMP_SCHEMA_NS, XMP.XMP_CREATE_DATE_PROP_NAME) { metadataMap[KEY_DATE_MILLIS] = it }
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
xmpMeta.getSafeDateMillis(XMP.PHOTOSHOP_SCHEMA_NS, XMP.PS_DATE_CREATED_PROP_NAME) { metadataMap[KEY_DATE_MILLIS] = it }
}
}
xmpMeta.getSafeInt(XMP.XMP_SCHEMA_NS, XMP.XMP_RATING_PROP_NAME) { metadataMap[KEY_RATING] = it }
if (!metadataMap.containsKey(KEY_RATING)) {
xmpMeta.getSafeInt(XMP.MICROSOFTPHOTO_SCHEMA_NS, XMP.MS_RATING_PROP_NAME) { percentRating ->
// values of 1,25,50,75,99% correspond to 1,2,3,4,5 stars
val standardRating = (percentRating / 25f).roundToInt() + 1
metadataMap[KEY_RATING] = standardRating
}
}
// identification of panorama (aka photo sphere)
if (xmpMeta.isPanorama()) {
flags = flags or MASK_IS_360
@ -659,10 +669,10 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
val metadata = ImageMetadataReader.readMetadata(input)
for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) {
foundExif = true
dir.getSafeRational(ExifSubIFDDirectory.TAG_FNUMBER) { metadataMap[KEY_APERTURE] = it.numerator.toDouble() / it.denominator }
dir.getSafeRational(ExifSubIFDDirectory.TAG_EXPOSURE_TIME, saveExposureTime)
dir.getSafeRational(ExifSubIFDDirectory.TAG_FOCAL_LENGTH) { metadataMap[KEY_FOCAL_LENGTH] = it.numerator.toDouble() / it.denominator }
dir.getSafeInt(ExifSubIFDDirectory.TAG_ISO_EQUIVALENT) { metadataMap[KEY_ISO] = it }
dir.getSafeRational(ExifDirectoryBase.TAG_FNUMBER) { metadataMap[KEY_APERTURE] = it.numerator.toDouble() / it.denominator }
dir.getSafeRational(ExifDirectoryBase.TAG_EXPOSURE_TIME, saveExposureTime)
dir.getSafeRational(ExifDirectoryBase.TAG_FOCAL_LENGTH) { metadataMap[KEY_FOCAL_LENGTH] = it.numerator.toDouble() / it.denominator }
dir.getSafeInt(ExifDirectoryBase.TAG_ISO_EQUIVALENT) { metadataMap[KEY_ISO] = it }
}
}
} catch (e: Exception) {
@ -876,6 +886,57 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
result.success(value?.toString())
}
private fun getDate(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<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 {
private val LOG_TAG = LogUtils.createTag<MetadataFetchHandler>()
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>
}
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
private const val KEY_MIME_TYPE = "mimeType"
private const val KEY_DATE_MILLIS = "dateMillis"
@ -914,6 +984,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
private const val KEY_LONGITUDE = "longitude"
private const val KEY_XMP_SUBJECTS = "xmpSubjects"
private const val KEY_XMP_TITLE_DESCRIPTION = "xmpTitleDescription"
private const val KEY_RATING = "rating"
private const val MASK_IS_ANIMATED = 1 shl 0
private const val MASK_IS_FLIPPED = 1 shl 1

View file

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

View file

@ -1,6 +1,7 @@
package deckers.thibault.aves.metadata
object TiffTags {
// Exif tags missing from `metadata-extractor`
object ExifTags {
// XPosition
// Tag = 286 (011E.H)
private const val TAG_X_POSITION = 0x011e
@ -32,6 +33,12 @@ object TiffTags {
// SAMPLEFORMAT_COMPLEXIEEEFP 6 // complex ieee floating
private const val TAG_SAMPLE_FORMAT = 0x0153
// Rating tag used by Windows, value in percent
// Tag = 18249 (4749.H)
// Type = SHORT
private const val TAG_RATING_PERCENT = 0x4749
/*
SGI
tags 32995-32999
@ -125,6 +132,7 @@ object TiffTags {
TAG_COLOR_MAP to "Color Map",
TAG_EXTRA_SAMPLES to "Extra Samples",
TAG_SAMPLE_FORMAT to "Sample Format",
TAG_RATING_PERCENT to "Rating Percent",
// SGI
TAG_MATTEING to "Matteing",
// GeoTIFF

View file

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

View file

@ -15,6 +15,7 @@ object XMP {
// standard namespaces
// cf com.adobe.internal.xmp.XMPConst
const val DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/"
const val MICROSOFTPHOTO_SCHEMA_NS = "http://ns.microsoft.com/photo/1.0/"
const val PHOTOSHOP_SCHEMA_NS = "http://ns.adobe.com/photoshop/1.0/"
const val XMP_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/"
private const val XMP_GIMG_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/g/img/"
@ -27,11 +28,13 @@ object XMP {
const val CONTAINER_SCHEMA_NS = "http://ns.google.com/photos/1.0/container/"
private const val CONTAINER_ITEM_SCHEMA_NS = "http://ns.google.com/photos/1.0/container/item/"
const val SUBJECT_PROP_NAME = "dc:subject"
const val TITLE_PROP_NAME = "dc:title"
const val DESCRIPTION_PROP_NAME = "dc:description"
const val DC_DESCRIPTION_PROP_NAME = "dc:description"
const val DC_SUBJECT_PROP_NAME = "dc:subject"
const val DC_TITLE_PROP_NAME = "dc:title"
const val MS_RATING_PROP_NAME = "MicrosoftPhoto:Rating"
const val PS_DATE_CREATED_PROP_NAME = "photoshop:DateCreated"
const val CREATE_DATE_PROP_NAME = "xmp:CreateDate"
const val XMP_CREATE_DATE_PROP_NAME = "xmp:CreateDate"
const val XMP_RATING_PROP_NAME = "xmp:Rating"
private const val GENERIC_LANG = ""
private const val SPECIFIC_LANG = "en-US"

View file

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

View file

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

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);
} catch (error) {
// loading may fail if the provided MIME type is incorrect (e.g. the Media Store may report a JPEG as a TIFF)
debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$error');
throw StateError('$mimeType region decoding failed (page $pageId)');
}

View file

@ -50,7 +50,8 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
}
return await decode(bytes);
} catch (error) {
debugPrint('$runtimeType _loadAsync failed with uri=$uri, error=$error');
// loading may fail if the provided MIME type is incorrect (e.g. the Media Store may report a JPEG as a TIFF)
debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$error');
throw StateError('$mimeType decoding failed (page $pageId)');
}
}

View file

@ -68,6 +68,7 @@ class UriImage extends ImageProvider<UriImage> with EquatableMixin {
}
return await decode(bytes);
} catch (error) {
// loading may fail if the provided MIME type is incorrect (e.g. the Media Store may report a JPEG as a TIFF)
debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$error');
throw StateError('$mimeType decoding failed (page $pageId)');
} finally {

View file

@ -22,7 +22,7 @@
"nextTooltip": "Nächste",
"showTooltip": "Anzeigen",
"hideTooltip": "Ausblenden",
"removeTooltip": "Entfernen",
"actionRemove": "Entfernen",
"resetButtonTooltip": "Zurücksetzen",
"doubleBackExitMessage": "Tippen Sie zum Verlassen erneut auf „Zurück“.",
@ -73,12 +73,15 @@
"videoActionSettings": "Einstellungen",
"entryInfoActionEditDate": "Datum & Uhrzeit bearbeiten",
"entryInfoActionEditRating": "Bewertung bearbeiten",
"entryInfoActionEditTags": "Tags bearbeiten",
"entryInfoActionRemoveMetadata": "Metadaten entfernen",
"filterFavouriteLabel": "Favorit",
"filterLocationEmptyLabel": "Ungeortet",
"filterTagEmptyLabel": "Unmarkiert",
"filterRatingUnratedLabel": "Nicht bewertet",
"filterRatingRejectedLabel": "Verworfen",
"filterTypeAnimatedLabel": "Animationen",
"filterTypeMotionPhotoLabel": "Bewegtes Foto",
"filterTypePanoramaLabel": "Panorama",
@ -137,6 +140,8 @@
"restrictedAccessDialogMessage": "Diese Anwendung darf keine Dateien im {directory} von „{volume}“ verändern.\n\nBitte verwenden Sie einen vorinstallierten Dateimanager oder eine Galerie-App, um die Objekte in ein anderes Verzeichnis zu verschieben.",
"notEnoughSpaceDialogTitle": "Nicht genug Platz",
"notEnoughSpaceDialogMessage": "Diese Operation benötigt {neededSize} freien Platz auf „{volume}“, um abgeschlossen zu werden, aber es ist nur noch {freeSize} übrig.",
"missingSystemFilePickerDialogTitle": "Fehlender System-Dateiauswahldialog",
"missingSystemFilePickerDialogMessage": "Der System-Dateiauswahldialog fehlt oder ist deaktiviert. Bitte aktivieren Sie ihn und versuchen Sie es erneut.",
"unsupportedTypeDialogTitle": "Nicht unterstützte Typen",
"unsupportedTypeDialogMessage": " {count, plural, =1{Dieser Vorgang wird für Elemente des folgenden Typs nicht unterstützt: {types}.} other{Dieser Vorgang wird für Elemente der folgenden Typen nicht unterstützt: {types}.}}",
@ -178,14 +183,17 @@
"renameEntryDialogLabel": "Neuer Name",
"editEntryDateDialogTitle": "Datum & Uhrzeit",
"editEntryDateDialogSet": "Festlegen",
"editEntryDateDialogShift": "Verschieben",
"editEntryDateDialogSetCustom": "Datum einstellen",
"editEntryDateDialogCopyField": "Von anderem Datum kopieren",
"editEntryDateDialogExtractFromTitle": "Auszug aus dem Titel",
"editEntryDateDialogClear": "Aufräumen",
"editEntryDateDialogFieldSelection": "Feldauswahl",
"editEntryDateDialogShift": "Verschieben",
"editEntryDateDialogSourceFileModifiedDate": "Änderungsdatum der Datei",
"editEntryDateDialogTargetFieldsHeader": "Zu ändernde Felder",
"editEntryDateDialogHours": "Stunden",
"editEntryDateDialogMinutes": "Minuten",
"editEntryRatingDialogTitle": "Bewertung",
"removeEntryMetadataDialogTitle": "Entfernung von Metadaten",
"removeEntryMetadataDialogMore": "Mehr",
@ -270,6 +278,7 @@
"collectionSortDate": "Nach Datum",
"collectionSortSize": "Nach Größe",
"collectionSortName": "Nach Album & Dateiname",
"collectionSortRating": "Nach Bewertung",
"collectionGroupAlbum": "Nach Album",
"collectionGroupMonth": "Nach Monat",
@ -343,6 +352,7 @@
"searchSectionCountries": "Länder",
"searchSectionPlaces": "Orte",
"searchSectionTags": "Tags",
"searchSectionRating": "Bewertungen",
"settingsPageTitle": "Einstellungen",
"settingsSystemDefault": "System",
@ -366,8 +376,10 @@
"settingsNavigationDrawerAddAlbum": "Album hinzufügen",
"settingsSectionThumbnails": "Vorschaubilder",
"settingsThumbnailShowFavouriteIcon": "Favoriten-Symbol anzeigen",
"settingsThumbnailShowLocationIcon": "Standort-Symbol anzeigen",
"settingsThumbnailShowMotionPhotoIcon": "Bewegungsfoto-Symbol anzeigen",
"settingsThumbnailShowRating": "Bewertung anzeigen",
"settingsThumbnailShowRawIcon": "Rohdaten-Symbol anzeigen",
"settingsThumbnailShowVideoDuration": "Videodauer anzeigen",

View file

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

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",
"showTooltip": "Afficher",
"hideTooltip": "Masquer",
"removeTooltip": "Supprimer",
"actionRemove": "Supprimer",
"resetButtonTooltip": "Réinitialiser",
"doubleBackExitMessage": "Pressez «\u00A0retour\u00A0» à nouveau pour quitter.",
@ -73,12 +73,15 @@
"videoActionSettings": "Préférences",
"entryInfoActionEditDate": "Modifier la date",
"entryInfoActionEditRating": "Modifier la notation",
"entryInfoActionEditTags": "Modifier les libellés",
"entryInfoActionRemoveMetadata": "Retirer les métadonnées",
"filterFavouriteLabel": "Favori",
"filterLocationEmptyLabel": "Sans lieu",
"filterTagEmptyLabel": "Sans libellé",
"filterRatingUnratedLabel": "Sans notation",
"filterRatingRejectedLabel": "Rejeté",
"filterTypeAnimatedLabel": "Animation",
"filterTypeMotionPhotoLabel": "Photo animée",
"filterTypePanoramaLabel": "Panorama",
@ -137,6 +140,8 @@
"restrictedAccessDialogMessage": "Cette app ne peut pas modifier les fichiers du {directory} de «\u00A0{volume}\u00A0».\n\nVeuillez utiliser une app pré-installée pour déplacer les fichiers vers un autre dossier.",
"notEnoughSpaceDialogTitle": "Espace insuffisant",
"notEnoughSpaceDialogMessage": "Cette opération nécessite {neededSize} 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",
"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",
"editEntryDateDialogTitle": "Date & Heure",
"editEntryDateDialogSet": "Régler",
"editEntryDateDialogShift": "Décaler",
"editEntryDateDialogSetCustom": "Régler une date personnalisée",
"editEntryDateDialogCopyField": "Copier d'une autre date",
"editEntryDateDialogExtractFromTitle": "Extraire du titre",
"editEntryDateDialogClear": "Effacer",
"editEntryDateDialogFieldSelection": "Champs affectés",
"editEntryDateDialogShift": "Décaler",
"editEntryDateDialogSourceFileModifiedDate": "Date de modification du fichier",
"editEntryDateDialogTargetFieldsHeader": "Champs à modifier",
"editEntryDateDialogHours": "Heures",
"editEntryDateDialogMinutes": "Minutes",
"editEntryRatingDialogTitle": "Notation",
"removeEntryMetadataDialogTitle": "Retrait de métadonnées",
"removeEntryMetadataDialogMore": "Voir plus",
@ -270,6 +278,7 @@
"collectionSortDate": "par date",
"collectionSortSize": "par taille",
"collectionSortName": "alphabétique",
"collectionSortRating": "par notation",
"collectionGroupAlbum": "par album",
"collectionGroupMonth": "par mois",
@ -343,6 +352,7 @@
"searchSectionCountries": "Pays",
"searchSectionPlaces": "Lieux",
"searchSectionTags": "Libellés",
"searchSectionRating": "Notations",
"settingsPageTitle": "Réglages",
"settingsSystemDefault": "Système",
@ -366,8 +376,10 @@
"settingsNavigationDrawerAddAlbum": "Ajouter un album",
"settingsSectionThumbnails": "Vignettes",
"settingsThumbnailShowFavouriteIcon": "Afficher licône de favori",
"settingsThumbnailShowLocationIcon": "Afficher licône de lieu",
"settingsThumbnailShowMotionPhotoIcon": "Afficher licône de photo animée",
"settingsThumbnailShowRating": "Afficher la notation",
"settingsThumbnailShowRawIcon": "Afficher licône de photo raw",
"settingsThumbnailShowVideoDuration": "Afficher la durée de la vidéo",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'package:aves/image_providers/thumbnail_provider.dart';
import 'package:aves/image_providers/uri_image_provider.dart';
import 'package:flutter/foundation.dart';
class EntryCache {
// ordered descending
@ -19,9 +20,11 @@ class EntryCache {
String uri,
String mimeType,
int? dateModifiedSecs,
int oldRotationDegrees,
bool oldIsFlipped,
int rotationDegrees,
bool isFlipped,
) async {
debugPrint('Evict cached images for uri=$uri, mimeType=$mimeType, dateModifiedSecs=$dateModifiedSecs, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped');
// TODO TLAD provide pageId parameter for multi page items, if someday image editing features are added for them
int? pageId;
@ -30,8 +33,8 @@ class EntryCache {
uri: uri,
mimeType: mimeType,
pageId: pageId,
rotationDegrees: oldRotationDegrees,
isFlipped: oldIsFlipped,
rotationDegrees: rotationDegrees,
isFlipped: isFlipped,
).evict();
// evict low quality thumbnail (without specified extents)
@ -40,8 +43,8 @@ class EntryCache {
mimeType: mimeType,
pageId: pageId,
dateModifiedSecs: dateModifiedSecs ?? 0,
rotationDegrees: oldRotationDegrees,
isFlipped: oldIsFlipped,
rotationDegrees: rotationDegrees,
isFlipped: isFlipped,
)).evict();
await Future.forEach<double>(
@ -51,8 +54,8 @@ class EntryCache {
mimeType: mimeType,
pageId: pageId,
dateModifiedSecs: dateModifiedSecs ?? 0,
rotationDegrees: oldRotationDegrees,
isFlipped: oldIsFlipped,
rotationDegrees: rotationDegrees,
isFlipped: isFlipped,
extent: extent,
)).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!);
Future<void> add(Iterable<AvesEntry> entries) async {
Future<void> add(Set<AvesEntry> entries) async {
final newRows = entries.map(_entryToRow);
await metadataDb.addFavourites(newRows);
@ -30,7 +30,7 @@ class Favourites with ChangeNotifier {
notifyListeners();
}
Future<void> remove(Iterable<AvesEntry> entries) async {
Future<void> remove(Set<AvesEntry> entries) async {
final contentIds = entries.map((entry) => entry.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/path.dart';
import 'package:aves/model/filters/query.dart';
import 'package:aves/model/filters/rating.dart';
import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/filters/type.dart';
import 'package:aves/utils/color_utils.dart';
@ -26,6 +27,7 @@ abstract class CollectionFilter extends Equatable implements Comparable<Collecti
AlbumFilter.type,
LocationFilter.type,
CoordinateFilter.type,
RatingFilter.type,
TagFilter.type,
PathFilter.type,
];
@ -52,6 +54,8 @@ abstract class CollectionFilter extends Equatable implements Comparable<Collecti
return PathFilter.fromMap(jsonMap);
case QueryFilter.type:
return QueryFilter.fromMap(jsonMap);
case RatingFilter.type:
return RatingFilter.fromMap(jsonMap);
case TagFilter.type:
return TagFilter.fromMap(jsonMap);
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

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;
@override
Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => showGenericIcon ? Icon(tag.isEmpty ? AIcons.tagOff : AIcons.tag, size: size) : null;
Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => showGenericIcon ? Icon(tag.isEmpty ? AIcons.tagUntagged : AIcons.tag, size: size) : null;
@override
String get category => type;

View file

@ -9,6 +9,7 @@ class CatalogMetadata {
final String? mimeType, xmpSubjects, xmpTitleDescription;
double? latitude, longitude;
Address? address;
int rating;
static const double _precisionErrorTolerance = 1e-9;
static const _isAnimatedMask = 1 << 0;
@ -31,6 +32,7 @@ class CatalogMetadata {
this.xmpTitleDescription,
double? latitude,
double? longitude,
this.rating = 0,
}) {
// Geocoder throws an `IllegalArgumentException` when a coordinate has a funky value like `1.7056881853375E7`
// We also exclude zero coordinates, taking into account precision errors (e.g. {5.952380952380953e-11,-2.7777777777777777e-10}),
@ -67,6 +69,7 @@ class CatalogMetadata {
xmpTitleDescription: xmpTitleDescription,
latitude: latitude,
longitude: longitude,
rating: rating,
);
}
@ -87,6 +90,7 @@ class CatalogMetadata {
xmpTitleDescription: map['xmpTitleDescription'] ?? '',
latitude: map['latitude'],
longitude: map['longitude'],
rating: map['rating'] ?? 0,
);
}
@ -100,8 +104,9 @@ class CatalogMetadata {
'xmpTitleDescription': xmpTitleDescription,
'latitude': latitude,
'longitude': longitude,
'rating': rating,
};
@override
String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, is360=$is360, isMultiPage=$isMultiPage, rotationDegrees=$rotationDegrees, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}';
String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, is360=$is360, isMultiPage=$isMultiPage, rotationDegrees=$rotationDegrees, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription, latitude=$latitude, longitude=$longitude, rating=$rating}';
}

View file

@ -4,17 +4,45 @@ import 'package:flutter/widgets.dart';
@immutable
class DateModifier {
static const allDateFields = [
static const writableDateFields = [
MetadataField.exifDate,
MetadataField.exifDateOriginal,
MetadataField.exifDateDigitized,
MetadataField.exifGpsDate,
MetadataField.xmpCreateDate,
];
final DateEditAction action;
final Set<MetadataField> fields;
final DateTime? dateTime;
final DateTime? setDateTime;
final DateFieldSource? copyFieldSource;
final int? shiftMinutes;
const DateModifier(this.action, this.fields, {this.dateTime, this.shiftMinutes});
const DateModifier._private(
this.action,
this.fields, {
this.setDateTime,
this.copyFieldSource,
this.shiftMinutes,
});
factory DateModifier.setCustom(Set<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,
exifDateDigitized,
exifGpsDate,
xmpCreateDate,
}
enum DateEditAction {
set,
shift,
setCustom,
copyField,
extractFromTitle,
clear,
shift,
remove,
}
enum DateFieldSource {
fileModifiedDate,
exifDate,
exifDateOriginal,
exifDateDigitized,
exifGpsDate,
}
enum MetadataType {
@ -56,7 +66,7 @@ class MetadataTypes {
}
extension ExtraMetadataType on MetadataType {
// match `ExifInterface` directory names
// match `metadata-extractor` directory names
String getText() {
switch (this) {
case MetadataType.comment:
@ -80,3 +90,49 @@ extension ExtraMetadataType on MetadataType {
}
}
}
extension ExtraMetadataField on MetadataField {
MetadataType get type {
switch (this) {
case MetadataField.exifDate:
case MetadataField.exifDateOriginal:
case MetadataField.exifDateDigitized:
case MetadataField.exifGpsDate:
return MetadataType.exif;
case MetadataField.xmpCreateDate:
return MetadataType.xmp;
}
}
String? toExifInterfaceTag() {
switch (this) {
case MetadataField.exifDate:
return 'DateTime';
case MetadataField.exifDateOriginal:
return 'DateTimeOriginal';
case MetadataField.exifDateDigitized:
return 'DateTimeDigitized';
case MetadataField.exifGpsDate:
return 'GPSDateStamp';
case MetadataField.xmpCreateDate:
return null;
}
}
}
extension ExtraDateFieldSource on DateFieldSource {
MetadataField? toMetadataField() {
switch (this) {
case DateFieldSource.fileModifiedDate:
return null;
case DateFieldSource.exifDate:
return MetadataField.exifDate;
case DateFieldSource.exifDateOriginal:
return MetadataField.exifDateOriginal;
case DateFieldSource.exifDateDigitized:
return MetadataField.exifDateDigitized;
case DateFieldSource.exifGpsDate:
return MetadataField.exifGpsDate;
}
}
}

View file

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

View file

@ -25,6 +25,9 @@ class MetadataDbUpgrader {
case 4:
await _upgradeFrom4(db);
break;
case 5:
await _upgradeFrom5(db);
break;
}
oldVersion++;
}
@ -121,4 +124,9 @@ class MetadataDbUpgrader {
', resumeTimeMillis INTEGER'
')');
}
static Future<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.delete,
];
static const showThumbnailFavourite = true;
static const showThumbnailLocation = true;
static const showThumbnailMotionPhoto = true;
static const showThumbnailRating = true;
static const showThumbnailRaw = true;
static const showThumbnailVideoDuration = true;

View file

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

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/location.dart';
import 'package:aves/model/filters/query.dart';
import 'package:aves/model/filters/rating.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/events.dart';
@ -108,15 +109,27 @@ class CollectionLens with ChangeNotifier {
}
bool get showHeaders {
if (sortFactor == EntrySortFactor.size) return false;
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;
bool showAlbumHeaders() => !filters.any((f) => f is AlbumFilter);
switch (sortFactor) {
case EntrySortFactor.date:
switch (sectionFactor) {
case EntryGroupFactor.none:
return false;
case EntryGroupFactor.album:
return showAlbumHeaders();
case EntryGroupFactor.month:
return true;
case EntryGroupFactor.day:
return true;
}
case EntrySortFactor.name:
return showAlbumHeaders();
case EntrySortFactor.rating:
return !filters.any((f) => f is RatingFilter);
case EntrySortFactor.size:
return false;
}
}
void addFilter(CollectionFilter filter) {
@ -181,12 +194,15 @@ class CollectionLens with ChangeNotifier {
case EntrySortFactor.date:
_filteredSortedEntries.sort(AvesEntry.compareByDate);
break;
case EntrySortFactor.size:
_filteredSortedEntries.sort(AvesEntry.compareBySize);
break;
case EntrySortFactor.name:
_filteredSortedEntries.sort(AvesEntry.compareByName);
break;
case EntrySortFactor.rating:
_filteredSortedEntries.sort(AvesEntry.compareByRating);
break;
case EntrySortFactor.size:
_filteredSortedEntries.sort(AvesEntry.compareBySize);
break;
}
}
@ -210,15 +226,18 @@ class CollectionLens with ChangeNotifier {
break;
}
break;
case EntrySortFactor.name:
final byAlbum = groupBy<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:
sections = Map.fromEntries([
MapEntry(const SectionKey(), _filteredSortedEntries),
]);
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);
_sortedEntries = null;

View file

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

View file

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

View file

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

View file

@ -2,6 +2,7 @@ class MimeTypes {
static const anyImage = 'image/*';
static const bmp = 'image/bmp';
static const bmpX = 'image/x-ms-bmp';
static const gif = 'image/gif';
static const heic = 'image/heic';
static const heif = 'image/heif';
@ -43,6 +44,8 @@ class MimeTypes {
static const avi = 'video/avi';
static const aviVnd = 'video/vnd.avi';
static const flv = 'video/flv';
static const flvX = 'video/x-flv';
static const mkv = 'video/x-matroska';
static const mov = 'video/quicktime';
static const mp2t = 'video/mp2t'; // .m2ts, .ts
@ -62,7 +65,7 @@ class MimeTypes {
// groups
// formats that support transparency
static const Set<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};
@ -71,11 +74,33 @@ class MimeTypes {
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 bool isImage(String mimeType) => mimeType.startsWith('image');
static bool isVideo(String mimeType) => mimeType.startsWith('video');
static bool refersToSameType(String a, b) {
switch (a) {
case avi:
case aviVnd:
return [avi, aviVnd].contains(b);
case bmp:
case bmpX:
return [bmp, bmpX].contains(b);
case flv:
case flvX:
return [flv, flvX].contains(b);
case heic:
case heif:
return [heic, heif].contains(b);
case psdVnd:
case psdX:
return [psdVnd, psdX].contains(b);
default:
return a == b;
}
}
}

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<int> getPerformanceClass();
Future<bool> isSystemFilePickerEnabled();
}
class PlatformDeviceService implements DeviceService {
@ -60,7 +62,6 @@ class PlatformDeviceService implements DeviceService {
@override
Future<int> getPerformanceClass() async {
try {
await platform.invokeMethod('getPerformanceClass');
final result = await platform.invokeMethod('getPerformanceClass');
if (result != null) return result as int;
} on PlatformException catch (e, stack) {
@ -68,4 +69,15 @@ class PlatformDeviceService implements DeviceService {
}
return 0;
}
@override
Future<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 'package:aves/model/entry.dart';
import 'package:aves/model/entry_xmp_iptc.dart';
import 'package:aves/model/metadata/date_modifier.dart';
import 'package:aves/model/metadata/enums.dart';
import 'package:aves/services/common/services.dart';
import 'package:collection/collection.dart';
import 'package:flutter/services.dart';
abstract class MetadataEditService {
@ -12,11 +12,9 @@ abstract class MetadataEditService {
Future<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>> setXmp(AvesEntry entry, AvesXmp? xmp);
Future<Map<String, dynamic>> editMetadata(AvesEntry entry, Map<MetadataType, dynamic> modifier);
Future<Map<String, dynamic>> removeTypes(AvesEntry entry, Set<MetadataType> types);
}
@ -73,13 +71,13 @@ class PlatformMetadataEditService implements MetadataEditService {
}
@override
Future<Map<String, dynamic>> editDate(AvesEntry entry, DateModifier modifier) async {
Future<Map<String, dynamic>> editExifDate(AvesEntry entry, DateModifier modifier) async {
try {
final result = await platform.invokeMethod('editDate', <String, dynamic>{
'entry': _toPlatformEntryMap(entry),
'dateMillis': modifier.dateTime?.millisecondsSinceEpoch,
'dateMillis': modifier.setDateTime?.millisecondsSinceEpoch,
'shiftMinutes': modifier.shiftMinutes,
'fields': modifier.fields.map(_toExifInterfaceTag).toList(),
'fields': modifier.fields.where((v) => v.type == MetadataType.exif).map((v) => v.toExifInterfaceTag()).whereNotNull().toList(),
});
if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e, stack) {
@ -91,29 +89,11 @@ class PlatformMetadataEditService implements MetadataEditService {
}
@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 {
final result = await platform.invokeMethod('setIptc', <String, dynamic>{
final result = await platform.invokeMethod('editMetadata', <String, dynamic>{
'entry': _toPlatformEntryMap(entry),
'iptc': iptc,
'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,
'metadata': metadata.map((type, value) => MapEntry(_toPlatformMetadataType(type), value)),
});
if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e, stack) {
@ -140,19 +120,6 @@ class PlatformMetadataEditService implements MetadataEditService {
return {};
}
String _toExifInterfaceTag(MetadataField field) {
switch (field) {
case MetadataField.exifDate:
return 'DateTime';
case MetadataField.exifDateOriginal:
return 'DateTimeOriginal';
case MetadataField.exifDateDigitized:
return 'DateTimeDigitized';
case MetadataField.exifGpsDate:
return 'GPSDateStamp';
}
}
String _toPlatformMetadataType(MetadataType type) {
switch (type) {
case MetadataType.comment:

View file

@ -1,11 +1,12 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/entry_xmp_iptc.dart';
import 'package:aves/model/metadata/catalog.dart';
import 'package:aves/model/metadata/enums.dart';
import 'package:aves/model/metadata/overlay.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/model/panorama.dart';
import 'package:aves/services/common/service_policy.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/services/metadata/xmp.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
@ -28,6 +29,8 @@ abstract class MetadataFetchService {
Future<bool> hasContentResolverProp(String prop);
Future<String?> getContentResolverProp(AvesEntry entry, String prop);
Future<DateTime?> getDate(AvesEntry entry, MetadataField field);
}
class PlatformMetadataFetchService implements MetadataFetchService {
@ -63,6 +66,7 @@ class PlatformMetadataFetchService implements MetadataFetchService {
// 'dateMillis': date taken in milliseconds since Epoch (long)
// 'isAnimated': animated gif/webp (bool)
// 'isFlipped': flipped according to EXIF orientation (bool)
// 'rating': rating in [-1,5] (int)
// 'rotationDegrees': rotation degrees according to EXIF orientation or other metadata (int)
// 'latitude': latitude (double)
// 'longitude': longitude (double)
@ -223,4 +227,22 @@ class PlatformMetadataFetchService implements MetadataFetchService {
}
return null;
}
@override
Future<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 {
// common animations
final Duration expansionTileAnimation;
final Duration formTransition;
final Duration iconAnimation;
final Duration staggeredAnimation;
final Duration staggeredAnimationPageTarget;
@ -111,6 +112,7 @@ class DurationsData {
const DurationsData({
this.expansionTileAnimation = const Duration(milliseconds: 200),
this.formTransition = const Duration(milliseconds: 200),
this.iconAnimation = const Duration(milliseconds: 300),
this.staggeredAnimation = const Duration(milliseconds: 375),
this.staggeredAnimationPageTarget = const Duration(milliseconds: 800),
@ -123,6 +125,7 @@ class DurationsData {
return DurationsData(
// as of Flutter v2.5.1, `ExpansionPanelList` throws if animation duration is zero
expansionTileAnimation: const Duration(microseconds: 1),
formTransition: Duration.zero,
iconAnimation: Duration.zero,
staggeredAnimation: Duration.zero,
staggeredAnimationPageTarget: Duration.zero,

View file

@ -19,18 +19,22 @@ class AIcons {
static const IconData home = Icons.home_outlined;
static const IconData language = Icons.translate_outlined;
static const IconData location = Icons.place_outlined;
static const IconData locationOff = Icons.location_off_outlined;
static const IconData locationUnlocated = Icons.location_off_outlined;
static const IconData mainStorage = Icons.smartphone_outlined;
static const IconData privacy = MdiIcons.shieldAccountOutline;
static const IconData rating = Icons.star_border_outlined;
static const IconData ratingFull = Icons.star;
static const IconData ratingRejected = MdiIcons.starMinusOutline;
static const IconData ratingUnrated = MdiIcons.starOffOutline;
static const IconData raw = Icons.raw_on_outlined;
static const IconData shooting = Icons.camera_outlined;
static const IconData removableStorage = Icons.sd_storage_outlined;
static const IconData sensorControl = Icons.explore_outlined;
static const IconData sensorControlOff = Icons.explore_off_outlined;
static const IconData sensorControlEnabled = Icons.explore_outlined;
static const IconData sensorControlDisabled = Icons.explore_off_outlined;
static const IconData settings = Icons.settings_outlined;
static const IconData text = Icons.format_quote_outlined;
static const IconData tag = Icons.local_offer_outlined;
static const IconData tagOff = MdiIcons.tagOffOutline;
static const IconData tagUntagged = MdiIcons.tagOffOutline;
// view
static const IconData group = Icons.group_work_outlined;
@ -40,7 +44,6 @@ class AIcons {
// actions
static const IconData add = Icons.add_circle_outline;
static const IconData addShortcut = Icons.add_to_home_screen_outlined;
static const IconData addTag = MdiIcons.tagPlusOutline;
static const IconData cancel = Icons.cancel_outlined;
static const IconData replay10 = Icons.replay_10_outlined;
static const IconData skip10 = Icons.forward_10_outlined;
@ -51,6 +54,8 @@ class AIcons {
static const IconData debug = Icons.whatshot_outlined;
static const IconData delete = Icons.delete_outlined;
static const IconData edit = Icons.edit_outlined;
static const IconData editRating = MdiIcons.starPlusOutline;
static const IconData editTags = MdiIcons.tagPlusOutline;
static const IconData export = MdiIcons.fileExportOutline;
static const IconData flip = Icons.flip_outlined;
static const IconData favourite = Icons.favorite_border;
@ -111,8 +116,8 @@ class AIcons {
static const IconData geo = Icons.language_outlined;
static const IconData motionPhoto = Icons.motion_photos_on_outlined;
static const IconData multiPage = Icons.burst_mode_outlined;
static const IconData videoThumb = Icons.play_circle_outline;
static const IconData threeSixty = Icons.threesixty_outlined;
static const IconData videoThumb = Icons.play_circle_outline;
static const IconData selected = Icons.check_circle_outline;
static const IconData unselected = Icons.radio_button_unchecked;

View file

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

View file

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

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 = {
'Deutsch': 'JanWaldhorn',
'Español (México)': 'n-berenice',
'Русский': 'D3ZOXY',
};

View file

@ -106,6 +106,12 @@ class _AvesAppState extends State<AvesApp> {
home: home,
navigatorObservers: _navigatorObservers,
builder: (context, child) {
// Flutter has various page transition implementations for Android:
// - `FadeUpwardsPageTransitionsBuilder` on Oreo / API 27 and below
// - `OpenUpwardsPageTransitionsBuilder` on Pie / API 28
// - `ZoomPageTransitionsBuilder` on Android 10 / API 29 and above
// As of Flutter v2.8.1, `FadeUpwardsPageTransitionsBuilder` is the default, regardless of versions.
// In practice, `ZoomPageTransitionsBuilder` feels unstable when transitioning from Album to Collection.
if (!areAnimationsEnabled) {
child = Theme(
data: Theme.of(context).copyWith(

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

View file

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

View file

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

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/device.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/entry_xmp_iptc.dart';
import 'package:aves/model/entry_metadata_edition.dart';
import 'package:aves/model/favourites.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/highlight.dart';
@ -15,7 +16,6 @@ import 'package:aves/model/selection.dart';
import 'package:aves/model/source/analysis_controller.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/services/common/image_op_events.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/services/media/enums.dart';
@ -44,7 +44,6 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
EntrySetAction action, {
required AppMode appMode,
required bool isSelecting,
required EntrySortFactor sortFactor,
required int itemCount,
required int selectedItemCount,
}) {
@ -75,10 +74,12 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
case EntrySetAction.copy:
case EntrySetAction.move:
case EntrySetAction.rescan:
case EntrySetAction.toggleFavourite:
case EntrySetAction.rotateCCW:
case EntrySetAction.rotateCW:
case EntrySetAction.flip:
case EntrySetAction.editDate:
case EntrySetAction.editRating:
case EntrySetAction.editTags:
case EntrySetAction.removeMetadata:
return appMode == AppMode.main && isSelecting;
@ -116,10 +117,12 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
case EntrySetAction.copy:
case EntrySetAction.move:
case EntrySetAction.rescan:
case EntrySetAction.toggleFavourite:
case EntrySetAction.rotateCCW:
case EntrySetAction.rotateCW:
case EntrySetAction.flip:
case EntrySetAction.editDate:
case EntrySetAction.editRating:
case EntrySetAction.editTags:
case EntrySetAction.removeMetadata:
return hasSelection;
@ -167,6 +170,9 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
case EntrySetAction.rescan:
_rescan(context);
break;
case EntrySetAction.toggleFavourite:
_toggleFavourite(context);
break;
case EntrySetAction.rotateCCW:
_rotate(context, clockwise: false);
break;
@ -179,6 +185,9 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
case EntrySetAction.editDate:
_editDate(context);
break;
case EntrySetAction.editRating:
_editRating(context);
break;
case EntrySetAction.editTags:
_editTags(context);
break;
@ -211,6 +220,18 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
selection.browse();
}
Future<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 {
final source = context.read<CollectionSource>();
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);
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 {
@ -499,7 +520,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canRotateAndFlip);
if (todoItems == null || todoItems.isEmpty) return;
await _edit(context, selection, todoItems, (entry) => entry.flip(persist: true));
await _edit(context, selection, todoItems, (entry) => entry.flip());
}
Future<void> _editDate(BuildContext context) async {
@ -515,6 +536,19 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
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 {
final selection = context.read<Selection<AvesEntry>>();
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/widgets/collection/grid/headers/album.dart';
import 'package:aves/widgets/collection/grid/headers/date.dart';
import 'package:aves/widgets/collection/grid/headers/rating.dart';
import 'package:aves/widgets/common/grid/header.dart';
import 'package:flutter/material.dart';
@ -49,6 +50,8 @@ class CollectionSectionHeader extends StatelessWidget {
break;
case EntrySortFactor.name:
return _buildAlbumHeader(context);
case EntrySortFactor.rating:
return RatingSectionHeader<AvesEntry>(key: ValueKey(sectionKey), rating: (sectionKey as EntryRatingSectionKey).rating);
case EntrySortFactor.size:
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/dialogs/aves_dialog.dart';
import 'package:aves/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart';
import 'package:aves/widgets/dialogs/entry_editors/edit_entry_rating_dialog.dart';
import 'package:aves/widgets/dialogs/entry_editors/edit_entry_tags_dialog.dart';
import 'package:aves/widgets/dialogs/entry_editors/remove_entry_metadata_dialog.dart';
import 'package:flutter/material.dart';
@ -22,6 +23,18 @@ mixin EntryEditorMixin {
return modifier;
}
Future<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 {
if (entries.isEmpty) return null;

View file

@ -47,11 +47,12 @@ mixin PermissionAwareMixin {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) {
final directory = dir.relativeDir.isEmpty ? context.l10n.rootDirectoryDescription : context.l10n.otherDirectoryDescription(dir.relativeDir);
final l10n = context.l10n;
final directory = dir.relativeDir.isEmpty ? l10n.rootDirectoryDescription : l10n.otherDirectoryDescription(dir.relativeDir);
final volume = dir.getVolumeDescription(context);
return AvesDialog(
title: context.l10n.storageAccessDialogTitle,
content: Text(context.l10n.storageAccessDialogMessage(directory, volume)),
title: l10n.storageAccessDialogTitle,
content: Text(l10n.storageAccessDialogMessage(directory, volume)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
@ -68,6 +69,26 @@ mixin PermissionAwareMixin {
// abort if the user cancels in Flutter
if (confirmed == null || !confirmed) return false;
if (!await deviceService.isSystemFilePickerEnabled()) {
await showDialog(
context: context,
builder: (context) {
final l10n = context.l10n;
return AvesDialog(
title: l10n.missingSystemFilePickerDialogTitle,
content: Text(l10n.missingSystemFilePickerDialogMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(MaterialLocalizations.of(context).okButtonLabel),
),
],
);
},
);
return false;
}
final granted = await storageService.requestDirectoryAccess(dir.volumePath);
if (!granted) {
// abort if the user denies access from the native dialog

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.black,
Colors.black54,
// Colors.amber,
],
),
)

View file

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

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 {
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 {
final IconData icon;
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
Widget build(BuildContext context) {
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.rating != 0 && context.select<GridThemeData, bool>((t) => t.showRating)) RatingIcon(entry: entry),
if (entry.isVideo)
VideoIcon(
entry: entry,
)
VideoIcon(entry: entry)
else if (entry.isAnimated)
const AnimatedImageIcon()
else ...[

View file

@ -1,6 +1,7 @@
import 'package:aves/image_providers/app_icon_image_provider.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/common/basic/query_bar.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
import 'package:aves/widgets/viewer/info/common.dart';
import 'package:collection/collection.dart';
@ -15,6 +16,7 @@ class DebugAndroidAppSection extends StatefulWidget {
class _DebugAndroidAppSectionState extends State<DebugAndroidAppSection> with AutomaticKeepAliveClientMixin {
late Future<Set<Package>> _loader;
final ValueNotifier<String> _queryNotifier = ValueNotifier('');
static const iconSize = 20.0;
@ -43,7 +45,15 @@ class _DebugAndroidAppSectionState extends State<DebugAndroidAppSection> with Au
final disabledTheme = enabledTheme.merge(const IconThemeData(opacity: .2));
return Column(
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(
TextSpan(
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/format.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/basic/wheel.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../aves_dialog.dart';
class EditEntryDateDialog extends StatefulWidget {
final AvesEntry entry;
@ -24,144 +24,78 @@ class EditEntryDateDialog extends StatefulWidget {
}
class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
DateEditAction _action = DateEditAction.set;
late Set<MetadataField> _fields;
late DateTime _dateTime;
int _shiftMinutes = 60;
DateEditAction _action = DateEditAction.setCustom;
DateFieldSource _copyFieldSource = DateFieldSource.fileModifiedDate;
late DateTime _setDateTime;
late ValueNotifier<int> _shiftHour, _shiftMinute;
late ValueNotifier<String> _shiftSign;
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
void initState() {
super.initState();
_fields = {
MetadataField.exifDate,
MetadataField.exifDateDigitized,
MetadataField.exifDateOriginal,
};
_dateTime = entry.bestDate ?? DateTime.now();
_initSet();
_initShift(60);
}
void _initSet() {
_setDateTime = widget.entry.bestDate ?? DateTime.now();
}
void _initShift(int initialMinutes) {
final abs = initialMinutes.abs();
_shiftHour = ValueNotifier(abs ~/ 60);
_shiftMinute = ValueNotifier(abs % 60);
_shiftSign = ValueNotifier(initialMinutes.isNegative ? '-' : '+');
}
@override
Widget build(BuildContext context) {
return MediaQueryDataProvider(
child: Builder(
builder: (context) {
child: TooltipTheme(
data: TooltipTheme.of(context).copyWith(
preferBelow: false,
),
child: Builder(builder: (context) {
final l10n = context.l10n;
final locale = l10n.localeName;
final use24hour = context.select<MediaQueryData, bool>((v) => v.alwaysUse24HourFormat);
void _updateAction(DateEditAction? action) {
if (action == null) return;
setState(() => _action = action);
}
Widget _tileText(String text) => Text(
text,
softWrap: false,
overflow: TextOverflow.fade,
maxLines: 1,
);
final setTile = Row(
children: [
Expanded(
child: RadioListTile<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(
return AvesDialog(
title: l10n.editEntryDateDialogTitle,
scrollableContent: [
setTile,
shiftTile,
extractFromTitleTile,
clearTile,
Padding(
padding: const EdgeInsets.only(bottom: 1),
child: ExpansionPanelList(
expansionCallback: (index, isExpanded) {
setState(() => _showOptions = !isExpanded);
},
animationDuration: animationDuration,
expandedHeaderPadding: EdgeInsets.zero,
elevation: 0,
children: [
ExpansionPanel(
headerBuilder: (context, isExpanded) => ListTile(
title: Text(l10n.editEntryDateDialogFieldSelection),
),
body: Column(
children: DateModifier.allDateFields
.map((field) => SwitchListTile(
value: _fields.contains(field),
onChanged: (selected) => setState(() => selected ? _fields.add(field) : _fields.remove(field)),
title: Text(_fieldTitle(field)),
padding: const EdgeInsets.only(left: 16, top: 8, right: 16),
child: DropdownButton<DateEditAction>(
items: DateEditAction.values
.map((v) => DropdownMenuItem<DateEditAction>(
value: v,
child: Text(_actionText(context, v)),
))
.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),
),
],
),
);
},
}),
),
);
}
String _formatShiftDuration() {
final abs = _shiftMinutes.abs();
final h = abs ~/ 60;
final m = abs % 60;
return '${_shiftMinutes.isNegative ? '-' : '+'}$h:${m.toString().padLeft(2, '0')}';
}
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,
Widget _formTransitionBuilder(Widget child, Animation<double> animation) => FadeTransition(
opacity: animation,
child: SizeTransition(
sizeFactor: animation,
axisAlignment: -1,
child: child,
),
);
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) {
late DateModifier modifier;
switch (_action) {
case DateEditAction.set:
modifier = DateModifier(_action, _fields, dateTime: _dateTime);
break;
case DateEditAction.shift:
modifier = DateModifier(_action, _fields, shiftMinutes: _shiftMinutes);
break;
case DateEditAction.extractFromTitle:
case DateEditAction.clear:
modifier = DateModifier(_action, _fields);
break;
}
Navigator.pop(context, modifier);
}
}
class TimeShiftDialog extends StatefulWidget {
final int initialShiftMinutes;
const TimeShiftDialog({
Key? key,
required this.initialShiftMinutes,
}) : super(key: key);
@override
_TimeShiftDialogState createState() => _TimeShiftDialogState();
}
class _TimeShiftDialogState extends State<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 ? '-' : '+');
Widget _buildCopyFieldContent(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(left: 16, top: 0, right: 16),
child: DropdownButton<DateFieldSource>(
items: DateFieldSource.values
.map((v) => DropdownMenuItem<DateFieldSource>(
value: v,
child: Text(_setSourceText(context, v)),
))
.toList(),
selectedItemBuilder: (context) => DateFieldSource.values
.map((v) => DropdownMenuItem<DateFieldSource>(
value: v,
child: Text(
_setSourceText(context, v),
softWrap: false,
overflow: TextOverflow.fade,
),
))
.toList(),
value: _copyFieldSource,
onChanged: (v) => setState(() => _copyFieldSource = v!),
isExpanded: true,
dropdownColor: dropdownColor,
),
);
}
@override
Widget build(BuildContext context) {
Widget _buildShiftContent(BuildContext context) {
const textStyle = TextStyle(fontSize: 34);
return AvesDialog(
scrollableContent: [
Center(
child: Padding(
padding: const EdgeInsets.only(top: 8),
return Center(
child: Table(
children: [
TableRow(
@ -304,16 +188,16 @@ class _TimeShiftDialogState extends State<TimeShiftDialog> {
),
TableRow(
children: [
_Wheel(
valueNotifier: _sign,
WheelSelector(
valueNotifier: _shiftSign,
values: const ['+', '-'],
textStyle: textStyle,
textAlign: TextAlign.center,
),
Align(
alignment: Alignment.centerRight,
child: _Wheel(
valueNotifier: _hour,
child: WheelSelector(
valueNotifier: _shiftHour,
values: List.generate(24, (i) => i),
textStyle: textStyle,
textAlign: TextAlign.end,
@ -328,8 +212,8 @@ class _TimeShiftDialogState extends State<TimeShiftDialog> {
),
Align(
alignment: Alignment.centerLeft,
child: _Wheel(
valueNotifier: _minute,
child: WheelSelector(
valueNotifier: _shiftMinute,
values: List.generate(60, (i) => i),
textStyle: textStyle,
textAlign: TextAlign.end,
@ -341,101 +225,133 @@ class _TimeShiftDialogState extends State<TimeShiftDialog> {
defaultColumnWidth: const IntrinsicColumnWidth(),
defaultVerticalAlignment: TableCellVerticalAlignment.middle,
),
),
),
],
hasScrollBar: false,
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
),
TextButton(
onPressed: () => Navigator.pop(context, (_hour.value * 60 + _minute.value) * (_sign.value == '+' ? 1 : -1)),
child: Text(MaterialLocalizations.of(context).okButtonLabel),
),
],
);
}
}
class _Wheel<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 build(BuildContext context) {
final background = Theme.of(context).dialogBackgroundColor;
final foreground = DefaultTextStyle.of(context).style.color!;
Widget _buildDestinationFields(BuildContext context) {
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,
// small padding as a workaround to show dialog action divider
padding: const EdgeInsets.only(bottom: 1),
child: ExpansionPanelList(
expansionCallback: (index, isExpanded) {
setState(() => _showOptions = !isExpanded);
},
animationDuration: context.read<DurationsData>().expansionTileAnimation,
expandedHeaderPadding: EdgeInsets.zero,
elevation: 0,
children: [
ExpansionPanel(
headerBuilder: (context, isExpanded) => ListTile(
title: Text(context.l10n.editEntryDateDialogTargetFieldsHeader),
),
body: Column(
children: DateModifier.writableDateFields
.map((field) => SwitchListTile(
value: _fields.contains(field),
onChanged: (selected) => setState(() => selected ? _fields.add(field) : _fields.remove(field)),
title: Text(_fieldTitle(field)),
))
.toList(),
),
isExpanded: _showOptions,
canTapOnHeader: true,
backgroundColor: Colors.transparent,
),
],
),
);
}
String _actionText(BuildContext context, DateEditAction action) {
final l10n = context.l10n;
switch (action) {
case DateEditAction.setCustom:
return l10n.editEntryDateDialogSetCustom;
case DateEditAction.copyField:
return l10n.editEntryDateDialogCopyField;
case DateEditAction.extractFromTitle:
return l10n.editEntryDateDialogExtractFromTitle;
case DateEditAction.shift:
return l10n.editEntryDateDialogShift;
case DateEditAction.remove:
return l10n.actionRemove;
}
}
String _setSourceText(BuildContext context, DateFieldSource source) {
final l10n = context.l10n;
switch (source) {
case DateFieldSource.fileModifiedDate:
return l10n.editEntryDateDialogSourceFileModifiedDate;
case DateFieldSource.exifDate:
return 'Exif date';
case DateFieldSource.exifDateOriginal:
return 'Exif original date';
case DateFieldSource.exifDateDigitized:
return 'Exif digitized date';
case DateFieldSource.exifGpsDate:
return 'Exif GPS date';
}
}
String _fieldTitle(MetadataField field) {
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(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(AIcons.tagOff, color: untaggedColor),
const Icon(AIcons.tagUntagged, color: untaggedColor),
const SizedBox(width: 8),
Text(
l10n.filterTagEmptyLabel,

View file

@ -69,7 +69,7 @@ class _RemoveEntryMetadataDialogState extends State<RemoveEntryMetadataDialog> {
),
isExpanded: _showMore,
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/empty.dart';
import 'package:aves/widgets/common/providers/selection_provider.dart';
import 'package:aves/widgets/common/sliver_app_bar_title.dart';
import 'package:aves/widgets/dialogs/filter_editors/create_album_dialog.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart';
import 'package:aves/widgets/filter_grids/common/action_delegates/album_set.dart';
@ -141,10 +142,12 @@ class _AlbumPickAppBar extends StatelessWidget {
return SliverAppBar(
leading: const BackButton(),
title: SourceStateAwareAppBarTitle(
title: SliverAppBarTitleWrapper(
child: SourceStateAwareAppBarTitle(
title: Text(title()),
source: source,
),
),
bottom: _AlbumQueryBar(
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/basic/menu.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/sliver_app_bar_title.dart';
import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart';
import 'package:aves/widgets/search/search_delegate.dart';
import 'package:flutter/material.dart';
@ -74,7 +75,9 @@ class _FilterGridAppBarState<T extends CollectionFilter> extends State<FilterGri
_isSelectingNotifier.value = isSelecting;
return SliverAppBar(
leading: appMode.hasDrawer ? _buildAppBarLeading(isSelecting) : null,
title: _buildAppBarTitle(isSelecting),
title: SliverAppBarTitleWrapper(
child: _buildAppBarTitle(isSelecting),
),
actions: _buildActions(appMode, selection),
titleSpacing: 0,
floating: true,
@ -103,7 +106,7 @@ class _FilterGridAppBarState<T extends CollectionFilter> extends State<FilterGri
);
}
Widget? _buildAppBarTitle(bool isSelecting) {
Widget _buildAppBarTitle(bool isSelecting) {
if (isSelecting) {
return Selector<Selection<FilterGridItem<T>>, int>(
selector: (context, selection) => selection.selectedItems.length,

View file

@ -2,11 +2,13 @@ import 'dart:async';
import 'package:aves/app_mode.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/home_page.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/services/analysis_service.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/services/global_search.dart';
@ -18,6 +20,7 @@ import 'package:aves/widgets/filter_grids/albums_page.dart';
import 'package:aves/widgets/search/search_delegate.dart';
import 'package:aves/widgets/search/search_page.dart';
import 'package:aves/widgets/viewer/entry_viewer_page.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:permission_handler/permission_handler.dart';
@ -114,7 +117,7 @@ class _HomePageState extends State<HomePage> {
context.read<ValueNotifier<AppMode>>().value = appMode;
unawaited(reportService.setCustomKey('app_mode', appMode.toString()));
if (appMode != AppMode.view) {
if (appMode != AppMode.view || _isViewerSourceable(_viewerEntry!)) {
debugPrint('Storage check complete in ${stopwatch.elapsed.inMilliseconds}ms');
unawaited(GlobalSearch.registerCallback());
unawaited(AnalysisService.registerCallback());
@ -127,11 +130,13 @@ class _HomePageState extends State<HomePage> {
// e.g. when opening the viewer in `view` mode should replace a viewer in `main` mode
unawaited(Navigator.pushAndRemoveUntil(
context,
_getRedirectRoute(appMode),
await _getRedirectRoute(appMode),
(route) => false,
));
}
bool _isViewerSourceable(AvesEntry viewerEntry) => viewerEntry.directory != null && !settings.hiddenFilters.any((filter) => filter.test(viewerEntry));
Future<AvesEntry?> _initViewerEntry({required String uri, required String? mimeType}) async {
if (uri.startsWith('/')) {
// convert this file path to a proper URI
@ -145,13 +150,50 @@ class _HomePageState extends State<HomePage> {
return entry;
}
Route _getRedirectRoute(AppMode appMode) {
Future<Route> _getRedirectRoute(AppMode appMode) async {
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(
settings: const RouteSettings(name: EntryViewerPage.routeName),
builder: (_) => EntryViewerPage(
initialEntry: _viewerEntry!,
),
builder: (_) {
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/mime.dart';
import 'package:aves/model/filters/query.dart';
import 'package:aves/model/filters/rating.dart';
import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/filters/type.dart';
import 'package:aves/model/settings/settings.dart';
@ -179,6 +180,11 @@ class CollectionSearchDelegate {
],
);
}),
_buildFilterRow(
context: context,
title: context.l10n.searchSectionRating,
filters: [0, 5, 4, 3, 2, 1, -1].map((rating) => RatingFilter(rating)).where((f) => containQuery(f.getLabel(context))).toList(),
),
],
);
});

View file

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

View file

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

View file

@ -154,7 +154,7 @@ class _HiddenPaths extends StatelessWidget {
onPressed: () {
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
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);
double opacityFor(bool enabled) => enabled ? 1 : .2;
@ -38,14 +33,39 @@ class ThumbnailsSection extends StatelessWidget {
showHighlight: false,
children: [
const CollectionActionsTile(),
SwitchListTile(
value: currentShowThumbnailLocation,
Selector<Settings, bool>(
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,
title: Row(
children: [
Expanded(child: Text(context.l10n.settingsThumbnailShowLocationIcon)),
AnimatedOpacity(
opacity: opacityFor(currentShowThumbnailLocation),
opacity: opacityFor(current),
duration: Durations.toggleableTransitionAnimation,
child: Icon(
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,
title: Row(
children: [
Expanded(child: Text(context.l10n.settingsThumbnailShowMotionPhotoIcon)),
AnimatedOpacity(
opacity: opacityFor(currentShowThumbnailMotionPhoto),
opacity: opacityFor(current),
duration: Durations.toggleableTransitionAnimation,
child: Padding(
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,
title: Row(
children: [
Expanded(child: Text(context.l10n.settingsThumbnailShowRawIcon)),
AnimatedOpacity(
opacity: opacityFor(currentShowThumbnailRaw),
opacity: opacityFor(current),
duration: Durations.toggleableTransitionAnimation,
child: Icon(
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,
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/constants.dart';
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:percent_indicator/linear_percent_indicator.dart';
class FilterTable extends StatelessWidget {
class FilterTable<T extends Comparable> extends StatelessWidget {
final int totalEntryCount;
final Map<String, int> entryCountMap;
final CollectionFilter Function(String key) filterBuilder;
final Map<T, int> entryCountMap;
final CollectionFilter Function(T key) filterBuilder;
final bool sortByCount;
final int? maxRowCount;
final FilterCallback onFilterSelection;
const FilterTable({
@ -18,6 +19,8 @@ class FilterTable extends StatelessWidget {
required this.totalEntryCount,
required this.entryCountMap,
required this.filterBuilder,
required this.sortByCount,
required this.maxRowCount,
required this.onFilterSelection,
}) : super(key: key);
@ -27,11 +30,13 @@ class FilterTable extends StatelessWidget {
@override
Widget build(BuildContext context) {
final sortedEntries = entryCountMap.entries.toList()
..sort((kv1, kv2) {
final sortedEntries = entryCountMap.entries.toList();
if (sortByCount) {
sortedEntries.sort((kv1, kv2) {
final c = kv2.value.compareTo(kv1.value);
return c != 0 ? c : compareAsciiUpperCase(kv1.key, kv2.key);
return c != 0 ? c : kv1.key.compareTo(kv2.key);
});
}
final textScaleFactor = MediaQuery.textScaleFactorOf(context);
final lineHeight = 16 * textScaleFactor;
@ -41,8 +46,9 @@ class FilterTable extends StatelessWidget {
child: LayoutBuilder(
builder: (context, constraints) {
final showPercentIndicator = constraints.maxWidth - (chipWidth + countWidth) > percentIndicatorMinWidth;
final displayedEntries = maxRowCount != null ? sortedEntries.take(maxRowCount!) : sortedEntries;
return Table(
children: sortedEntries.take(5).map((kv) {
children: displayedEntries.map((kv) {
final filter = filterBuilder(kv.key);
final label = filter.getLabel(context);
final count = kv.value;

View file

@ -4,6 +4,7 @@ import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/location.dart';
import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/filters/rating.dart';
import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/settings/accessibility_animations.dart';
import 'package:aves/model/settings/settings.dart';
@ -33,6 +34,7 @@ class StatsPage extends StatelessWidget {
final CollectionLens? parentCollection;
final Set<AvesEntry> entries;
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;
@ -55,9 +57,13 @@ class StatsPage extends StatelessWidget {
entryCountPerPlace[place] = (entryCountPerPlace[place] ?? 0) + 1;
}
}
entry.tags.forEach((tag) {
entryCountPerTag[tag] = (entryCountPerTag[tag] ?? 0) + 1;
});
final rating = entry.rating;
entryCountPerRating[rating] = (entryCountPerRating[rating] ?? 0) + 1;
});
}
@ -115,13 +121,15 @@ class StatsPage extends StatelessWidget {
],
),
);
final showRatings = entryCountPerRating.entries.any((kv) => kv.key != 0 && kv.value > 0);
child = ListView(
children: [
mimeDonuts,
locationIndicator,
..._buildTopFilters(context, context.l10n.statsTopCountries, entryCountPerCountry, (s) => LocationFilter(LocationLevel.country, s)),
..._buildTopFilters(context, context.l10n.statsTopPlaces, entryCountPerPlace, (s) => LocationFilter(LocationLevel.place, s)),
..._buildTopFilters(context, context.l10n.statsTopTags, entryCountPerTag, (s) => TagFilter(s)),
..._buildFilterSection<String>(context, context.l10n.statsTopCountries, entryCountPerCountry, (v) => LocationFilter(LocationLevel.country, v)),
..._buildFilterSection<String>(context, context.l10n.statsTopPlaces, entryCountPerPlace, (v) => LocationFilter(LocationLevel.place, v)),
..._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,
String title,
Map<String, int> entryCountMap,
CollectionFilter Function(String key) filterBuilder,
) {
Map<T, int> entryCountMap,
CollectionFilter Function(T key) filterBuilder, {
bool sortByCount = true,
int? maxRowCount = 5,
}) {
if (entryCountMap.isEmpty) return [];
return [
@ -263,6 +273,8 @@ class StatsPage extends StatelessWidget {
totalEntryCount: entries.length,
entryCountMap: entryCountMap,
filterBuilder: filterBuilder,
sortByCount: sortByCount,
maxRowCount: maxRowCount,
onFilterSelection: (filter) => _onFilterSelection(context, filter),
),
];

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

View file

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

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

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/basic/menu.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/viewer/info/entry_info_action_delegate.dart';
import 'package:aves/widgets/common/sliver_app_bar_title.dart';
import 'package:aves/widgets/viewer/action/entry_info_action_delegate.dart';
import 'package:aves/widgets/viewer/info/info_search.dart';
import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart';
import 'package:flutter/material.dart';
@ -37,10 +38,12 @@ class InfoAppBar extends StatelessWidget {
onPressed: onBackPressed,
tooltip: context.l10n.viewerInfoBackToViewerTooltip,
),
title: InteractiveAppBarTitle(
title: SliverAppBarTitleWrapper(
child: InteractiveAppBarTitle(
onTap: () => _goToSearch(context),
child: Text(context.l10n.viewerInfoPageTitle),
),
),
actions: [
IconButton(
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/widgets/common/basic/insets.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/viewer/action/entry_info_action_delegate.dart';
import 'package:aves/widgets/viewer/embedded/embedded_data_opener.dart';
import 'package:aves/widgets/viewer/info/basic_section.dart';
import 'package:aves/widgets/viewer/info/entry_info_action_delegate.dart';
import 'package:aves/widgets/viewer/info/info_app_bar.dart';
import 'package:aves/widgets/viewer/info/location_section.dart';
import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart';
@ -150,7 +150,7 @@ class _InfoPageContentState extends State<_InfoPageContent> {
final List<StreamSubscription> _subscriptions = [];
late EntryInfoActionDelegate _actionDelegate;
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);
@ -197,7 +197,7 @@ class _InfoPageContentState extends State<_InfoPageContent> {
entry: entry,
collection: collection,
actionDelegate: _actionDelegate,
isEditingTagNotifier: _isEditingTagNotifier,
isEditingMetadataNotifier: _isEditingMetadataNotifier,
onFilter: _goToCollection,
);
final locationAtTop = widget.split && entry.hasGps;
@ -255,16 +255,14 @@ class _InfoPageContentState extends State<_InfoPageContent> {
}
void _onActionDelegateEvent(ActionEvent<EntryInfoAction> event) {
if (event.action == EntryInfoAction.editTags) {
Future.delayed(Durations.dialogTransitionAnimation).then((_) {
if (event is ActionStartedEvent) {
_isEditingTagNotifier.value = true;
_isEditingMetadataNotifier.value = event.action;
} else if (event is ActionEndedEvent) {
_isEditingTagNotifier.value = false;
_isEditingMetadataNotifier.value = null;
}
});
}
}
void _goToCollection(CollectionFilter filter) {
if (collection == null) return;

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