Merge branch 'develop'
This commit is contained in:
commit
3c408051b1
111 changed files with 3737 additions and 1456 deletions
27
CHANGELOG.md
27
CHANGELOG.md
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
|
@ -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,22 +87,29 @@ object MetadataExtractorHelper {
|
|||
fun Directory.isPngTextDir(): Boolean = this is PngDirectory && setOf(PNG_ITXT_DIR_NAME, PNG_TEXT_DIR_NAME, PNG_ZTXT_DIR_NAME).contains(this.name)
|
||||
|
||||
fun extractPngProfile(key: String, valueString: String): Iterable<Directory>? {
|
||||
when (key) {
|
||||
"Raw profile type iptc" -> {
|
||||
val match = PNG_RAW_PROFILE_PATTERN.matchEntire(valueString)
|
||||
if (match != null) {
|
||||
val dataString = match.groupValues[3]
|
||||
val hexString = dataString.replace(Regex("[\\r\\n]"), "")
|
||||
val dataBytes = hexStringToByteArray(hexString)
|
||||
if (dataBytes != null) {
|
||||
val start = dataBytes.indexOf(Metadata.IPTC_MARKER_BYTE)
|
||||
if (start != -1) {
|
||||
val segmentBytes = dataBytes.copyOfRange(fromIndex = start, toIndex = dataBytes.size - start)
|
||||
val metadata = com.drew.metadata.Metadata()
|
||||
IptcReader().extract(SequentialByteArrayReader(segmentBytes), metadata, segmentBytes.size.toLong())
|
||||
return metadata.directories
|
||||
if (key == PNG_RAW_PROFILE_EXIF || key == PNG_RAW_PROFILE_IPTC) {
|
||||
val match = PNG_RAW_PROFILE_PATTERN.matchEntire(valueString)
|
||||
if (match != null) {
|
||||
val dataString = match.groupValues[3]
|
||||
val hexString = dataString.replace(Regex("[\\r\\n]"), "")
|
||||
val dataBytes = hexString.decodeHex()
|
||||
if (dataBytes != null) {
|
||||
val metadata = com.drew.metadata.Metadata()
|
||||
when (key) {
|
||||
PNG_RAW_PROFILE_EXIF -> {
|
||||
if (ExifReader.startsWithJpegExifPreamble(dataBytes)) {
|
||||
ExifReader().extract(ByteArrayReader(dataBytes), metadata, ExifReader.JPEG_SEGMENT_PREAMBLE.length)
|
||||
}
|
||||
}
|
||||
PNG_RAW_PROFILE_IPTC -> {
|
||||
val start = dataBytes.indexOf(Metadata.IPTC_MARKER_BYTE)
|
||||
if (start != -1) {
|
||||
val segmentBytes = dataBytes.copyOfRange(fromIndex = start, toIndex = dataBytes.size)
|
||||
IptcReader().extract(SequentialByteArrayReader(segmentBytes), metadata, segmentBytes.size.toLong())
|
||||
}
|
||||
}
|
||||
}
|
||||
return metadata.directories
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -101,15 +118,18 @@ object MetadataExtractorHelper {
|
|||
|
||||
// convenience methods
|
||||
|
||||
private fun hexStringToByteArray(hexString: String): ByteArray? {
|
||||
if (hexString.length % 2 != 0) return null
|
||||
private fun String.decodeHex(): ByteArray? {
|
||||
if (length % 2 != 0) return null
|
||||
|
||||
val dataBytes = ByteArray(hexString.length / 2)
|
||||
var i = 0
|
||||
while (i < hexString.length) {
|
||||
dataBytes[i / 2] = hexString.substring(i, i + 2).toByte(16)
|
||||
i += 2
|
||||
try {
|
||||
val byteIterator = chunkedSequence(2)
|
||||
.map { it.toInt(16).toByte() }
|
||||
.iterator()
|
||||
|
||||
return ByteArray(length / 2) { byteIterator.next() }
|
||||
} catch (e: NumberFormatException) {
|
||||
Log.w(LOG_TAG, "failed to decode hex string=$this", e)
|
||||
}
|
||||
return dataBytes
|
||||
return null
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -800,63 +800,47 @@ abstract class ImageProvider {
|
|||
}
|
||||
}
|
||||
|
||||
fun setIptc(
|
||||
fun editMetadata(
|
||||
context: Context,
|
||||
path: String,
|
||||
uri: Uri,
|
||||
mimeType: String,
|
||||
postEditScan: Boolean,
|
||||
modifier: FieldMap,
|
||||
callback: ImageOpCallback,
|
||||
iptc: List<FieldMap>? = null,
|
||||
) {
|
||||
val newFields = HashMap<String, Any?>()
|
||||
if (modifier.containsKey("iptc")) {
|
||||
val iptc = (modifier["iptc"] as List<*>?)?.filterIsInstance<FieldMap>()
|
||||
if (!editIptc(
|
||||
context = context,
|
||||
path = path,
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
callback = callback,
|
||||
iptc = iptc,
|
||||
)
|
||||
) return
|
||||
}
|
||||
|
||||
val success = editIptc(
|
||||
context = context,
|
||||
path = path,
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
callback = callback,
|
||||
iptc = iptc,
|
||||
)
|
||||
|
||||
if (success) {
|
||||
if (postEditScan) {
|
||||
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
|
||||
} else {
|
||||
callback.onSuccess(HashMap())
|
||||
if (modifier.containsKey("xmp")) {
|
||||
val xmp = modifier["xmp"] as Map<*, *>?
|
||||
if (xmp != null) {
|
||||
val coreXmp = xmp["xmp"] as String?
|
||||
val extendedXmp = xmp["extendedXmp"] as String?
|
||||
if (!editXmp(
|
||||
context = context,
|
||||
path = path,
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
callback = callback,
|
||||
coreXmp = coreXmp,
|
||||
extendedXmp = extendedXmp,
|
||||
)
|
||||
) return
|
||||
}
|
||||
} else {
|
||||
callback.onFailure(Exception("failed to set IPTC"))
|
||||
}
|
||||
}
|
||||
|
||||
fun setXmp(
|
||||
context: Context,
|
||||
path: String,
|
||||
uri: Uri,
|
||||
mimeType: String,
|
||||
callback: ImageOpCallback,
|
||||
coreXmp: String? = null,
|
||||
extendedXmp: String? = null,
|
||||
) {
|
||||
val newFields = HashMap<String, Any?>()
|
||||
|
||||
val success = editXmp(
|
||||
context = context,
|
||||
path = path,
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
callback = callback,
|
||||
coreXmp = coreXmp,
|
||||
extendedXmp = extendedXmp,
|
||||
)
|
||||
|
||||
if (success) {
|
||||
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
|
||||
} else {
|
||||
callback.onFailure(Exception("failed to set XMP"))
|
||||
}
|
||||
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
|
||||
}
|
||||
|
||||
fun removeMetadataTypes(
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
10
android/app/src/main/res/values-es/strings.xml
Normal file
10
android/app/src/main/res/values-es/strings.xml
Normal 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 & videos</string>
|
||||
<string name="analysis_notification_default_title">Explorando medios</string>
|
||||
<string name="analysis_notification_action_stop">Anular</string>
|
||||
</resources>
|
BIN
fastlane/metadata/android/de/images/featureGraphic.png
Normal file
BIN
fastlane/metadata/android/de/images/featureGraphic.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 16 KiB |
5
fastlane/metadata/android/es-MX/full_description.txt
Normal file
5
fastlane/metadata/android/es-MX/full_description.txt
Normal 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>.
|
BIN
fastlane/metadata/android/es-MX/images/featureGraphic.png
Normal file
BIN
fastlane/metadata/android/es-MX/images/featureGraphic.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 17 KiB |
1
fastlane/metadata/android/es-MX/short_description.txt
Normal file
1
fastlane/metadata/android/es-MX/short_description.txt
Normal file
|
@ -0,0 +1 @@
|
|||
Galería y visor de metadatos
|
|
@ -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)');
|
||||
}
|
||||
|
|
|
@ -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)');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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",
|
||||
|
||||
|
|
|
@ -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
540
lib/l10n/app_es.arb
Normal 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": {}
|
||||
}
|
|
@ -22,7 +22,7 @@
|
|||
"nextTooltip": "Suivant",
|
||||
"showTooltip": "Afficher",
|
||||
"hideTooltip": "Masquer",
|
||||
"removeTooltip": "Supprimer",
|
||||
"actionRemove": "Supprimer",
|
||||
"resetButtonTooltip": "Réinitialiser",
|
||||
|
||||
"doubleBackExitMessage": "Pressez «\u00A0retour\u00A0» à nouveau pour quitter.",
|
||||
|
@ -73,12 +73,15 @@
|
|||
"videoActionSettings": "Préférences",
|
||||
|
||||
"entryInfoActionEditDate": "Modifier la date",
|
||||
"entryInfoActionEditRating": "Modifier la notation",
|
||||
"entryInfoActionEditTags": "Modifier les libellés",
|
||||
"entryInfoActionRemoveMetadata": "Retirer les métadonnées",
|
||||
|
||||
"filterFavouriteLabel": "Favori",
|
||||
"filterLocationEmptyLabel": "Sans lieu",
|
||||
"filterTagEmptyLabel": "Sans libellé",
|
||||
"filterRatingUnratedLabel": "Sans notation",
|
||||
"filterRatingRejectedLabel": "Rejeté",
|
||||
"filterTypeAnimatedLabel": "Animation",
|
||||
"filterTypeMotionPhotoLabel": "Photo animée",
|
||||
"filterTypePanoramaLabel": "Panorama",
|
||||
|
@ -137,6 +140,8 @@
|
|||
"restrictedAccessDialogMessage": "Cette app ne peut pas modifier les fichiers du {directory} de «\u00A0{volume}\u00A0».\n\nVeuillez utiliser une app pré-installée pour déplacer les fichiers vers un autre dossier.",
|
||||
"notEnoughSpaceDialogTitle": "Espace insuffisant",
|
||||
"notEnoughSpaceDialogMessage": "Cette opération nécessite {neededSize} d’espace disponible sur «\u00A0{volume}\u00A0», mais il ne reste que {freeSize}.",
|
||||
"missingSystemFilePickerDialogTitle": "Sélecteur de fichiers désactivé",
|
||||
"missingSystemFilePickerDialogMessage": "Le sélecteur de fichiers du système est absent ou désactivé. Veuillez le réactiver et réessayer.",
|
||||
|
||||
"unsupportedTypeDialogTitle": "Formats non supportés",
|
||||
"unsupportedTypeDialogMessage": "{count, plural, =1{Cette opération n’est pas disponible pour les fichiers au format suivant : {types}.} other{Cette opération n’est pas disponible pour les fichiers aux formats suivants : {types}.}}",
|
||||
|
@ -178,14 +183,17 @@
|
|||
"renameEntryDialogLabel": "Nouveau nom",
|
||||
|
||||
"editEntryDateDialogTitle": "Date & Heure",
|
||||
"editEntryDateDialogSet": "Régler",
|
||||
"editEntryDateDialogShift": "Décaler",
|
||||
"editEntryDateDialogSetCustom": "Régler une date personnalisée",
|
||||
"editEntryDateDialogCopyField": "Copier d'une autre date",
|
||||
"editEntryDateDialogExtractFromTitle": "Extraire du titre",
|
||||
"editEntryDateDialogClear": "Effacer",
|
||||
"editEntryDateDialogFieldSelection": "Champs affectés",
|
||||
"editEntryDateDialogShift": "Décaler",
|
||||
"editEntryDateDialogSourceFileModifiedDate": "Date de modification du fichier",
|
||||
"editEntryDateDialogTargetFieldsHeader": "Champs à modifier",
|
||||
"editEntryDateDialogHours": "Heures",
|
||||
"editEntryDateDialogMinutes": "Minutes",
|
||||
|
||||
"editEntryRatingDialogTitle": "Notation",
|
||||
|
||||
"removeEntryMetadataDialogTitle": "Retrait de métadonnées",
|
||||
"removeEntryMetadataDialogMore": "Voir plus",
|
||||
|
||||
|
@ -270,6 +278,7 @@
|
|||
"collectionSortDate": "par date",
|
||||
"collectionSortSize": "par taille",
|
||||
"collectionSortName": "alphabétique",
|
||||
"collectionSortRating": "par notation",
|
||||
|
||||
"collectionGroupAlbum": "par album",
|
||||
"collectionGroupMonth": "par mois",
|
||||
|
@ -343,6 +352,7 @@
|
|||
"searchSectionCountries": "Pays",
|
||||
"searchSectionPlaces": "Lieux",
|
||||
"searchSectionTags": "Libellés",
|
||||
"searchSectionRating": "Notations",
|
||||
|
||||
"settingsPageTitle": "Réglages",
|
||||
"settingsSystemDefault": "Système",
|
||||
|
@ -366,8 +376,10 @@
|
|||
"settingsNavigationDrawerAddAlbum": "Ajouter un album",
|
||||
|
||||
"settingsSectionThumbnails": "Vignettes",
|
||||
"settingsThumbnailShowFavouriteIcon": "Afficher l’icône de favori",
|
||||
"settingsThumbnailShowLocationIcon": "Afficher l’icône de lieu",
|
||||
"settingsThumbnailShowMotionPhotoIcon": "Afficher l’icône de photo animée",
|
||||
"settingsThumbnailShowRating": "Afficher la notation",
|
||||
"settingsThumbnailShowRawIcon": "Afficher l’icône de photo raw",
|
||||
"settingsThumbnailShowVideoDuration": "Afficher la durée de la vidéo",
|
||||
|
||||
|
|
|
@ -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": "동영상 길이 표시",
|
||||
|
||||
|
|
|
@ -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": "Показывать продолжительность видео",
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
|
|
309
lib/model/entry_metadata_edition.dart
Normal file
309
lib/model/entry_metadata_edition.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
64
lib/model/filters/rating.dart
Normal file
64
lib/model/filters/rating.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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}';
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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;');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -9,6 +9,7 @@ import 'package:aves/model/filters/favourite.dart';
|
|||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/filters/location.dart';
|
||||
import 'package:aves/model/filters/query.dart';
|
||||
import 'package:aves/model/filters/rating.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/events.dart';
|
||||
|
@ -108,15 +109,27 @@ class CollectionLens with ChangeNotifier {
|
|||
}
|
||||
|
||||
bool get showHeaders {
|
||||
if (sortFactor == EntrySortFactor.size) return false;
|
||||
bool showAlbumHeaders() => !filters.any((f) => f is AlbumFilter);
|
||||
|
||||
if (sortFactor == EntrySortFactor.date && sectionFactor == EntryGroupFactor.none) return false;
|
||||
|
||||
final albumSections = sortFactor == EntrySortFactor.name || (sortFactor == EntrySortFactor.date && sectionFactor == EntryGroupFactor.album);
|
||||
final filterByAlbum = filters.any((f) => f is AlbumFilter);
|
||||
if (albumSections && filterByAlbum) return false;
|
||||
|
||||
return true;
|
||||
switch (sortFactor) {
|
||||
case EntrySortFactor.date:
|
||||
switch (sectionFactor) {
|
||||
case EntryGroupFactor.none:
|
||||
return false;
|
||||
case EntryGroupFactor.album:
|
||||
return showAlbumHeaders();
|
||||
case EntryGroupFactor.month:
|
||||
return true;
|
||||
case EntryGroupFactor.day:
|
||||
return true;
|
||||
}
|
||||
case EntrySortFactor.name:
|
||||
return showAlbumHeaders();
|
||||
case EntrySortFactor.rating:
|
||||
return !filters.any((f) => f is RatingFilter);
|
||||
case EntrySortFactor.size:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void addFilter(CollectionFilter filter) {
|
||||
|
@ -181,12 +194,15 @@ class CollectionLens with ChangeNotifier {
|
|||
case EntrySortFactor.date:
|
||||
_filteredSortedEntries.sort(AvesEntry.compareByDate);
|
||||
break;
|
||||
case EntrySortFactor.size:
|
||||
_filteredSortedEntries.sort(AvesEntry.compareBySize);
|
||||
break;
|
||||
case EntrySortFactor.name:
|
||||
_filteredSortedEntries.sort(AvesEntry.compareByName);
|
||||
break;
|
||||
case EntrySortFactor.rating:
|
||||
_filteredSortedEntries.sort(AvesEntry.compareByRating);
|
||||
break;
|
||||
case EntrySortFactor.size:
|
||||
_filteredSortedEntries.sort(AvesEntry.compareBySize);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -210,15 +226,18 @@ class CollectionLens with ChangeNotifier {
|
|||
break;
|
||||
}
|
||||
break;
|
||||
case EntrySortFactor.name:
|
||||
final byAlbum = groupBy<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;
|
||||
|
|
|
@ -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 }
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
40
lib/services/metadata/xmp.dart
Normal file
40
lib/services/metadata/xmp.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
|
|
|
@ -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
289
lib/utils/xmp_utils.dart
Normal 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 }
|
|
@ -8,6 +8,7 @@ class AboutCredits extends StatelessWidget {
|
|||
|
||||
static const translators = {
|
||||
'Deutsch': 'JanWaldhorn',
|
||||
'Español (México)': 'n-berenice',
|
||||
'Русский': 'D3ZOXY',
|
||||
};
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -20,6 +20,8 @@ import 'package:aves/widgets/common/app_bar_subtitle.dart';
|
|||
import 'package:aves/widgets/common/app_bar_title.dart';
|
||||
import 'package:aves/widgets/common/basic/menu.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/favourite_toggler.dart';
|
||||
import 'package:aves/widgets/common/sliver_app_bar_title.dart';
|
||||
import 'package:aves/widgets/dialogs/tile_view_dialog.dart';
|
||||
import 'package:aves/widgets/search/search_delegate.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
@ -101,48 +103,42 @@ class _CollectionAppBarState extends State<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;
|
||||
_isSelectingNotifier.value = isSelecting;
|
||||
return AnimatedBuilder(
|
||||
animation: collection.filterChangeNotifier,
|
||||
builder: (context, child) {
|
||||
final removableFilters = appMode != AppMode.pickInternal;
|
||||
return Selector<Query, bool>(
|
||||
selector: (context, query) => query.enabled,
|
||||
builder: (context, queryEnabled, child) {
|
||||
return SliverAppBar(
|
||||
leading: appMode.hasDrawer ? _buildAppBarLeading(isSelecting) : null,
|
||||
title: _buildAppBarTitle(isSelecting),
|
||||
actions: _buildActions(
|
||||
isSelecting: isSelecting,
|
||||
selectedItemCount: selectedItemCount,
|
||||
),
|
||||
bottom: PreferredSize(
|
||||
preferredSize: Size.fromHeight(appBarBottomHeight),
|
||||
child: Column(
|
||||
children: [
|
||||
if (showFilterBar)
|
||||
FilterBar(
|
||||
filters: collection.filters.where((v) => !(v is QueryFilter && v.live)).toSet(),
|
||||
removable: removableFilters,
|
||||
onTap: removableFilters ? collection.removeFilter : null,
|
||||
),
|
||||
if (queryEnabled)
|
||||
EntryQueryBar(
|
||||
queryNotifier: context.select<Query, ValueNotifier<String>>((query) => query.queryNotifier),
|
||||
focusNode: _queryBarFocusNode,
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
titleSpacing: 0,
|
||||
floating: true,
|
||||
);
|
||||
},
|
||||
final selection = context.watch<Selection<AvesEntry>>();
|
||||
final isSelecting = selection.isSelecting;
|
||||
_isSelectingNotifier.value = isSelecting;
|
||||
return AnimatedBuilder(
|
||||
animation: collection.filterChangeNotifier,
|
||||
builder: (context, child) {
|
||||
final removableFilters = appMode != AppMode.pickInternal;
|
||||
return Selector<Query, bool>(
|
||||
selector: (context, query) => query.enabled,
|
||||
builder: (context, queryEnabled, child) {
|
||||
return SliverAppBar(
|
||||
leading: appMode.hasDrawer ? _buildAppBarLeading(isSelecting) : null,
|
||||
title: SliverAppBarTitleWrapper(
|
||||
child: _buildAppBarTitle(isSelecting),
|
||||
),
|
||||
actions: _buildActions(selection),
|
||||
bottom: PreferredSize(
|
||||
preferredSize: Size.fromHeight(appBarBottomHeight),
|
||||
child: Column(
|
||||
children: [
|
||||
if (showFilterBar)
|
||||
FilterBar(
|
||||
filters: collection.filters.where((v) => !(v is QueryFilter && v.live)).toSet(),
|
||||
removable: removableFilters,
|
||||
onTap: removableFilters ? collection.removeFilter : null,
|
||||
),
|
||||
if (queryEnabled)
|
||||
EntryQueryBar(
|
||||
queryNotifier: context.select<Query, ValueNotifier<String>>((query) => query.queryNotifier),
|
||||
focusNode: _queryBarFocusNode,
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
titleSpacing: 0,
|
||||
floating: true,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
@ -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,
|
||||
|
|
|
@ -2,6 +2,7 @@ import 'dart:async';
|
|||
|
||||
import 'package:aves/app_mode.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/favourites.dart';
|
||||
import 'package:aves/model/filters/favourite.dart';
|
||||
import 'package:aves/model/filters/mime.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
|
@ -103,13 +104,18 @@ class _CollectionGridContent extends StatelessWidget {
|
|||
columnCount: columnCount,
|
||||
spacing: tileSpacing,
|
||||
tileExtent: thumbnailExtent,
|
||||
tileBuilder: (entry) => InteractiveTile(
|
||||
key: ValueKey(entry.contentId),
|
||||
collection: collection,
|
||||
entry: entry,
|
||||
thumbnailExtent: thumbnailExtent,
|
||||
tileLayout: tileLayout,
|
||||
isScrollingNotifier: _isScrollingNotifier,
|
||||
tileBuilder: (entry) => AnimatedBuilder(
|
||||
animation: favourites,
|
||||
builder: (context, child) {
|
||||
return InteractiveTile(
|
||||
key: ValueKey(entry.contentId),
|
||||
collection: collection,
|
||||
entry: entry,
|
||||
thumbnailExtent: thumbnailExtent,
|
||||
tileLayout: tileLayout,
|
||||
isScrollingNotifier: _isScrollingNotifier,
|
||||
);
|
||||
},
|
||||
),
|
||||
tileAnimationDelay: tileAnimationDelay,
|
||||
child: child!,
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
21
lib/widgets/collection/grid/headers/rating.dart
Normal file
21
lib/widgets/collection/grid/headers/rating.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
82
lib/widgets/common/basic/wheel.dart
Normal file
82
lib/widgets/common/basic/wheel.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
87
lib/widgets/common/favourite_toggler.dart
Normal file
87
lib/widgets/common/favourite_toggler.dart
Normal 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);
|
||||
}
|
||||
}
|
|
@ -316,7 +316,6 @@ class _ScaleOverlayState extends State<_ScaleOverlay> {
|
|||
colors: const [
|
||||
Colors.black,
|
||||
Colors.black54,
|
||||
// Colors.amber,
|
||||
],
|
||||
),
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
23
lib/widgets/common/sliver_app_bar_title.dart
Normal file
23
lib/widgets/common/sliver_app_bar_title.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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 ...[
|
||||
|
|
|
@ -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,53 +45,64 @@ class _DebugAndroidAppSectionState extends State<DebugAndroidAppSection> with Au
|
|||
final disabledTheme = enabledTheme.merge(const IconThemeData(opacity: .2));
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: packages.map((package) {
|
||||
return Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: Image(
|
||||
image: AppIconImage(
|
||||
packageName: package.packageName,
|
||||
size: iconSize,
|
||||
),
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
children: [
|
||||
QueryBar(queryNotifier: _queryNotifier),
|
||||
...packages.map((package) {
|
||||
return ValueListenableBuilder<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: [
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: Image(
|
||||
image: AppIconImage(
|
||||
packageName: package.packageName,
|
||||
size: iconSize,
|
||||
),
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: ' ${package.packageName}\n',
|
||||
style: InfoRowGroup.keyStyle,
|
||||
),
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: IconTheme(
|
||||
data: package.categoryLauncher ? enabledTheme : disabledTheme,
|
||||
child: const Icon(
|
||||
Icons.launch_outlined,
|
||||
size: iconSize,
|
||||
),
|
||||
),
|
||||
),
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: IconTheme(
|
||||
data: package.isSystem ? enabledTheme : disabledTheme,
|
||||
child: const Icon(
|
||||
Icons.android,
|
||||
size: iconSize,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: ' ${package.potentialDirs.join(', ')}\n',
|
||||
style: InfoRowGroup.baseStyle,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: ' ${package.packageName}\n',
|
||||
style: InfoRowGroup.keyStyle,
|
||||
),
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: IconTheme(
|
||||
data: package.categoryLauncher ? enabledTheme : disabledTheme,
|
||||
child: const Icon(
|
||||
Icons.launch_outlined,
|
||||
size: iconSize,
|
||||
),
|
||||
),
|
||||
),
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: IconTheme(
|
||||
data: package.isSystem ? enabledTheme : disabledTheme,
|
||||
child: const Icon(
|
||||
Icons.android,
|
||||
size: iconSize,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: ' ${package.potentialDirs.join(', ')}\n',
|
||||
style: InfoRowGroup.baseStyle,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
);
|
||||
})
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
|
@ -4,13 +4,13 @@ import 'package:aves/model/metadata/enums.dart';
|
|||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/theme/format.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/widgets/common/basic/wheel.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../aves_dialog.dart';
|
||||
|
||||
class EditEntryDateDialog extends StatefulWidget {
|
||||
final AvesEntry entry;
|
||||
|
||||
|
@ -24,170 +24,274 @@ class EditEntryDateDialog extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _EditEntryDateDialogState extends State<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)),
|
||||
return AvesDialog(
|
||||
title: l10n.editEntryDateDialogTitle,
|
||||
scrollableContent: [
|
||||
Padding(
|
||||
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,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(end: 12),
|
||||
child: IconButton(
|
||||
icon: const Icon(AIcons.edit),
|
||||
onPressed: _action == DateEditAction.set ? _editDate : null,
|
||||
tooltip: l10n.changeTooltip,
|
||||
AnimatedSwitcher(
|
||||
duration: context.read<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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
final shiftTile = Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: RadioListTile<DateEditAction>(
|
||||
value: DateEditAction.shift,
|
||||
groupValue: _action,
|
||||
onChanged: _updateAction,
|
||||
title: _tileText(l10n.editEntryDateDialogShift),
|
||||
subtitle: Text(_formatShiftDuration()),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(end: 12),
|
||||
child: IconButton(
|
||||
icon: const Icon(AIcons.edit),
|
||||
onPressed: _action == DateEditAction.shift ? _editShift : null,
|
||||
tooltip: l10n.changeTooltip,
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => _submit(context),
|
||||
child: Text(l10n.applyButtonLabel),
|
||||
),
|
||||
],
|
||||
);
|
||||
final extractFromTitleTile = RadioListTile<DateEditAction>(
|
||||
value: DateEditAction.extractFromTitle,
|
||||
groupValue: _action,
|
||||
onChanged: _updateAction,
|
||||
title: _tileText(l10n.editEntryDateDialogExtractFromTitle),
|
||||
);
|
||||
final clearTile = RadioListTile<DateEditAction>(
|
||||
value: DateEditAction.clear,
|
||||
groupValue: _action,
|
||||
onChanged: _updateAction,
|
||||
title: _tileText(l10n.editEntryDateDialogClear),
|
||||
);
|
||||
|
||||
final animationDuration = context.select<DurationsData, Duration>((v) => v.expansionTileAnimation);
|
||||
final theme = Theme.of(context);
|
||||
return Theme(
|
||||
data: theme.copyWith(
|
||||
textTheme: theme.textTheme.copyWith(
|
||||
// dense style font for tile subtitles, without modifying title font
|
||||
bodyText2: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
child: AvesDialog(
|
||||
title: l10n.editEntryDateDialogTitle,
|
||||
scrollableContent: [
|
||||
setTile,
|
||||
shiftTile,
|
||||
extractFromTitleTile,
|
||||
clearTile,
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 1),
|
||||
child: ExpansionPanelList(
|
||||
expansionCallback: (index, isExpanded) {
|
||||
setState(() => _showOptions = !isExpanded);
|
||||
},
|
||||
animationDuration: animationDuration,
|
||||
expandedHeaderPadding: EdgeInsets.zero,
|
||||
elevation: 0,
|
||||
children: [
|
||||
ExpansionPanel(
|
||||
headerBuilder: (context, isExpanded) => ListTile(
|
||||
title: Text(l10n.editEntryDateDialogFieldSelection),
|
||||
),
|
||||
body: Column(
|
||||
children: DateModifier.allDateFields
|
||||
.map((field) => SwitchListTile(
|
||||
value: _fields.contains(field),
|
||||
onChanged: (selected) => setState(() => selected ? _fields.add(field) : _fields.remove(field)),
|
||||
title: Text(_fieldTitle(field)),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
isExpanded: _showOptions,
|
||||
canTapOnHeader: true,
|
||||
backgroundColor: Theme.of(context).dialogBackgroundColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => _submit(context),
|
||||
child: Text(l10n.applyButtonLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatShiftDuration() {
|
||||
final abs = _shiftMinutes.abs();
|
||||
final h = abs ~/ 60;
|
||||
final m = abs % 60;
|
||||
return '${_shiftMinutes.isNegative ? '-' : '+'}$h:${m.toString().padLeft(2, '0')}';
|
||||
Widget _formTransitionBuilder(Widget child, Animation<double> animation) => FadeTransition(
|
||||
opacity: animation,
|
||||
child: SizeTransition(
|
||||
sizeFactor: animation,
|
||||
axisAlignment: -1,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
|
||||
Widget _buildSetCustomContent(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final locale = l10n.localeName;
|
||||
final use24hour = context.select<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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildShiftContent(BuildContext context) {
|
||||
const textStyle = TextStyle(fontSize: 34);
|
||||
return Center(
|
||||
child: Table(
|
||||
children: [
|
||||
TableRow(
|
||||
children: [
|
||||
const SizedBox(),
|
||||
Center(child: Text(context.l10n.editEntryDateDialogHours)),
|
||||
const SizedBox(),
|
||||
Center(child: Text(context.l10n.editEntryDateDialogMinutes)),
|
||||
],
|
||||
),
|
||||
TableRow(
|
||||
children: [
|
||||
WheelSelector(
|
||||
valueNotifier: _shiftSign,
|
||||
values: const ['+', '-'],
|
||||
textStyle: textStyle,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: WheelSelector(
|
||||
valueNotifier: _shiftHour,
|
||||
values: List.generate(24, (i) => i),
|
||||
textStyle: textStyle,
|
||||
textAlign: TextAlign.end,
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(bottom: 2),
|
||||
child: Text(
|
||||
':',
|
||||
style: textStyle,
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: WheelSelector(
|
||||
valueNotifier: _shiftMinute,
|
||||
values: List.generate(60, (i) => i),
|
||||
textStyle: textStyle,
|
||||
textAlign: TextAlign.end,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
defaultColumnWidth: const IntrinsicColumnWidth(),
|
||||
defaultVerticalAlignment: TableCellVerticalAlignment.middle,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDestinationFields(BuildContext context) {
|
||||
return Padding(
|
||||
// small padding as a workaround to show dialog action divider
|
||||
padding: const EdgeInsets.only(bottom: 1),
|
||||
child: ExpansionPanelList(
|
||||
expansionCallback: (index, isExpanded) {
|
||||
setState(() => _showOptions = !isExpanded);
|
||||
},
|
||||
animationDuration: context.read<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) {
|
||||
|
@ -200,13 +304,15 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
|
|||
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: _dateTime,
|
||||
initialDate: _setDateTime,
|
||||
firstDate: DateTime(0),
|
||||
lastDate: DateTime.now(),
|
||||
confirmText: context.l10n.nextButtonLabel,
|
||||
|
@ -215,11 +321,11 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
|
|||
|
||||
final _time = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: TimeOfDay.fromDateTime(_dateTime),
|
||||
initialTime: TimeOfDay.fromDateTime(_setDateTime),
|
||||
);
|
||||
if (_time == null) return;
|
||||
|
||||
setState(() => _dateTime = DateTime(
|
||||
setState(() => _setDateTime = DateTime(
|
||||
_date.year,
|
||||
_date.month,
|
||||
_date.day,
|
||||
|
@ -228,214 +334,24 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
|
|||
));
|
||||
}
|
||||
|
||||
void _editShift() async {
|
||||
final picked = await showDialog<int>(
|
||||
context: context,
|
||||
builder: (context) => TimeShiftDialog(
|
||||
initialShiftMinutes: _shiftMinutes,
|
||||
),
|
||||
);
|
||||
if (picked == null) return;
|
||||
|
||||
setState(() => _shiftMinutes = picked);
|
||||
}
|
||||
|
||||
void _submit(BuildContext context) {
|
||||
late DateModifier modifier;
|
||||
DateModifier _getModifier() {
|
||||
// fields to modify are only set for the `shift` and `remove` actions,
|
||||
// as the effective fields for the other actions will depend on
|
||||
// whether each item supports Exif edition
|
||||
switch (_action) {
|
||||
case DateEditAction.set:
|
||||
modifier = DateModifier(_action, _fields, dateTime: _dateTime);
|
||||
break;
|
||||
case DateEditAction.shift:
|
||||
modifier = DateModifier(_action, _fields, shiftMinutes: _shiftMinutes);
|
||||
break;
|
||||
case DateEditAction.setCustom:
|
||||
return DateModifier.setCustom(const {}, _setDateTime);
|
||||
case DateEditAction.copyField:
|
||||
return DateModifier.copyField(const {}, _copyFieldSource);
|
||||
case DateEditAction.extractFromTitle:
|
||||
case DateEditAction.clear:
|
||||
modifier = DateModifier(_action, _fields);
|
||||
break;
|
||||
return DateModifier.extractFromTitle(const {});
|
||||
case DateEditAction.shift:
|
||||
final shiftTotalMinutes = (_shiftHour.value * 60 + _shiftMinute.value) * (_shiftSign.value == '+' ? 1 : -1);
|
||||
return DateModifier.shift(_fields, shiftTotalMinutes);
|
||||
case DateEditAction.remove:
|
||||
return DateModifier.remove(_fields);
|
||||
}
|
||||
Navigator.pop(context, modifier);
|
||||
}
|
||||
}
|
||||
|
||||
class TimeShiftDialog extends StatefulWidget {
|
||||
final int initialShiftMinutes;
|
||||
|
||||
const TimeShiftDialog({
|
||||
Key? key,
|
||||
required this.initialShiftMinutes,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
_TimeShiftDialogState createState() => _TimeShiftDialogState();
|
||||
}
|
||||
|
||||
class _TimeShiftDialogState extends State<TimeShiftDialog> {
|
||||
late ValueNotifier<int> _hour, _minute;
|
||||
late ValueNotifier<String> _sign;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final initial = widget.initialShiftMinutes;
|
||||
final abs = initial.abs();
|
||||
_hour = ValueNotifier(abs ~/ 60);
|
||||
_minute = ValueNotifier(abs % 60);
|
||||
_sign = ValueNotifier(initial.isNegative ? '-' : '+');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const textStyle = TextStyle(fontSize: 34);
|
||||
return AvesDialog(
|
||||
scrollableContent: [
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Table(
|
||||
children: [
|
||||
TableRow(
|
||||
children: [
|
||||
const SizedBox(),
|
||||
Center(child: Text(context.l10n.editEntryDateDialogHours)),
|
||||
const SizedBox(),
|
||||
Center(child: Text(context.l10n.editEntryDateDialogMinutes)),
|
||||
],
|
||||
),
|
||||
TableRow(
|
||||
children: [
|
||||
_Wheel(
|
||||
valueNotifier: _sign,
|
||||
values: const ['+', '-'],
|
||||
textStyle: textStyle,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: _Wheel(
|
||||
valueNotifier: _hour,
|
||||
values: List.generate(24, (i) => i),
|
||||
textStyle: textStyle,
|
||||
textAlign: TextAlign.end,
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(bottom: 2),
|
||||
child: Text(
|
||||
':',
|
||||
style: textStyle,
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: _Wheel(
|
||||
valueNotifier: _minute,
|
||||
values: List.generate(60, (i) => i),
|
||||
textStyle: textStyle,
|
||||
textAlign: TextAlign.end,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
defaultColumnWidth: const IntrinsicColumnWidth(),
|
||||
defaultVerticalAlignment: TableCellVerticalAlignment.middle,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
hasScrollBar: false,
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, (_hour.value * 60 + _minute.value) * (_sign.value == '+' ? 1 : -1)),
|
||||
child: Text(MaterialLocalizations.of(context).okButtonLabel),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Wheel<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!;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: SizedBox(
|
||||
width: itemSize.width,
|
||||
height: itemSize.height * 3,
|
||||
child: ShaderMask(
|
||||
shaderCallback: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
background,
|
||||
foreground,
|
||||
foreground,
|
||||
background,
|
||||
],
|
||||
).createShader,
|
||||
child: ListWheelScrollView(
|
||||
controller: _controller,
|
||||
physics: const FixedExtentScrollPhysics(parent: BouncingScrollPhysics()),
|
||||
diameterRatio: 1.2,
|
||||
itemExtent: itemSize.height,
|
||||
squeeze: 1.3,
|
||||
onSelectedItemChanged: (i) => valueNotifier.value = values[i],
|
||||
children: values
|
||||
.map((i) => SizedBox.fromSize(
|
||||
size: itemSize,
|
||||
child: Text(
|
||||
'$i',
|
||||
textAlign: widget.textAlign,
|
||||
style: widget.textStyle,
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
void _submit(BuildContext context) => Navigator.pop(context, _getModifier());
|
||||
}
|
||||
|
|
136
lib/widgets/dialogs/entry_editors/edit_entry_rating_dialog.dart
Normal file
136
lib/widgets/dialogs/entry_editors/edit_entry_rating_dialog.dart
Normal 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 }
|
|
@ -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,
|
||||
|
|
|
@ -69,7 +69,7 @@ class _RemoveEntryMetadataDialogState extends State<RemoveEntryMetadataDialog> {
|
|||
),
|
||||
isExpanded: _showMore,
|
||||
canTapOnHeader: true,
|
||||
backgroundColor: Theme.of(context).dialogBackgroundColor,
|
||||
backgroundColor: Colors.transparent,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
@ -16,6 +16,7 @@ import 'package:aves/widgets/common/extensions/build_context.dart';
|
|||
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
|
||||
import 'package:aves/widgets/common/identity/empty.dart';
|
||||
import 'package:aves/widgets/common/providers/selection_provider.dart';
|
||||
import 'package:aves/widgets/common/sliver_app_bar_title.dart';
|
||||
import 'package:aves/widgets/dialogs/filter_editors/create_album_dialog.dart';
|
||||
import 'package:aves/widgets/filter_grids/albums_page.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/action_delegates/album_set.dart';
|
||||
|
@ -141,9 +142,11 @@ class _AlbumPickAppBar extends StatelessWidget {
|
|||
|
||||
return SliverAppBar(
|
||||
leading: const BackButton(),
|
||||
title: SourceStateAwareAppBarTitle(
|
||||
title: Text(title()),
|
||||
source: source,
|
||||
title: SliverAppBarTitleWrapper(
|
||||
child: SourceStateAwareAppBarTitle(
|
||||
title: Text(title()),
|
||||
source: source,
|
||||
),
|
||||
),
|
||||
bottom: _AlbumQueryBar(
|
||||
queryNotifier: queryNotifier,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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(),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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':
|
||||
|
|
|
@ -43,7 +43,7 @@ class _DrawerAlbumTabState extends State<DrawerAlbumTab> {
|
|||
onPressed: () {
|
||||
setState(() => widget.items.remove(album));
|
||||
},
|
||||
tooltip: context.l10n.removeTooltip,
|
||||
tooltip: context.l10n.actionRemove,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
|
@ -154,7 +154,7 @@ class _HiddenPaths extends StatelessWidget {
|
|||
onPressed: () {
|
||||
context.read<CollectionSource>().changeFilterVisibility({pathFilter}, true);
|
||||
},
|
||||
tooltip: context.l10n.removeTooltip,
|
||||
tooltip: context.l10n.actionRemove,
|
||||
),
|
||||
)),
|
||||
],
|
||||
|
|
|
@ -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,64 +33,119 @@ class ThumbnailsSection extends StatelessWidget {
|
|||
showHighlight: false,
|
||||
children: [
|
||||
const CollectionActionsTile(),
|
||||
SwitchListTile(
|
||||
value: currentShowThumbnailLocation,
|
||||
onChanged: (v) => settings.showThumbnailLocation = v,
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(child: Text(context.l10n.settingsThumbnailShowLocationIcon)),
|
||||
AnimatedOpacity(
|
||||
opacity: opacityFor(currentShowThumbnailLocation),
|
||||
duration: Durations.toggleableTransitionAnimation,
|
||||
child: Icon(
|
||||
AIcons.location,
|
||||
size: iconSize,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: currentShowThumbnailMotionPhoto,
|
||||
onChanged: (v) => settings.showThumbnailMotionPhoto = v,
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(child: Text(context.l10n.settingsThumbnailShowMotionPhotoIcon)),
|
||||
AnimatedOpacity(
|
||||
opacity: opacityFor(currentShowThumbnailMotionPhoto),
|
||||
duration: Durations.toggleableTransitionAnimation,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: iconSize * (1 - MotionPhotoIcon.scale) / 2),
|
||||
child: Icon(
|
||||
AIcons.motionPhoto,
|
||||
size: iconSize * MotionPhotoIcon.scale,
|
||||
Selector<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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: currentShowThumbnailRaw,
|
||||
onChanged: (v) => settings.showThumbnailRaw = v,
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(child: Text(context.l10n.settingsThumbnailShowRawIcon)),
|
||||
AnimatedOpacity(
|
||||
opacity: opacityFor(currentShowThumbnailRaw),
|
||||
duration: Durations.toggleableTransitionAnimation,
|
||||
child: Icon(
|
||||
AIcons.raw,
|
||||
size: iconSize,
|
||||
Selector<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(current),
|
||||
duration: Durations.toggleableTransitionAnimation,
|
||||
child: Icon(
|
||||
AIcons.location,
|
||||
size: iconSize,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: currentShowThumbnailVideoDuration,
|
||||
onChanged: (v) => settings.showThumbnailVideoDuration = v,
|
||||
title: Text(context.l10n.settingsThumbnailShowVideoDuration),
|
||||
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(current),
|
||||
duration: Durations.toggleableTransitionAnimation,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: iconSize * (1 - MotionPhotoIcon.scale) / 2),
|
||||
child: Icon(
|
||||
AIcons.motionPhoto,
|
||||
size: iconSize * MotionPhotoIcon.scale,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
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(current),
|
||||
duration: Durations.toggleableTransitionAnimation,
|
||||
child: Icon(
|
||||
AIcons.raw,
|
||||
size: iconSize,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
];
|
||||
|
|
|
@ -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(
|
|
@ -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));
|
||||
}
|
||||
}
|
48
lib/widgets/viewer/action/single_entry_editor.dart
Normal file
48
lib/widgets/viewer/action/single_entry_editor.dart
Normal 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();
|
||||
}
|
||||
}
|
|
@ -123,6 +123,7 @@ class _DbTabState extends State<DbTab> {
|
|||
'longitude': '${data.longitude}',
|
||||
'xmpSubjects': data.xmpSubjects ?? '',
|
||||
'xmpTitleDescription': data.xmpTitleDescription ?? '',
|
||||
'rating': '${data.rating}',
|
||||
},
|
||||
),
|
||||
],
|
||||
|
|
|
@ -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(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AvesFilterChip.outlineWidth / 2) + const EdgeInsets.only(top: 8),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: children,
|
||||
),
|
||||
);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AvesFilterChip.outlineWidth / 2) + const EdgeInsets.only(top: 8),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: effectiveFilters
|
||||
.map((filter) => AvesFilterChip(
|
||||
filter: filter,
|
||||
onTap: onFilter,
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEditTagButton(BuildContext context) {
|
||||
const action = EntryInfoAction.editTags;
|
||||
return ValueListenableBuilder<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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
|
|
@ -5,7 +5,8 @@ import 'package:aves/theme/icons.dart';
|
|||
import 'package:aves/widgets/common/app_bar_title.dart';
|
||||
import 'package:aves/widgets/common/basic/menu.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/viewer/info/entry_info_action_delegate.dart';
|
||||
import 'package:aves/widgets/common/sliver_app_bar_title.dart';
|
||||
import 'package:aves/widgets/viewer/action/entry_info_action_delegate.dart';
|
||||
import 'package:aves/widgets/viewer/info/info_search.dart';
|
||||
import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
@ -37,9 +38,11 @@ class InfoAppBar extends StatelessWidget {
|
|||
onPressed: onBackPressed,
|
||||
tooltip: context.l10n.viewerInfoBackToViewerTooltip,
|
||||
),
|
||||
title: InteractiveAppBarTitle(
|
||||
onTap: () => _goToSearch(context),
|
||||
child: Text(context.l10n.viewerInfoPageTitle),
|
||||
title: SliverAppBarTitleWrapper(
|
||||
child: InteractiveAppBarTitle(
|
||||
onTap: () => _goToSearch(context),
|
||||
child: Text(context.l10n.viewerInfoPageTitle),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
|
|
|
@ -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,15 +255,13 @@ 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;
|
||||
} else if (event is ActionEndedEvent) {
|
||||
_isEditingTagNotifier.value = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
Future.delayed(Durations.dialogTransitionAnimation).then((_) {
|
||||
if (event is ActionStartedEvent) {
|
||||
_isEditingMetadataNotifier.value = event.action;
|
||||
} else if (event is ActionEndedEvent) {
|
||||
_isEditingMetadataNotifier.value = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _goToCollection(CollectionFilter filter) {
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue