#1 edit tags via XMP & IPTC, for JPEG, GIF, PNG, TIFF

This commit is contained in:
Thibault Deckers 2021-11-22 12:27:40 +09:00
parent f1aefb2bb1
commit fbcd8ad208
54 changed files with 1387 additions and 130 deletions

View file

@ -149,7 +149,7 @@ dependencies {
// forked, built by JitPack, cf https://jitpack.io/p/deckerst/Android-TiffBitmapFactory // forked, built by JitPack, cf https://jitpack.io/p/deckerst/Android-TiffBitmapFactory
implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a' implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a'
// forked, built by JitPack, cf https://jitpack.io/p/deckerst/pixymeta-android // forked, built by JitPack, cf https://jitpack.io/p/deckerst/pixymeta-android
implementation 'com.github.deckerst:pixymeta-android:0bea51ead2' implementation 'com.github.deckerst:pixymeta-android:a86b1b8e4c'
implementation 'com.github.bumptech.glide:glide:4.12.0' implementation 'com.github.bumptech.glide:glide:4.12.0'
kapt 'androidx.annotation:annotation:1.3.0' kapt 'androidx.annotation:annotation:1.3.0'

View file

@ -20,6 +20,8 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
"rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) } "rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) }
"flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) } "flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) }
"editDate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::editDate) } "editDate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::editDate) }
"setIptc" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::setIptc) }
"setXmp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::setXmp) }
"removeTypes" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::removeTypes) } "removeTypes" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::removeTypes) }
else -> result.notImplemented() else -> result.notImplemented()
} }
@ -97,6 +99,64 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
}) })
} }
private fun setIptc(call: MethodCall, result: MethodChannel.Result) {
val iptc = call.argument<List<FieldMap>>("iptc")
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)
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("setIptc-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)
return
}
provider.setIptc(activity, path, uri, mimeType, postEditScan, iptc = iptc, 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)
})
}
private fun removeTypes(call: MethodCall, result: MethodChannel.Result) { private fun removeTypes(call: MethodCall, result: MethodChannel.Result) {
val types = call.argument<List<String>>("types") val types = call.argument<List<String>>("types")
val entryMap = call.argument<FieldMap>("entry") val entryMap = call.argument<FieldMap>("entry")

View file

@ -10,6 +10,8 @@ import android.provider.MediaStore
import android.util.Log import android.util.Log
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import com.adobe.internal.xmp.XMPException import com.adobe.internal.xmp.XMPException
import com.adobe.internal.xmp.XMPMetaFactory
import com.adobe.internal.xmp.options.SerializeOptions
import com.adobe.internal.xmp.properties.XMPPropertyInfo import com.adobe.internal.xmp.properties.XMPPropertyInfo
import com.drew.imaging.ImageMetadataReader import com.drew.imaging.ImageMetadataReader
import com.drew.lang.KeyValuePair import com.drew.lang.KeyValuePair
@ -84,6 +86,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
"getOverlayMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getOverlayMetadata) } "getOverlayMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getOverlayMetadata) }
"getMultiPageInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMultiPageInfo) } "getMultiPageInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMultiPageInfo) }
"getPanoramaInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPanoramaInfo) } "getPanoramaInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPanoramaInfo) }
"getIptc" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getIptc) }
"getXmp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getXmp) }
"hasContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::hasContentResolverProp) } "hasContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::hasContentResolverProp) }
"getContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverProp) } "getContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverProp) }
else -> result.notImplemented() else -> result.notImplemented()
@ -734,6 +738,59 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
result.error("getPanoramaInfo-empty", "failed to read XMP for mimeType=$mimeType uri=$uri", null) result.error("getPanoramaInfo-empty", "failed to read XMP for mimeType=$mimeType uri=$uri", null)
} }
private fun getIptc(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (mimeType == null || uri == null) {
result.error("getIptc-args", "failed because of missing arguments", null)
return
}
if (MimeTypes.canReadWithPixyMeta(mimeType)) {
try {
StorageUtils.openInputStream(context, uri)?.use { input ->
val iptcDataList = PixyMetaHelper.getIptc(input)
result.success(iptcDataList)
return
}
} catch (e: Exception) {
result.error("getIptc-exception", "failed to read IPTC for mimeType=$mimeType uri=$uri", e.message)
return
}
}
result.success(null)
}
private fun getXmp(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()
if (mimeType == null || uri == null) {
result.error("getXmp-args", "failed because of missing arguments", null)
return
}
if (canReadWithMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
val xmpStrings = metadata.getDirectoriesOfType(XmpDirectory::class.java).map { XMPMetaFactory.serializeToString(it.xmpMeta, xmpSerializeOptions) }.filterNotNull()
result.success(xmpStrings.toMutableList())
return
}
} catch (e: Exception) {
result.error("getXmp-exception", "failed to read XMP for mimeType=$mimeType uri=$uri", e.message)
return
} catch (e: NoClassDefFoundError) {
result.error("getXmp-error", "failed to read XMP for mimeType=$mimeType uri=$uri", e.message)
return
}
}
result.success(null)
}
private fun hasContentResolverProp(call: MethodCall, result: MethodChannel.Result) { private fun hasContentResolverProp(call: MethodCall, result: MethodChannel.Result) {
val prop = call.argument<String>("prop") val prop = call.argument<String>("prop")
if (prop == null) { if (prop == null) {
@ -829,6 +886,11 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
"XMP", "XMP",
) )
private val xmpSerializeOptions = SerializeOptions().apply {
omitPacketWrapper = true // e.g. <?xpacket begin="..." id="W5M0MpCehiHzreSzNTczkc9d"?>...<?xpacket end="r"?>
omitXmpMetaElement = false // e.g. <x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core Test.SNAPSHOT">...</x:xmpmeta>
}
// catalog metadata // catalog metadata
private const val KEY_MIME_TYPE = "mimeType" private const val KEY_MIME_TYPE = "mimeType"
private const val KEY_DATE_MILLIS = "dateMillis" private const val KEY_DATE_MILLIS = "dateMillis"

View file

@ -32,12 +32,12 @@ object Metadata {
const val DIR_PNG_TEXTUAL_DATA = "PNG Textual Data" // custom const val DIR_PNG_TEXTUAL_DATA = "PNG Textual Data" // custom
// types of metadata // types of metadata
const val TYPE_COMMENT = "comment"
const val TYPE_EXIF = "exif" const val TYPE_EXIF = "exif"
const val TYPE_ICC_PROFILE = "icc_profile" const val TYPE_ICC_PROFILE = "icc_profile"
const val TYPE_IPTC = "iptc" const val TYPE_IPTC = "iptc"
const val TYPE_JFIF = "jfif" const val TYPE_JFIF = "jfif"
const val TYPE_JPEG_ADOBE = "jpeg_adobe" const val TYPE_JPEG_ADOBE = "jpeg_adobe"
const val TYPE_JPEG_COMMENT = "jpeg_comment"
const val TYPE_JPEG_DUCKY = "jpeg_ducky" const val TYPE_JPEG_DUCKY = "jpeg_ducky"
const val TYPE_PHOTOSHOP_IRB = "photoshop_irb" const val TYPE_PHOTOSHOP_IRB = "photoshop_irb"
const val TYPE_XMP = "xmp" const val TYPE_XMP = "xmp"

View file

@ -1,17 +1,21 @@
package deckers.thibault.aves.metadata package deckers.thibault.aves.metadata
import deckers.thibault.aves.metadata.Metadata.TYPE_COMMENT
import deckers.thibault.aves.metadata.Metadata.TYPE_EXIF import deckers.thibault.aves.metadata.Metadata.TYPE_EXIF
import deckers.thibault.aves.metadata.Metadata.TYPE_ICC_PROFILE import deckers.thibault.aves.metadata.Metadata.TYPE_ICC_PROFILE
import deckers.thibault.aves.metadata.Metadata.TYPE_IPTC import deckers.thibault.aves.metadata.Metadata.TYPE_IPTC
import deckers.thibault.aves.metadata.Metadata.TYPE_JFIF import deckers.thibault.aves.metadata.Metadata.TYPE_JFIF
import deckers.thibault.aves.metadata.Metadata.TYPE_JPEG_ADOBE import deckers.thibault.aves.metadata.Metadata.TYPE_JPEG_ADOBE
import deckers.thibault.aves.metadata.Metadata.TYPE_JPEG_COMMENT
import deckers.thibault.aves.metadata.Metadata.TYPE_JPEG_DUCKY import deckers.thibault.aves.metadata.Metadata.TYPE_JPEG_DUCKY
import deckers.thibault.aves.metadata.Metadata.TYPE_PHOTOSHOP_IRB import deckers.thibault.aves.metadata.Metadata.TYPE_PHOTOSHOP_IRB
import deckers.thibault.aves.metadata.Metadata.TYPE_XMP import deckers.thibault.aves.metadata.Metadata.TYPE_XMP
import deckers.thibault.aves.model.FieldMap
import pixy.meta.meta.Metadata import pixy.meta.meta.Metadata
import pixy.meta.meta.MetadataEntry import pixy.meta.meta.MetadataEntry
import pixy.meta.meta.MetadataType import pixy.meta.meta.MetadataType
import pixy.meta.meta.iptc.IPTC
import pixy.meta.meta.iptc.IPTCDataSet
import pixy.meta.meta.iptc.IPTCRecord
import pixy.meta.meta.jpeg.JPGMeta import pixy.meta.meta.jpeg.JPGMeta
import pixy.meta.meta.xmp.XMP import pixy.meta.meta.xmp.XMP
import pixy.meta.string.XMLUtils import pixy.meta.string.XMLUtils
@ -50,9 +54,46 @@ object PixyMetaHelper {
return metadataMap return metadataMap
} }
fun getIptc(input: InputStream): List<FieldMap>? {
val iptc = Metadata.readMetadata(input)[MetadataType.IPTC] as IPTC? ?: return null
val iptcDataList = ArrayList<FieldMap>()
iptc.dataSets.forEach { dataSetEntry ->
val tag = dataSetEntry.key
val dataSets = dataSetEntry.value
iptcDataList.add(
hashMapOf(
"record" to tag.recordNumber,
"tag" to tag.tag,
"values" to dataSets.map { it.data }.toMutableList(),
)
)
}
return iptcDataList
}
fun setIptc(
input: InputStream,
output: OutputStream,
iptcDataList: List<FieldMap>?,
) {
val iptc = iptcDataList?.flatMap {
val record = it["record"] as Int
val tag = it["tag"] as Int
val values = it["values"] as List<*>
values.map { data -> IPTCDataSet(IPTCRecord.fromRecordNumber(record), tag, data as ByteArray) }
} ?: ArrayList<IPTCDataSet>()
Metadata.insertIPTC(input, output, iptc)
}
fun getXmp(input: InputStream): XMP? = Metadata.readMetadata(input)[MetadataType.XMP] as XMP? fun getXmp(input: InputStream): XMP? = Metadata.readMetadata(input)[MetadataType.XMP] as XMP?
fun setXmp(input: InputStream, output: OutputStream, xmpString: String, extendedXmpString: String?) { fun setXmp(
input: InputStream,
output: OutputStream,
xmpString: String?,
extendedXmpString: String?
) {
if (extendedXmpString != null) { if (extendedXmpString != null) {
JPGMeta.insertXMP(input, output, xmpString, extendedXmpString) JPGMeta.insertXMP(input, output, xmpString, extendedXmpString)
} else { } else {
@ -70,12 +111,12 @@ object PixyMetaHelper {
} }
private fun toMetadataType(typeString: String): MetadataType? = when (typeString) { private fun toMetadataType(typeString: String): MetadataType? = when (typeString) {
TYPE_COMMENT -> MetadataType.COMMENT
TYPE_EXIF -> MetadataType.EXIF TYPE_EXIF -> MetadataType.EXIF
TYPE_ICC_PROFILE -> MetadataType.ICC_PROFILE TYPE_ICC_PROFILE -> MetadataType.ICC_PROFILE
TYPE_IPTC -> MetadataType.IPTC TYPE_IPTC -> MetadataType.IPTC
TYPE_JFIF -> MetadataType.JPG_JFIF TYPE_JFIF -> MetadataType.JPG_JFIF
TYPE_JPEG_ADOBE -> MetadataType.JPG_ADOBE TYPE_JPEG_ADOBE -> MetadataType.JPG_ADOBE
TYPE_JPEG_COMMENT -> MetadataType.COMMENT
TYPE_JPEG_DUCKY -> MetadataType.JPG_DUCKY TYPE_JPEG_DUCKY -> MetadataType.JPG_DUCKY
TYPE_PHOTOSHOP_IRB -> MetadataType.PHOTOSHOP_IRB TYPE_PHOTOSHOP_IRB -> MetadataType.PHOTOSHOP_IRB
TYPE_XMP -> MetadataType.XMP TYPE_XMP -> MetadataType.XMP

View file

@ -27,6 +27,7 @@ import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.NameConflictStrategy import deckers.thibault.aves.model.NameConflictStrategy
import deckers.thibault.aves.utils.* import deckers.thibault.aves.utils.*
import deckers.thibault.aves.utils.MimeTypes.canEditExif import deckers.thibault.aves.utils.MimeTypes.canEditExif
import deckers.thibault.aves.utils.MimeTypes.canEditIptc
import deckers.thibault.aves.utils.MimeTypes.canEditXmp import deckers.thibault.aves.utils.MimeTypes.canEditXmp
import deckers.thibault.aves.utils.MimeTypes.canRemoveMetadata import deckers.thibault.aves.utils.MimeTypes.canRemoveMetadata
import deckers.thibault.aves.utils.MimeTypes.extensionFor import deckers.thibault.aves.utils.MimeTypes.extensionFor
@ -460,6 +461,94 @@ abstract class ImageProvider {
return true return true
} }
private fun editIptc(
context: Context,
path: String,
uri: Uri,
mimeType: String,
callback: ImageOpCallback,
trailerDiff: Int = 0,
iptc: List<FieldMap>?,
): Boolean {
if (!canEditIptc(mimeType)) {
callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType"))
return false
}
val originalFileSize = File(path).length()
val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff }
var videoBytes: ByteArray? = null
val editableFile = File.createTempFile("aves", null).apply {
deleteOnExit()
try {
outputStream().use { output ->
if (videoSize != null) {
// handle motion photo and embedded video separately
val imageSize = (originalFileSize - videoSize).toInt()
videoBytes = ByteArray(videoSize)
StorageUtils.openInputStream(context, uri)?.let { input ->
val imageBytes = ByteArray(imageSize)
input.read(imageBytes, 0, imageSize)
input.read(videoBytes, 0, videoSize)
// copy only the image to a temporary file for editing
// video will be appended after metadata modification
ByteArrayInputStream(imageBytes).use { imageInput ->
imageInput.copyTo(output)
}
}
} else {
// copy original file to a temporary file for editing
StorageUtils.openInputStream(context, uri)?.use { imageInput ->
imageInput.copyTo(output)
}
}
}
} catch (e: Exception) {
callback.onFailure(e)
return false
}
}
try {
editableFile.outputStream().use { output ->
// reopen input to read from start
StorageUtils.openInputStream(context, uri)?.use { input ->
when {
iptc != null ->
PixyMetaHelper.setIptc(input, output, iptc)
canRemoveMetadata(mimeType) ->
PixyMetaHelper.removeMetadata(input, output, setOf(Metadata.TYPE_IPTC))
else -> {
Log.w(LOG_TAG, "setting empty IPTC for mimeType=$mimeType")
PixyMetaHelper.setIptc(input, output, null)
}
}
}
}
if (videoBytes != null) {
// append trailer video, if any
editableFile.appendBytes(videoBytes!!)
}
// copy the edited temporary file back to the original
copyTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path)
if (!checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
return false
}
} catch (e: IOException) {
callback.onFailure(e)
return false
}
return true
}
// provide `editCoreXmp` to modify existing core XMP,
// or provide `coreXmp` and `extendedXmp` to set them
private fun editXmp( private fun editXmp(
context: Context, context: Context,
path: String, path: String,
@ -467,7 +556,9 @@ abstract class ImageProvider {
mimeType: String, mimeType: String,
callback: ImageOpCallback, callback: ImageOpCallback,
trailerDiff: Int = 0, trailerDiff: Int = 0,
edit: (xmp: String) -> String, coreXmp: String? = null,
extendedXmp: String? = null,
editCoreXmp: ((xmp: String) -> String)? = null,
): Boolean { ): Boolean {
if (!canEditXmp(mimeType)) { if (!canEditXmp(mimeType)) {
callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType")) callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType"))
@ -479,18 +570,34 @@ abstract class ImageProvider {
val editableFile = File.createTempFile("aves", null).apply { val editableFile = File.createTempFile("aves", null).apply {
deleteOnExit() deleteOnExit()
try { try {
val xmp = StorageUtils.openInputStream(context, uri)?.use { input -> PixyMetaHelper.getXmp(input) } var editedXmpString = coreXmp
if (xmp == null) { var editedExtendedXmp = extendedXmp
callback.onFailure(Exception("failed to find XMP for path=$path, uri=$uri")) if (editCoreXmp != null) {
return false val pixyXmp = StorageUtils.openInputStream(context, uri)?.use { input -> PixyMetaHelper.getXmp(input) }
if (pixyXmp != null) {
editedXmpString = editCoreXmp(pixyXmp.xmpDocString())
if (pixyXmp.hasExtendedXmp()) {
editedExtendedXmp = pixyXmp.extendedXmpDocString()
}
}
} }
outputStream().use { output -> outputStream().use { output ->
// reopen input to read from start // reopen input to read from start
StorageUtils.openInputStream(context, uri)?.use { input -> StorageUtils.openInputStream(context, uri)?.use { input ->
val editedXmpString = edit(xmp.xmpDocString()) if (editedXmpString != null) {
val extendedXmpString = if (xmp.hasExtendedXmp()) xmp.extendedXmpDocString() else null if (editedExtendedXmp != null && mimeType != MimeTypes.JPEG) {
PixyMetaHelper.setXmp(input, output, editedXmpString, extendedXmpString) Log.w(LOG_TAG, "extended XMP is not supported by mimeType=$mimeType")
PixyMetaHelper.setXmp(input, output, editedXmpString, null)
} else {
PixyMetaHelper.setXmp(input, output, editedXmpString, editedExtendedXmp)
}
} else if (canRemoveMetadata(mimeType)) {
PixyMetaHelper.removeMetadata(input, output, setOf(Metadata.TYPE_XMP))
} else {
Log.w(LOG_TAG, "setting empty XMP for mimeType=$mimeType")
PixyMetaHelper.setXmp(input, output, null, null)
}
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
@ -538,7 +645,7 @@ abstract class ImageProvider {
"We need to edit XMP to adjust trailer video offset by $diff bytes." "We need to edit XMP to adjust trailer video offset by $diff bytes."
) )
val newTrailerOffset = trailerOffset + diff val newTrailerOffset = trailerOffset + diff
return editXmp(context, path, uri, mimeType, callback, trailerDiff = diff) { xmp -> return editXmp(context, path, uri, mimeType, callback, trailerDiff = diff, editCoreXmp = { xmp ->
xmp.replace( xmp.replace(
// GCamera motion photo // GCamera motion photo
"${XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME}=\"$trailerOffset\"", "${XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME}=\"$trailerOffset\"",
@ -548,7 +655,7 @@ abstract class ImageProvider {
"${XMP.CONTAINER_ITEM_LENGTH_PROP_NAME}=\"$trailerOffset\"", "${XMP.CONTAINER_ITEM_LENGTH_PROP_NAME}=\"$trailerOffset\"",
"${XMP.CONTAINER_ITEM_LENGTH_PROP_NAME}=\"$newTrailerOffset\"", "${XMP.CONTAINER_ITEM_LENGTH_PROP_NAME}=\"$newTrailerOffset\"",
) )
} })
} }
fun editOrientation( fun editOrientation(
@ -679,6 +786,65 @@ abstract class ImageProvider {
} }
} }
fun setIptc(
context: Context,
path: String,
uri: Uri,
mimeType: String,
postEditScan: Boolean,
callback: ImageOpCallback,
iptc: List<FieldMap>? = null,
) {
val newFields = HashMap<String, Any?>()
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())
}
} 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"))
}
}
fun removeMetadataTypes( fun removeMetadataTypes(
context: Context, context: Context,
path: String, path: String,

View file

@ -110,7 +110,16 @@ object MimeTypes {
} }
// as of latest PixyMeta // as of latest PixyMeta
fun canEditXmp(mimeType: String) = canReadWithPixyMeta(mimeType) fun canEditIptc(mimeType: String) = when (mimeType) {
JPEG, TIFF -> true
else -> false
}
// as of latest PixyMeta
fun canEditXmp(mimeType: String) = when (mimeType) {
JPEG, TIFF, PNG, GIF -> true
else -> false
}
// as of latest PixyMeta // as of latest PixyMeta
fun canRemoveMetadata(mimeType: String) = when (mimeType) { fun canRemoveMetadata(mimeType: String) = when (mimeType) {

View file

@ -1,6 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript { buildscript {
ext.kotlin_version = '1.5.31' ext.kotlin_version = '1.6.0'
repositories { repositories {
google() google()
mavenCentral() mavenCentral()

View file

@ -53,6 +53,8 @@
"@hideTooltip": {}, "@hideTooltip": {},
"removeTooltip": "Remove", "removeTooltip": "Remove",
"@removeTooltip": {}, "@removeTooltip": {},
"resetButtonTooltip": "Reset",
"@resetButtonTooltip": {},
"doubleBackExitMessage": "Tap “back” again to exit.", "doubleBackExitMessage": "Tap “back” again to exit.",
"@doubleBackExitMessage": {}, "@doubleBackExitMessage": {},
@ -145,6 +147,8 @@
"entryInfoActionEditDate": "Edit date & time", "entryInfoActionEditDate": "Edit date & time",
"@entryInfoActionEditDate": {}, "@entryInfoActionEditDate": {},
"entryInfoActionEditTags": "Edit tags",
"@entryInfoActionEditTags": {},
"entryInfoActionRemoveMetadata": "Remove metadata", "entryInfoActionRemoveMetadata": "Remove metadata",
"@entryInfoActionRemoveMetadata": {}, "@entryInfoActionRemoveMetadata": {},
@ -1026,6 +1030,13 @@
"viewerInfoSearchSuggestionRights": "Rights", "viewerInfoSearchSuggestionRights": "Rights",
"@viewerInfoSearchSuggestionRights": {}, "@viewerInfoSearchSuggestionRights": {},
"tagEditorPageTitle": "Edit Tags",
"@tagEditorPageTitle": {},
"tagEditorPageNewTagFieldLabel": "New tag",
"@tagEditorPageNewTagFieldLabel": {},
"tagEditorPageAddTagTooltip": "Add tag",
"@tagEditorPageAddTagTooltip": {},
"panoramaEnableSensorControl": "Enable sensor control", "panoramaEnableSensorControl": "Enable sensor control",
"@panoramaEnableSensorControl": {}, "@panoramaEnableSensorControl": {},
"panoramaDisableSensorControl": "Disable sensor control", "panoramaDisableSensorControl": "Disable sensor control",

View file

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

View file

@ -26,6 +26,7 @@ enum EntrySetAction {
rotateCW, rotateCW,
flip, flip,
editDate, editDate,
editTags,
removeMetadata, removeMetadata,
} }
@ -104,6 +105,8 @@ extension ExtraEntrySetAction on EntrySetAction {
return context.l10n.entryActionFlip; return context.l10n.entryActionFlip;
case EntrySetAction.editDate: case EntrySetAction.editDate:
return context.l10n.entryInfoActionEditDate; return context.l10n.entryInfoActionEditDate;
case EntrySetAction.editTags:
return context.l10n.entryInfoActionEditTags;
case EntrySetAction.removeMetadata: case EntrySetAction.removeMetadata:
return context.l10n.entryInfoActionRemoveMetadata; return context.l10n.entryInfoActionRemoveMetadata;
} }
@ -158,6 +161,8 @@ extension ExtraEntrySetAction on EntrySetAction {
return AIcons.flip; return AIcons.flip;
case EntrySetAction.editDate: case EntrySetAction.editDate:
return AIcons.date; return AIcons.date;
case EntrySetAction.editTags:
return AIcons.addTag;
case EntrySetAction.removeMetadata: case EntrySetAction.removeMetadata:
return AIcons.clear; return AIcons.clear;
} }

View file

@ -0,0 +1,18 @@
import 'package:flutter/foundation.dart';
@immutable
class ActionEvent<T> {
final T action;
const ActionEvent(this.action);
}
@immutable
class ActionStartedEvent<T> extends ActionEvent<T> {
const ActionStartedEvent(T action) : super(action);
}
@immutable
class ActionEndedEvent<T> extends ActionEvent<T> {
const ActionEndedEvent(T action) : super(action);
}

View file

@ -24,6 +24,8 @@ import 'package:country_code/country_code.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
enum EntryDataType { basic, catalog, address, references }
class AvesEntry { class AvesEntry {
String uri; String uri;
String? _path, _directory, _filename, _extension; String? _path, _directory, _filename, _extension;
@ -235,6 +237,10 @@ class AvesEntry {
bool get canEdit => path != null; bool get canEdit => path != null;
bool get canEditDate => canEdit && canEditExif;
bool get canEditTags => canEdit && canEditXmp;
bool get canRotateAndFlip => canEdit && canEditExif; bool get canRotateAndFlip => canEdit && canEditExif;
// as of androidx.exifinterface:exifinterface:1.3.3 // as of androidx.exifinterface:exifinterface:1.3.3
@ -250,6 +256,30 @@ class AvesEntry {
} }
} }
// as of latest PixyMeta
bool get canEditIptc {
switch (mimeType.toLowerCase()) {
case MimeTypes.jpeg:
case MimeTypes.tiff:
return true;
default:
return false;
}
}
// as of latest PixyMeta
bool get canEditXmp {
switch (mimeType.toLowerCase()) {
case MimeTypes.gif:
case MimeTypes.jpeg:
case MimeTypes.png:
case MimeTypes.tiff:
return true;
default:
return false;
}
}
// as of latest PixyMeta // as of latest PixyMeta
bool get canRemoveMetadata { bool get canRemoveMetadata {
switch (mimeType.toLowerCase()) { switch (mimeType.toLowerCase()) {
@ -394,11 +424,11 @@ class AvesEntry {
LatLng? get latLng => hasGps ? LatLng(_catalogMetadata!.latitude!, _catalogMetadata!.longitude!) : null; LatLng? get latLng => hasGps ? LatLng(_catalogMetadata!.latitude!, _catalogMetadata!.longitude!) : null;
List<String>? _xmpSubjects; Set<String>? _tags;
List<String> get xmpSubjects { Set<String> get tags {
_xmpSubjects ??= _catalogMetadata?.xmpSubjects?.split(';').where((tag) => tag.isNotEmpty).toList() ?? []; _tags ??= _catalogMetadata?.xmpSubjects?.split(';').where((tag) => tag.isNotEmpty).toSet() ?? {};
return _xmpSubjects!; return _tags!;
} }
String? _bestTitle; String? _bestTitle;
@ -423,7 +453,7 @@ class AvesEntry {
catalogDateMillis = newMetadata?.dateMillis; catalogDateMillis = newMetadata?.dateMillis;
_catalogMetadata = newMetadata; _catalogMetadata = newMetadata;
_bestTitle = null; _bestTitle = null;
_xmpSubjects = null; _tags = null;
metadataChangeNotifier.notifyListeners(); metadataChangeNotifier.notifyListeners();
_onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); _onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
@ -434,7 +464,7 @@ class AvesEntry {
addressDetails = null; addressDetails = null;
} }
Future<void> catalog({required bool background, required bool persist, required bool force}) async { Future<void> catalog({required bool background, required bool force, required bool persist}) async {
if (isCatalogued && !force) return; if (isCatalogued && !force) return;
if (isSvg) { if (isSvg) {
// vector image sizing is not essential, so we should not spend time for it during loading // vector image sizing is not essential, so we should not spend time for it during loading
@ -593,58 +623,80 @@ class AvesEntry {
metadataChangeNotifier.notifyListeners(); metadataChangeNotifier.notifyListeners();
} }
Future<void> refresh({required bool background, required bool persist, required bool force, required Locale geocoderLocale}) async { Future<void> refresh({
_catalogMetadata = null; required bool background,
_addressDetails = null; required bool persist,
required Set<EntryDataType> dataTypes,
required Locale geocoderLocale,
}) async {
// clear derived fields
_bestDate = null; _bestDate = null;
_bestTitle = null; _bestTitle = null;
_xmpSubjects = null; _tags = null;
if (persist) { if (persist) {
await metadataDb.removeIds({contentId!}, metadataOnly: true); await metadataDb.removeIds({contentId!}, dataTypes: dataTypes);
} }
final updated = await mediaFileService.getEntry(uri, mimeType); final updatedEntry = await mediaFileService.getEntry(uri, mimeType);
if (updated != null) { if (updatedEntry != null) {
await _applyNewFields(updated.toMap(), persist: persist); await _applyNewFields(updatedEntry.toMap(), persist: persist);
await catalog(background: background, persist: persist, force: force);
await locate(background: background, force: force, geocoderLocale: geocoderLocale);
} }
await catalog(background: background, force: dataTypes.contains(EntryDataType.catalog), persist: persist);
await locate(background: background, force: dataTypes.contains(EntryDataType.address), geocoderLocale: geocoderLocale);
} }
Future<bool> rotate({required bool clockwise, required bool persist}) async { Future<Set<EntryDataType>> rotate({required bool clockwise, required bool persist}) async {
final newFields = await metadataEditService.rotate(this, clockwise: clockwise); final newFields = await metadataEditService.rotate(this, clockwise: clockwise);
if (newFields.isEmpty) return false; if (newFields.isEmpty) return {};
await _applyNewFields(newFields, persist: persist); await _applyNewFields(newFields, persist: persist);
return true; return {
EntryDataType.basic,
EntryDataType.catalog,
};
} }
Future<bool> flip({required bool persist}) async { Future<Set<EntryDataType>> flip({required bool persist}) async {
final newFields = await metadataEditService.flip(this); final newFields = await metadataEditService.flip(this);
if (newFields.isEmpty) return false; if (newFields.isEmpty) return {};
await _applyNewFields(newFields, persist: persist); await _applyNewFields(newFields, persist: persist);
return true; return {
EntryDataType.basic,
EntryDataType.catalog,
};
} }
Future<bool> editDate(DateModifier modifier) async { Future<Set<EntryDataType>> editDate(DateModifier modifier) async {
if (modifier.action == DateEditAction.extractFromTitle) { if (modifier.action == DateEditAction.extractFromTitle) {
final _title = bestTitle; final _title = bestTitle;
if (_title == null) return false; if (_title == null) return {};
final date = parseUnknownDateFormat(_title); final date = parseUnknownDateFormat(_title);
if (date == null) { if (date == null) {
await reportService.recordError('failed to parse date from title=$_title', null); await reportService.recordError('failed to parse date from title=$_title', null);
return false; return {};
} }
modifier = DateModifier(DateEditAction.set, modifier.fields, dateTime: date); modifier = DateModifier(DateEditAction.set, modifier.fields, dateTime: date);
} }
final newFields = await metadataEditService.editDate(this, modifier); final newFields = await metadataEditService.editDate(this, modifier);
return newFields.isNotEmpty; return newFields.isEmpty
? {}
: {
EntryDataType.basic,
EntryDataType.catalog,
};
} }
Future<bool> removeMetadata(Set<MetadataType> types) async { Future<Set<EntryDataType>> removeMetadata(Set<MetadataType> types) async {
final newFields = await metadataEditService.removeTypes(this, types); final newFields = await metadataEditService.removeTypes(this, types);
return newFields.isNotEmpty; return newFields.isEmpty
? {}
: {
EntryDataType.basic,
EntryDataType.catalog,
EntryDataType.address,
};
} }
Future<bool> delete() { Future<bool> delete() {

View file

@ -9,7 +9,7 @@ import 'package:aves/model/entry_cache.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
extension ExtraAvesEntry on AvesEntry { extension ExtraAvesEntryImages on AvesEntry {
bool isThumbnailReady({double extent = 0}) => _isReady(_getThumbnailProviderKey(extent)); bool isThumbnailReady({double extent = 0}) => _isReady(_getThumbnailProviderKey(extent));
ThumbnailProvider getThumbnail({double extent = 0}) { ThumbnailProvider getThumbnail({double extent = 0}) {

View file

@ -0,0 +1,237 @@
import 'dart:convert';
import 'package:aves/model/entry.dart';
import 'package:aves/ref/iptc.dart';
import 'package:aves/services/common/services.dart';
import 'package:collection/collection.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:intl/intl.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:xml/xml.dart';
extension ExtraAvesEntryXmpIptc on AvesEntry {
static const dcNamespace = 'http://purl.org/dc/elements/1.1/';
static const rdfNamespace = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#';
static const xNamespace = 'adobe:ns:meta/';
static const xmpNamespace = 'http://ns.adobe.com/xap/1.0/';
static const xmpNoteNamespace = 'http://ns.adobe.com/xmp/note/';
static const xmlnsPrefix = 'xmlns';
static final nsDefaultPrefixes = {
dcNamespace: 'dc',
rdfNamespace: 'rdf',
xNamespace: 'x',
xmpNamespace: 'xmp',
xmpNoteNamespace: 'xmpNote',
};
// elements
static const xXmpmeta = 'xmpmeta';
static const rdfRoot = 'RDF';
static const rdfDescription = 'Description';
static const dcSubject = 'subject';
// attributes
static const xXmptk = 'xmptk';
static const rdfAbout = 'about';
static const xmpMetadataDate = 'MetadataDate';
static const xmpModifyDate = 'ModifyDate';
static const xmpNoteHasExtendedXMP = 'HasExtendedXMP';
static String prefixOf(String ns) => nsDefaultPrefixes[ns] ?? '';
Future<Set<EntryDataType>> editTags(Set<String> tags) async {
final xmp = await metadataFetchService.getXmp(this);
final extendedXmpString = xmp?.extendedXmpString;
XmlDocument? xmpDoc;
if (xmp != null) {
final xmpString = xmp.xmpString;
if (xmpString != null) {
xmpDoc = XmlDocument.parse(xmpString);
}
}
if (xmpDoc == null) {
final toolkit = 'Aves v${(await PackageInfo.fromPlatform()).version}';
final builder = XmlBuilder();
builder.namespace(xNamespace, prefixOf(xNamespace));
builder.element(xXmpmeta, namespace: xNamespace, namespaces: {
xNamespace: prefixOf(xNamespace),
}, attributes: {
'${prefixOf(xNamespace)}:$xXmptk': toolkit,
});
xmpDoc = builder.buildDocument();
}
final root = xmpDoc.rootElement;
XmlNode? rdf = root.getElement(rdfRoot, namespace: rdfNamespace);
if (rdf == null) {
final builder = XmlBuilder();
builder.namespace(rdfNamespace, prefixOf(rdfNamespace));
builder.element(rdfRoot, namespace: rdfNamespace, namespaces: {
rdfNamespace: prefixOf(rdfNamespace),
});
// get element because doc fragment cannot be used to edit
root.children.add(builder.buildFragment());
rdf = root.getElement(rdfRoot, namespace: rdfNamespace)!;
}
XmlNode? description = rdf.getElement(rdfDescription, namespace: rdfNamespace);
if (description == null) {
final builder = XmlBuilder();
builder.namespace(rdfNamespace, prefixOf(rdfNamespace));
builder.element(rdfDescription, namespace: rdfNamespace, attributes: {
'${prefixOf(rdfNamespace)}:$rdfAbout': '',
});
rdf.children.add(builder.buildFragment());
// get element because doc fragment cannot be used to edit
description = rdf.getElement(rdfDescription, namespace: rdfNamespace)!;
}
_setNamespaces(description, {
dcNamespace: prefixOf(dcNamespace),
xmpNamespace: prefixOf(xmpNamespace),
});
_setStringBag(description, dcSubject, tags, namespace: dcNamespace);
if (_isMeaningfulXmp(rdf)) {
final modifyDate = DateTime.now();
description.setAttribute('${prefixOf(xmpNamespace)}:$xmpMetadataDate', _toXmpDate(modifyDate));
description.setAttribute('${prefixOf(xmpNamespace)}:$xmpModifyDate', _toXmpDate(modifyDate));
} else {
// clear XMP if there are no attributes or elements worth preserving
xmpDoc = null;
}
final editedXmp = AvesXmp(
xmpString: xmpDoc?.toXmlString(),
extendedXmpString: extendedXmpString,
);
if (canEditIptc) {
final iptc = await metadataFetchService.getIptc(this);
if (iptc != null) {
await _setIptcKeywords(iptc, tags);
}
}
final newFields = await metadataEditService.setXmp(this, editedXmp);
return newFields.isEmpty ? {} : {EntryDataType.catalog};
}
Future<void> _setIptcKeywords(List<Map<String, dynamic>> iptc, Set<String> tags) async {
iptc.removeWhere((v) => v['record'] == IPTC.applicationRecord && v['tag'] == IPTC.keywordsTag);
iptc.add({
'record': IPTC.applicationRecord,
'tag': IPTC.keywordsTag,
'values': tags.map((v) => utf8.encode(v)).toList(),
});
await metadataEditService.setIptc(this, iptc, postEditScan: false);
}
int _meaningfulChildrenCount(XmlNode node) => node.children.where((v) => v.nodeType != XmlNodeType.TEXT || v.text.trim().isNotEmpty).length;
bool _isMeaningfulXmp(XmlNode rdf) {
if (_meaningfulChildrenCount(rdf) > 1) return true;
final description = rdf.getElement(rdfDescription, namespace: rdfNamespace);
if (description == null) return true;
if (_meaningfulChildrenCount(description) > 0) return true;
final hasMeaningfulAttributes = description.attributes.any((v) {
switch (v.name.local) {
case rdfAbout:
return v.value.isNotEmpty;
case xmpMetadataDate:
case xmpModifyDate:
return false;
default:
switch (v.name.prefix) {
case xmlnsPrefix:
return false;
default:
// if the attribute got defined with the prefix as part of the name,
// the prefix is not recognized as such, so we check the full name
return !v.name.qualified.startsWith(xmlnsPrefix);
}
}
});
return hasMeaningfulAttributes;
}
// return time zone designator, formatted as `Z` or `+hh:mm` or `-hh:mm`
// as of intl v0.17.0, formatting time zone offset is not implemented
String _xmpTimeZoneDesignator(DateTime date) {
final offsetMinutes = date.timeZoneOffset.inMinutes;
final abs = offsetMinutes.abs();
final h = abs ~/ Duration.minutesPerHour;
final m = abs % Duration.minutesPerHour;
return '${offsetMinutes.isNegative ? '-' : '+'}${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}';
}
String _toXmpDate(DateTime date) => '${DateFormat('yyyy-MM-ddTHH:mm:ss').format(date)}${_xmpTimeZoneDesignator(date)}';
void _setNamespaces(XmlNode node, Map<String, String> namespaces) => namespaces.forEach((uri, prefix) => node.setAttribute('$xmlnsPrefix:$prefix', uri));
void _setStringBag(XmlNode node, String name, Set<String> values, {required String namespace}) {
// remove existing
node.findElements(name, namespace: namespace).toSet().forEach(node.children.remove);
if (values.isNotEmpty) {
// add new bag
final rootBuilder = XmlBuilder();
rootBuilder.namespace(namespace, prefixOf(namespace));
rootBuilder.element(name, namespace: namespace);
node.children.add(rootBuilder.buildFragment());
final bagBuilder = XmlBuilder();
bagBuilder.namespace(rdfNamespace, prefixOf(rdfNamespace));
bagBuilder.element('Bag', namespace: rdfNamespace, nest: () {
values.forEach((v) {
bagBuilder.element('li', namespace: rdfNamespace, nest: v);
});
});
node.children.last.children.add(bagBuilder.buildFragment());
}
}
}
@immutable
class AvesXmp extends Equatable {
final String? xmpString;
final String? extendedXmpString;
@override
List<Object?> get props => [xmpString, extendedXmpString];
const AvesXmp({
required this.xmpString,
this.extendedXmpString,
});
static AvesXmp? fromList(List<String> xmpStrings) {
switch (xmpStrings.length) {
case 0:
return null;
case 1:
return AvesXmp(xmpString: xmpStrings.single);
default:
final byExtending = groupBy<String, bool>(xmpStrings, (v) => v.contains(':HasExtendedXMP='));
final extending = byExtending[true] ?? [];
final extension = byExtending[false] ?? [];
if (extending.length == 1 && extension.length == 1) {
return AvesXmp(
xmpString: extending.single,
extendedXmpString: extension.single,
);
}
// take the first XMP and ignore the rest when the file is weirdly constructed
debugPrint('warning: entry has ${xmpStrings.length} XMP directories, xmpStrings=$xmpStrings');
return AvesXmp(xmpString: xmpStrings.firstOrNull);
}
}
}

View file

@ -14,9 +14,9 @@ class TagFilter extends CollectionFilter {
TagFilter(this.tag) { TagFilter(this.tag) {
if (tag.isEmpty) { if (tag.isEmpty) {
_test = (entry) => entry.xmpSubjects.isEmpty; _test = (entry) => entry.tags.isEmpty;
} else { } else {
_test = (entry) => entry.xmpSubjects.contains(tag); _test = (entry) => entry.tags.contains(tag);
} }
} }

View file

@ -13,6 +13,8 @@ enum DateEditAction {
} }
enum MetadataType { enum MetadataType {
// JPEG COM marker or GIF comment
comment,
// Exif: https://en.wikipedia.org/wiki/Exif // Exif: https://en.wikipedia.org/wiki/Exif
exif, exif,
// ICC profile: https://en.wikipedia.org/wiki/ICC_profile // ICC profile: https://en.wikipedia.org/wiki/ICC_profile
@ -23,8 +25,6 @@ enum MetadataType {
jfif, jfif,
// JPEG APP14 / Adobe: https://www.exiftool.org/TagNames/JPEG.html#Adobe // JPEG APP14 / Adobe: https://www.exiftool.org/TagNames/JPEG.html#Adobe
jpegAdobe, jpegAdobe,
// JPEG COM marker
jpegComment,
// JPEG APP12 / Ducky: https://www.exiftool.org/TagNames/APP12.html#Ducky // JPEG APP12 / Ducky: https://www.exiftool.org/TagNames/APP12.html#Ducky
jpegDucky, jpegDucky,
// Photoshop IRB: https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/ // Photoshop IRB: https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/
@ -42,6 +42,7 @@ class MetadataTypes {
static const common = { static const common = {
MetadataType.exif, MetadataType.exif,
MetadataType.xmp, MetadataType.xmp,
MetadataType.comment,
MetadataType.iccProfile, MetadataType.iccProfile,
MetadataType.iptc, MetadataType.iptc,
MetadataType.photoshopIrb, MetadataType.photoshopIrb,
@ -50,7 +51,6 @@ class MetadataTypes {
static const jpeg = { static const jpeg = {
MetadataType.jfif, MetadataType.jfif,
MetadataType.jpegAdobe, MetadataType.jpegAdobe,
MetadataType.jpegComment,
MetadataType.jpegDucky, MetadataType.jpegDucky,
}; };
} }
@ -59,6 +59,8 @@ extension ExtraMetadataType on MetadataType {
// match `ExifInterface` directory names // match `ExifInterface` directory names
String getText() { String getText() {
switch (this) { switch (this) {
case MetadataType.comment:
return 'Comment';
case MetadataType.exif: case MetadataType.exif:
return 'Exif'; return 'Exif';
case MetadataType.iccProfile: case MetadataType.iccProfile:
@ -69,8 +71,6 @@ extension ExtraMetadataType on MetadataType {
return 'JFIF'; return 'JFIF';
case MetadataType.jpegAdobe: case MetadataType.jpegAdobe:
return 'Adobe JPEG'; return 'Adobe JPEG';
case MetadataType.jpegComment:
return 'JpegComment';
case MetadataType.jpegDucky: case MetadataType.jpegDucky:
return 'Ducky'; return 'Ducky';
case MetadataType.photoshopIrb: case MetadataType.photoshopIrb:

View file

@ -20,7 +20,7 @@ abstract class MetadataDb {
Future<void> reset(); Future<void> reset();
Future<void> removeIds(Set<int> contentIds, {required bool metadataOnly}); Future<void> removeIds(Set<int> contentIds, {Set<EntryDataType>? dataTypes});
// entries // entries
@ -187,20 +187,28 @@ class SqfliteMetadataDb implements MetadataDb {
} }
@override @override
Future<void> removeIds(Set<int> contentIds, {required bool metadataOnly}) async { Future<void> removeIds(Set<int> contentIds, {Set<EntryDataType>? dataTypes}) async {
if (contentIds.isEmpty) return; if (contentIds.isEmpty) return;
final _dataTypes = dataTypes ?? EntryDataType.values.toSet();
final db = await _database; final db = await _database;
// using array in `whereArgs` and using it with `where contentId IN ?` is a pain, so we prefer `batch` instead // using array in `whereArgs` and using it with `where contentId IN ?` is a pain, so we prefer `batch` instead
final batch = db.batch(); final batch = db.batch();
const where = 'contentId = ?'; const where = 'contentId = ?';
contentIds.forEach((id) { contentIds.forEach((id) {
final whereArgs = [id]; final whereArgs = [id];
batch.delete(entryTable, where: where, whereArgs: whereArgs); if (_dataTypes.contains(EntryDataType.basic)) {
batch.delete(dateTakenTable, where: where, whereArgs: whereArgs); batch.delete(entryTable, where: where, whereArgs: whereArgs);
batch.delete(metadataTable, where: where, whereArgs: whereArgs); }
batch.delete(addressTable, where: where, whereArgs: whereArgs); if (_dataTypes.contains(EntryDataType.catalog)) {
if (!metadataOnly) { batch.delete(dateTakenTable, where: where, whereArgs: whereArgs);
batch.delete(metadataTable, where: where, whereArgs: whereArgs);
}
if (_dataTypes.contains(EntryDataType.address)) {
batch.delete(addressTable, where: where, whereArgs: whereArgs);
}
if (_dataTypes.contains(EntryDataType.references)) {
batch.delete(favouriteTable, where: where, whereArgs: whereArgs); batch.delete(favouriteTable, where: where, whereArgs: whereArgs);
batch.delete(coverTable, where: where, whereArgs: whereArgs); batch.delete(coverTable, where: where, whereArgs: whereArgs);
batch.delete(videoPlaybackTable, where: where, whereArgs: whereArgs); batch.delete(videoPlaybackTable, where: where, whereArgs: whereArgs);

View file

@ -284,8 +284,8 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
Future<Set<String>> refreshUris(Set<String> changedUris, {AnalysisController? analysisController}); Future<Set<String>> refreshUris(Set<String> changedUris, {AnalysisController? analysisController});
Future<void> refreshEntry(AvesEntry entry) async { Future<void> refreshEntry(AvesEntry entry, Set<EntryDataType> dataTypes) async {
await entry.refresh(background: false, persist: true, force: true, geocoderLocale: settings.appliedLocale); await entry.refresh(background: false, persist: true, dataTypes: dataTypes, geocoderLocale: settings.appliedLocale);
updateDerivedFilters({entry}); updateDerivedFilters({entry});
eventBus.fire(EntryRefreshedEvent({entry})); eventBus.fire(EntryRefreshedEvent({entry}));
} }

View file

@ -67,7 +67,7 @@ class MediaStoreSource extends CollectionSource {
// clean up obsolete entries // clean up obsolete entries
debugPrint('$runtimeType refresh ${stopwatch.elapsed} remove obsolete entries'); debugPrint('$runtimeType refresh ${stopwatch.elapsed} remove obsolete entries');
await metadataDb.removeIds(obsoleteContentIds, metadataOnly: false); await metadataDb.removeIds(obsoleteContentIds);
// verify paths because some apps move files without updating their `last modified date` // verify paths because some apps move files without updating their `last modified date`
debugPrint('$runtimeType refresh ${stopwatch.elapsed} check obsolete paths'); debugPrint('$runtimeType refresh ${stopwatch.elapsed} check obsolete paths');

View file

@ -38,7 +38,7 @@ mixin TagMixin on SourceBase {
var stopCheckCount = 0; var stopCheckCount = 0;
final newMetadata = <CatalogMetadata>{}; final newMetadata = <CatalogMetadata>{};
for (final entry in todo) { for (final entry in todo) {
await entry.catalog(background: true, persist: true, force: force); await entry.catalog(background: true, force: force, persist: true);
if (entry.isCatalogued) { if (entry.isCatalogued) {
newMetadata.add(entry.catalogMetadata!); newMetadata.add(entry.catalogMetadata!);
if (newMetadata.length >= commitCountThreshold) { if (newMetadata.length >= commitCountThreshold) {
@ -63,7 +63,7 @@ mixin TagMixin on SourceBase {
} }
void updateTags() { void updateTags() {
final updatedTags = visibleEntries.expand((entry) => entry.xmpSubjects).toSet().toList()..sort(compareAsciiUpperCase); final updatedTags = visibleEntries.expand((entry) => entry.tags).toSet().toList()..sort(compareAsciiUpperCase);
if (!listEquals(updatedTags, sortedTags)) { if (!listEquals(updatedTags, sortedTags)) {
sortedTags = List.unmodifiable(updatedTags); sortedTags = List.unmodifiable(updatedTags);
invalidateTagFilterSummary(); invalidateTagFilterSummary();
@ -85,7 +85,7 @@ mixin TagMixin on SourceBase {
_filterEntryCountMap.clear(); _filterEntryCountMap.clear();
_filterRecentEntryMap.clear(); _filterRecentEntryMap.clear();
} else { } else {
tags = entries.where((entry) => entry.isCatalogued).expand((entry) => entry.xmpSubjects).toSet(); tags = entries.where((entry) => entry.isCatalogued).expand((entry) => entry.tags).toSet();
tags.forEach(_filterEntryCountMap.remove); tags.forEach(_filterEntryCountMap.remove);
} }
eventBus.fire(TagSummaryInvalidatedEvent(tags)); eventBus.fire(TagSummaryInvalidatedEvent(tags));

6
lib/ref/iptc.dart Normal file
View file

@ -0,0 +1,6 @@
class IPTC {
static const int applicationRecord = 2;
// ApplicationRecord tags
static const int keywordsTag = 25;
}

View file

@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/entry_xmp_iptc.dart';
import 'package:aves/model/metadata/date_modifier.dart'; import 'package:aves/model/metadata/date_modifier.dart';
import 'package:aves/model/metadata/enums.dart'; import 'package:aves/model/metadata/enums.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
@ -13,6 +14,10 @@ abstract class MetadataEditService {
Future<Map<String, dynamic>> editDate(AvesEntry entry, DateModifier modifier); Future<Map<String, dynamic>> editDate(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>> removeTypes(AvesEntry entry, Set<MetadataType> types); Future<Map<String, dynamic>> removeTypes(AvesEntry entry, Set<MetadataType> types);
} }
@ -85,6 +90,40 @@ class PlatformMetadataEditService implements MetadataEditService {
return {}; return {};
} }
@override
Future<Map<String, dynamic>> setIptc(AvesEntry entry, List<Map<String, dynamic>>? iptc, {required bool postEditScan}) async {
try {
final result = await platform.invokeMethod('setIptc', <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,
});
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 @override
Future<Map<String, dynamic>> removeTypes(AvesEntry entry, Set<MetadataType> types) async { Future<Map<String, dynamic>> removeTypes(AvesEntry entry, Set<MetadataType> types) async {
try { try {
@ -116,6 +155,8 @@ class PlatformMetadataEditService implements MetadataEditService {
String _toPlatformMetadataType(MetadataType type) { String _toPlatformMetadataType(MetadataType type) {
switch (type) { switch (type) {
case MetadataType.comment:
return 'comment';
case MetadataType.exif: case MetadataType.exif:
return 'exif'; return 'exif';
case MetadataType.iccProfile: case MetadataType.iccProfile:
@ -126,8 +167,6 @@ class PlatformMetadataEditService implements MetadataEditService {
return 'jfif'; return 'jfif';
case MetadataType.jpegAdobe: case MetadataType.jpegAdobe:
return 'jpeg_adobe'; return 'jpeg_adobe';
case MetadataType.jpegComment:
return 'jpeg_comment';
case MetadataType.jpegDucky: case MetadataType.jpegDucky:
return 'jpeg_ducky'; return 'jpeg_ducky';
case MetadataType.photoshopIrb: case MetadataType.photoshopIrb:

View file

@ -1,4 +1,5 @@
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/entry_xmp_iptc.dart';
import 'package:aves/model/metadata/catalog.dart'; import 'package:aves/model/metadata/catalog.dart';
import 'package:aves/model/metadata/overlay.dart'; import 'package:aves/model/metadata/overlay.dart';
import 'package:aves/model/multipage.dart'; import 'package:aves/model/multipage.dart';
@ -20,6 +21,10 @@ abstract class MetadataFetchService {
Future<PanoramaInfo?> getPanoramaInfo(AvesEntry entry); Future<PanoramaInfo?> getPanoramaInfo(AvesEntry entry);
Future<List<Map<String, dynamic>>?> getIptc(AvesEntry entry);
Future<AvesXmp?> getXmp(AvesEntry entry);
Future<bool> hasContentResolverProp(String prop); Future<bool> hasContentResolverProp(String prop);
Future<String?> getContentResolverProp(AvesEntry entry, String prop); Future<String?> getContentResolverProp(AvesEntry entry, String prop);
@ -151,6 +156,39 @@ class PlatformMetadataFetchService implements MetadataFetchService {
return null; return null;
} }
@override
Future<List<Map<String, dynamic>>?> getIptc(AvesEntry entry) async {
try {
final result = await platform.invokeMethod('getIptc', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
});
if (result != null) return (result as List).cast<Map>().map((fields) => fields.cast<String, dynamic>()).toList();
} on PlatformException catch (e, stack) {
if (!entry.isMissingAtPath) {
await reportService.recordError(e, stack);
}
}
return null;
}
@override
Future<AvesXmp?> getXmp(AvesEntry entry) async {
try {
final result = await platform.invokeMethod('getXmp', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
'sizeBytes': entry.sizeBytes,
});
if (result != null) return AvesXmp.fromList((result as List).cast<String>());
} on PlatformException catch (e, stack) {
if (!entry.isMissingAtPath) {
await reportService.recordError(e, stack);
}
}
return null;
}
final Map<String, bool> _contentResolverProps = {}; final Map<String, bool> _contentResolverProps = {};
@override @override

View file

@ -46,6 +46,7 @@ class Durations {
// info animations // info animations
static const mapStyleSwitchAnimation = Duration(milliseconds: 300); static const mapStyleSwitchAnimation = Duration(milliseconds: 300);
static const xmpStructArrayCardTransition = Duration(milliseconds: 300); static const xmpStructArrayCardTransition = Duration(milliseconds: 300);
static const tagEditorTransition = Duration(milliseconds: 200);
// settings animations // settings animations
static const quickActionListAnimation = Duration(milliseconds: 200); static const quickActionListAnimation = Duration(milliseconds: 200);

View file

@ -33,6 +33,7 @@ class AIcons {
// actions // actions
static const IconData add = Icons.add_circle_outline; static const IconData add = Icons.add_circle_outline;
static const IconData addShortcut = Icons.add_to_home_screen_outlined; static const IconData addShortcut = Icons.add_to_home_screen_outlined;
static const IconData addTag = MdiIcons.tagPlusOutline;
static const IconData replay10 = Icons.replay_10_outlined; static const IconData replay10 = Icons.replay_10_outlined;
static const IconData skip10 = Icons.forward_10_outlined; static const IconData skip10 = Icons.forward_10_outlined;
static const IconData captureFrame = Icons.screenshot_outlined; static const IconData captureFrame = Icons.screenshot_outlined;
@ -66,6 +67,7 @@ class AIcons {
static const IconData print = Icons.print_outlined; static const IconData print = Icons.print_outlined;
static const IconData refresh = Icons.refresh_outlined; static const IconData refresh = Icons.refresh_outlined;
static const IconData rename = Icons.title_outlined; static const IconData rename = Icons.title_outlined;
static const IconData reset = Icons.restart_alt_outlined;
static const IconData rotateLeft = Icons.rotate_left_outlined; static const IconData rotateLeft = Icons.rotate_left_outlined;
static const IconData rotateRight = Icons.rotate_right_outlined; static const IconData rotateRight = Icons.rotate_right_outlined;
static const IconData rotateScreen = Icons.screen_rotation_outlined; static const IconData rotateScreen = Icons.screen_rotation_outlined;

View file

@ -269,6 +269,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
_buildRotateAndFlipMenuItems(context, canApply: canApply), _buildRotateAndFlipMenuItems(context, canApply: canApply),
...[ ...[
EntrySetAction.editDate, EntrySetAction.editDate,
EntrySetAction.editTags,
EntrySetAction.removeMetadata, EntrySetAction.removeMetadata,
].map((action) => _toMenuItem(action, enabled: canApply(action))), ].map((action) => _toMenuItem(action, enabled: canApply(action))),
], ],
@ -439,6 +440,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
case EntrySetAction.rotateCW: case EntrySetAction.rotateCW:
case EntrySetAction.flip: case EntrySetAction.flip:
case EntrySetAction.editDate: case EntrySetAction.editDate:
case EntrySetAction.editTags:
case EntrySetAction.removeMetadata: case EntrySetAction.removeMetadata:
_actionDelegate.onActionSelected(context, action); _actionDelegate.onActionSelected(context, action);
break; break;

View file

@ -5,6 +5,7 @@ import 'package:aves/app_mode.dart';
import 'package:aves/model/actions/entry_set_actions.dart'; import 'package:aves/model/actions/entry_set_actions.dart';
import 'package:aves/model/actions/move_type.dart'; import 'package:aves/model/actions/move_type.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/entry_xmp_iptc.dart';
import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/highlight.dart'; import 'package:aves/model/highlight.dart';
@ -81,6 +82,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
case EntrySetAction.rotateCW: case EntrySetAction.rotateCW:
case EntrySetAction.flip: case EntrySetAction.flip:
case EntrySetAction.editDate: case EntrySetAction.editDate:
case EntrySetAction.editTags:
case EntrySetAction.removeMetadata: case EntrySetAction.removeMetadata:
return appMode == AppMode.main && isSelecting; return appMode == AppMode.main && isSelecting;
} }
@ -122,6 +124,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
case EntrySetAction.rotateCW: case EntrySetAction.rotateCW:
case EntrySetAction.flip: case EntrySetAction.flip:
case EntrySetAction.editDate: case EntrySetAction.editDate:
case EntrySetAction.editTags:
case EntrySetAction.removeMetadata: case EntrySetAction.removeMetadata:
return hasSelection; return hasSelection;
} }
@ -181,6 +184,9 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
case EntrySetAction.editDate: case EntrySetAction.editDate:
_editDate(context); _editDate(context);
break; break;
case EntrySetAction.editTags:
_editTags(context);
break;
case EntrySetAction.removeMetadata: case EntrySetAction.removeMetadata:
_removeMetadata(context); _removeMetadata(context);
break; break;
@ -399,7 +405,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
BuildContext context, BuildContext context,
Selection<AvesEntry> selection, Selection<AvesEntry> selection,
Set<AvesEntry> todoItems, Set<AvesEntry> todoItems,
Future<bool> Function(AvesEntry entry) op, Future<Set<EntryDataType>> Function(AvesEntry entry) op,
) async { ) async {
final selectionDirs = todoItems.map((e) => e.directory).whereNotNull().toSet(); final selectionDirs = todoItems.map((e) => e.directory).whereNotNull().toSet();
final todoCount = todoItems.length; final todoCount = todoItems.length;
@ -411,8 +417,8 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
showOpReport<ImageOpEvent>( showOpReport<ImageOpEvent>(
context: context, context: context,
opStream: Stream.fromIterable(todoItems).asyncMap((entry) async { opStream: Stream.fromIterable(todoItems).asyncMap((entry) async {
final success = await op(entry); final dataTypes = await op(entry);
return ImageOpEvent(success: success, uri: entry.uri); return ImageOpEvent(success: dataTypes.isNotEmpty, uri: entry.uri);
}).asBroadcastStream(), }).asBroadcastStream(),
itemCount: todoCount, itemCount: todoCount,
onDone: (processed) async { onDone: (processed) async {
@ -470,6 +476,8 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
); );
if (confirmed == null || !confirmed) return null; if (confirmed == null || !confirmed) return null;
// wait for the dialog to hide as applying the change may block the UI
await Future.delayed(Durations.dialogTransitionAnimation);
return supported; return supported;
} }
@ -497,7 +505,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
final selection = context.read<Selection<AvesEntry>>(); final selection = context.read<Selection<AvesEntry>>();
final selectedItems = _getExpandedSelectedItems(selection); final selectedItems = _getExpandedSelectedItems(selection);
final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canEditExif); final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canEditDate);
if (todoItems == null || todoItems.isEmpty) return; if (todoItems == null || todoItems.isEmpty) return;
final modifier = await selectDateModifier(context, todoItems); final modifier = await selectDateModifier(context, todoItems);
@ -506,6 +514,28 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
await _edit(context, selection, todoItems, (entry) => entry.editDate(modifier)); await _edit(context, selection, todoItems, (entry) => entry.editDate(modifier));
} }
Future<void> _editTags(BuildContext context) async {
final selection = context.read<Selection<AvesEntry>>();
final selectedItems = _getExpandedSelectedItems(selection);
final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canEditTags);
if (todoItems == null || todoItems.isEmpty) return;
final newTagsByEntry = await selectTags(context, todoItems);
if (newTagsByEntry == null) return;
// only process modified items
todoItems.removeWhere((entry) {
final newTags = newTagsByEntry[entry] ?? entry.tags;
final currentTags = entry.tags;
return newTags.length == currentTags.length && newTags.every(currentTags.contains);
});
if (todoItems.isEmpty) return;
await _edit(context, selection, todoItems, (entry) => entry.editTags(newTagsByEntry[entry]!));
}
Future<void> _removeMetadata(BuildContext context) async { Future<void> _removeMetadata(BuildContext context) async {
final selection = context.read<Selection<AvesEntry>>(); final selection = context.read<Selection<AvesEntry>>();
final selectedItems = _getExpandedSelectedItems(selection); final selectedItems = _getExpandedSelectedItems(selection);

View file

@ -4,8 +4,9 @@ import 'package:aves/model/metadata/enums.dart';
import 'package:aves/ref/mime_types.dart'; import 'package:aves/ref/mime_types.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/dialogs/edit_entry_date_dialog.dart'; import 'package:aves/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart';
import 'package:aves/widgets/dialogs/remove_entry_metadata_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'; import 'package:flutter/material.dart';
mixin EntryEditorMixin { mixin EntryEditorMixin {
@ -21,6 +22,23 @@ mixin EntryEditorMixin {
return modifier; return modifier;
} }
Future<Map<AvesEntry, Set<String>>?> selectTags(BuildContext context, Set<AvesEntry> entries) async {
if (entries.isEmpty) return null;
final tagsByEntry = Map.fromEntries(entries.map((v) => MapEntry(v, v.tags.toSet())));
await Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: TagEditorPage.routeName),
builder: (context) => TagEditorPage(
tagsByEntry: tagsByEntry,
),
),
);
return tagsByEntry;
}
Future<Set<MetadataType>?> selectMetadataToRemove(BuildContext context, Set<AvesEntry> entries) async { Future<Set<MetadataType>?> selectMetadataToRemove(BuildContext context, Set<AvesEntry> entries) async {
if (entries.isEmpty) return null; if (entries.isEmpty) return null;

View file

@ -40,7 +40,7 @@ class AvesFilterChip extends StatefulWidget {
final bool removable, showGenericIcon, useFilterColor; final bool removable, showGenericIcon, useFilterColor;
final AvesFilterDecoration? decoration; final AvesFilterDecoration? decoration;
final String? banner; final String? banner;
final Widget? details; final Widget? leadingOverride, details;
final double padding, maxWidth; final double padding, maxWidth;
final HeroType heroType; final HeroType heroType;
final FilterCallback? onTap; final FilterCallback? onTap;
@ -64,6 +64,7 @@ class AvesFilterChip extends StatefulWidget {
this.useFilterColor = true, this.useFilterColor = true,
this.decoration, this.decoration,
this.banner, this.banner,
this.leadingOverride,
this.details, this.details,
this.padding = 6.0, this.padding = 6.0,
this.maxWidth = defaultMaxChipWidth, this.maxWidth = defaultMaxChipWidth,
@ -162,7 +163,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
final chipBackground = Theme.of(context).scaffoldBackgroundColor; final chipBackground = Theme.of(context).scaffoldBackgroundColor;
final textScaleFactor = MediaQuery.textScaleFactorOf(context); final textScaleFactor = MediaQuery.textScaleFactorOf(context);
final iconSize = AvesFilterChip.iconSize * textScaleFactor; final iconSize = AvesFilterChip.iconSize * textScaleFactor;
final leading = filter.iconBuilder(context, iconSize, showGenericIcon: widget.showGenericIcon); final leading = widget.leadingOverride ?? filter.iconBuilder(context, iconSize, showGenericIcon: widget.showGenericIcon);
final trailing = widget.removable ? Icon(AIcons.clear, size: iconSize) : null; final trailing = widget.removable ? Icon(AIcons.clear, size: iconSize) : null;
final decoration = widget.decoration; final decoration = widget.decoration;

View file

@ -9,7 +9,7 @@ import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'aves_dialog.dart'; import '../aves_dialog.dart';
class EditEntryDateDialog extends StatefulWidget { class EditEntryDateDialog extends StatefulWidget {
final AvesEntry entry; final AvesEntry entry;

View file

@ -0,0 +1,278 @@
import 'dart:math';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/search/expandable_filter_row.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class TagEditorPage extends StatefulWidget {
static const routeName = '/info/tag_editor';
final Map<AvesEntry, Set<String>> tagsByEntry;
const TagEditorPage({
Key? key,
required this.tagsByEntry,
}) : super(key: key);
@override
_TagEditorPageState createState() => _TagEditorPageState();
}
class _TagEditorPageState extends State<TagEditorPage> {
final TextEditingController _newTagTextController = TextEditingController();
final FocusNode _newTagTextFocusNode = FocusNode();
final ValueNotifier<String?> _expandedSectionNotifier = ValueNotifier(null);
late final List<String> _topTags;
static final List<String> _recentTags = [];
static const Color untaggedColor = Colors.blueGrey;
static const int tagHistoryCount = 10;
Map<AvesEntry, Set<String>> get tagsByEntry => widget.tagsByEntry;
@override
void initState() {
super.initState();
_initTopTags();
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final showCount = tagsByEntry.length > 1;
final Map<String, int> entryCountByTag = {};
tagsByEntry.entries.forEach((kv) {
kv.value.forEach((tag) => entryCountByTag[tag] = (entryCountByTag[tag] ?? 0) + 1);
});
List<MapEntry<String, int>> sortedTags = _sortEntryCountByTag(entryCountByTag);
return MediaQueryDataProvider(
child: Scaffold(
appBar: AppBar(
title: Text(l10n.tagEditorPageTitle),
actions: [
IconButton(
icon: const Icon(AIcons.reset),
onPressed: _reset,
tooltip: l10n.resetButtonTooltip,
),
],
),
body: SafeArea(
child: ValueListenableBuilder<String?>(
valueListenable: _expandedSectionNotifier,
builder: (context, expandedSection, child) {
return ValueListenableBuilder<TextEditingValue>(
valueListenable: _newTagTextController,
builder: (context, value, child) {
final upQuery = value.text.trim().toUpperCase();
bool containQuery(String s) => s.toUpperCase().contains(upQuery);
final recentFilters = _recentTags.where(containQuery).map((v) => TagFilter(v)).toList();
final topTagFilters = _topTags.where(containQuery).map((v) => TagFilter(v)).toList();
return ListView(
children: [
Padding(
padding: const EdgeInsetsDirectional.only(start: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: TextField(
controller: _newTagTextController,
focusNode: _newTagTextFocusNode,
decoration: InputDecoration(
labelText: l10n.tagEditorPageNewTagFieldLabel,
),
autofocus: true,
onSubmitted: (newTag) {
_addTag(newTag);
_newTagTextFocusNode.requestFocus();
},
),
),
ValueListenableBuilder<TextEditingValue>(
valueListenable: _newTagTextController,
builder: (context, value, child) {
return IconButton(
icon: const Icon(AIcons.add),
onPressed: value.text.isEmpty ? null : () => _addTag(_newTagTextController.text),
tooltip: l10n.tagEditorPageAddTagTooltip,
);
},
)
],
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: AnimatedCrossFade(
firstChild: ConstrainedBox(
constraints: const BoxConstraints(minHeight: AvesFilterChip.minChipHeight),
child: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(AIcons.tagOff, color: untaggedColor),
const SizedBox(width: 8),
Text(
l10n.filterTagEmptyLabel,
style: const TextStyle(color: untaggedColor),
),
],
),
),
),
secondChild: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: sortedTags.map((kv) {
final tag = kv.key;
return AvesFilterChip(
filter: TagFilter(tag),
removable: true,
showGenericIcon: false,
leadingOverride: showCount ? _TagCount(count: kv.value) : null,
onTap: (filter) => _removeTag(tag),
onLongPress: null,
);
}).toList(),
),
),
crossFadeState: sortedTags.isEmpty ? CrossFadeState.showFirst : CrossFadeState.showSecond,
duration: Durations.tagEditorTransition,
),
),
const Divider(height: 1),
_FilterRow(
title: l10n.searchSectionRecent,
filters: recentFilters,
expandedNotifier: _expandedSectionNotifier,
onTap: _addTag,
),
_FilterRow(
title: l10n.statsTopTags,
filters: topTagFilters,
expandedNotifier: _expandedSectionNotifier,
onTap: _addTag,
),
],
);
},
);
},
),
),
),
);
}
void _initTopTags() {
final Map<String, int> entryCountByTag = {};
final visibleEntries = context.read<CollectionSource?>()?.visibleEntries;
visibleEntries?.forEach((entry) {
entry.tags.forEach((tag) => entryCountByTag[tag] = (entryCountByTag[tag] ?? 0) + 1);
});
List<MapEntry<String, int>> sortedTopTags = _sortEntryCountByTag(entryCountByTag);
_topTags = sortedTopTags.map((kv) => kv.key).toList();
}
List<MapEntry<String, int>> _sortEntryCountByTag(Map<String, int> entryCountByTag) {
return entryCountByTag.entries.toList()
..sort((kv1, kv2) {
final c = kv2.value.compareTo(kv1.value);
return c != 0 ? c : compareAsciiUpperCase(kv1.key, kv2.key);
});
}
void _reset() {
setState(() => tagsByEntry.forEach((entry, tags) {
tags
..clear()
..addAll(entry.tags);
}));
}
void _addTag(String newTag) {
if (newTag.isNotEmpty) {
setState(() {
_recentTags
..remove(newTag)
..insert(0, newTag)
..removeRange(min(tagHistoryCount, _recentTags.length), _recentTags.length);
tagsByEntry.forEach((entry, tags) => tags.add(newTag));
});
_newTagTextController.clear();
}
}
void _removeTag(String tag) {
setState(() => tagsByEntry.forEach((entry, tags) => tags.remove(tag)));
}
}
class _FilterRow extends StatelessWidget {
final String title;
final List<TagFilter> filters;
final ValueNotifier<String?> expandedNotifier;
final void Function(String tag) onTap;
const _FilterRow({
Key? key,
required this.title,
required this.filters,
required this.expandedNotifier,
required this.onTap,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return filters.isEmpty
? const SizedBox()
: ExpandableFilterRow(
title: title,
filters: filters,
expandedNotifier: expandedNotifier,
showGenericIcon: false,
onTap: (filter) => onTap((filter as TagFilter).tag),
onLongPress: null,
);
}
}
class _TagCount extends StatelessWidget {
final int count;
const _TagCount({
Key? key,
required this.count,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 6),
decoration: BoxDecoration(
border: Border.fromBorderSide(BorderSide(
color: DefaultTextStyle.of(context).style.color!,
)),
borderRadius: const BorderRadius.all(Radius.circular(123)),
),
child: Text(
'$count',
style: const TextStyle(fontSize: AvesFilterChip.fontSize),
),
);
}
}

View file

@ -9,7 +9,7 @@ import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'aves_dialog.dart'; import '../aves_dialog.dart';
class RemoveEntryMetadataDialog extends StatefulWidget { class RemoveEntryMetadataDialog extends StatefulWidget {
final bool showJpegTypes; final bool showJpegTypes;

View file

@ -5,7 +5,7 @@ import 'package:aves/services/common/services.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'aves_dialog.dart'; import '../aves_dialog.dart';
class RenameEntryDialog extends StatefulWidget { class RenameEntryDialog extends StatefulWidget {
final AvesEntry entry; final AvesEntry entry;

View file

@ -8,7 +8,7 @@ import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'aves_dialog.dart'; import '../aves_dialog.dart';
class CreateAlbumDialog extends StatefulWidget { class CreateAlbumDialog extends StatefulWidget {
const CreateAlbumDialog({Key? key}) : super(key: key); const CreateAlbumDialog({Key? key}) : super(key: key);

View file

@ -14,7 +14,7 @@ import 'package:aves/widgets/common/basic/query_bar.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/empty.dart'; import 'package:aves/widgets/common/identity/empty.dart';
import 'package:aves/widgets/common/providers/selection_provider.dart'; import 'package:aves/widgets/common/providers/selection_provider.dart';
import 'package:aves/widgets/dialogs/create_album_dialog.dart'; import 'package:aves/widgets/dialogs/filter_editors/create_album_dialog.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart';
import 'package:aves/widgets/filter_grids/common/action_delegates/album_set.dart'; import 'package:aves/widgets/filter_grids/common/action_delegates/album_set.dart';
import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart'; import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart';

View file

@ -18,8 +18,8 @@ import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/dialogs/create_album_dialog.dart'; import 'package:aves/widgets/dialogs/filter_editors/create_album_dialog.dart';
import 'package:aves/widgets/dialogs/rename_album_dialog.dart'; import 'package:aves/widgets/dialogs/filter_editors/rename_album_dialog.dart';
import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart'; import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';

View file

@ -15,7 +15,7 @@ import 'package:aves/widgets/common/action_mixins/size_aware.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/dialogs/cover_selection_dialog.dart'; import 'package:aves/widgets/dialogs/filter_editors/cover_selection_dialog.dart';
import 'package:aves/widgets/map/map_page.dart'; import 'package:aves/widgets/map/map_page.dart';
import 'package:aves/widgets/search/search_delegate.dart'; import 'package:aves/widgets/search/search_delegate.dart';
import 'package:aves/widgets/stats/stats_page.dart'; import 'package:aves/widgets/stats/stats_page.dart';

View file

@ -136,7 +136,7 @@ class _HomePageState extends State<HomePage> {
final entry = await mediaFileService.getEntry(uri, mimeType); final entry = await mediaFileService.getEntry(uri, mimeType);
if (entry != null) { if (entry != null) {
// cataloguing is essential for coordinates and video rotation // cataloguing is essential for coordinates and video rotation
await entry.catalog(background: false, persist: false, force: false); await entry.catalog(background: false, force: false, persist: false);
} }
return entry; return entry;
} }

View file

@ -9,16 +9,20 @@ class ExpandableFilterRow extends StatelessWidget {
final String? title; final String? title;
final Iterable<CollectionFilter> filters; final Iterable<CollectionFilter> filters;
final ValueNotifier<String?> expandedNotifier; final ValueNotifier<String?> expandedNotifier;
final bool showGenericIcon;
final HeroType Function(CollectionFilter filter)? heroTypeBuilder; final HeroType Function(CollectionFilter filter)? heroTypeBuilder;
final FilterCallback onTap; final FilterCallback onTap;
final OffsetFilterCallback? onLongPress;
const ExpandableFilterRow({ const ExpandableFilterRow({
Key? key, Key? key,
this.title, this.title,
required this.filters, required this.filters,
required this.expandedNotifier, required this.expandedNotifier,
this.showGenericIcon = true,
this.heroTypeBuilder, this.heroTypeBuilder,
required this.onTap, required this.onTap,
required this.onLongPress,
}) : super(key: key); }) : super(key: key);
static const double horizontalPadding = 8; static const double horizontalPadding = 8;
@ -109,8 +113,10 @@ class ExpandableFilterRow extends StatelessWidget {
// key `album-{path}` is expected by test driver // key `album-{path}` is expected by test driver
key: Key(filter.key), key: Key(filter.key),
filter: filter, filter: filter,
showGenericIcon: showGenericIcon,
heroType: heroTypeBuilder?.call(filter) ?? HeroType.onTap, heroType: heroTypeBuilder?.call(filter) ?? HeroType.onTap,
onTap: onTap, onTap: onTap,
onLongPress: onLongPress,
); );
} }
} }

View file

@ -27,10 +27,10 @@ import 'package:provider/provider.dart';
class CollectionSearchDelegate { class CollectionSearchDelegate {
final CollectionSource source; final CollectionSource source;
final CollectionLens? parentCollection; final CollectionLens? parentCollection;
final ValueNotifier<String?> expandedSectionNotifier = ValueNotifier(null); final ValueNotifier<String?> _expandedSectionNotifier = ValueNotifier(null);
final bool canPop; final bool canPop;
static const searchHistoryCount = 10; static const int searchHistoryCount = 10;
static final typeFilters = [ static final typeFilters = [
FavouriteFilter.instance, FavouriteFilter.instance,
MimeFilter.image, MimeFilter.image,
@ -90,7 +90,7 @@ class CollectionSearchDelegate {
bool containQuery(String s) => s.toUpperCase().contains(upQuery); bool containQuery(String s) => s.toUpperCase().contains(upQuery);
return SafeArea( return SafeArea(
child: ValueListenableBuilder<String?>( child: ValueListenableBuilder<String?>(
valueListenable: expandedSectionNotifier, valueListenable: _expandedSectionNotifier,
builder: (context, expandedSection, child) { builder: (context, expandedSection, child) {
final queryFilter = _buildQueryFilter(false); final queryFilter = _buildQueryFilter(false);
return Selector<Settings, Set<CollectionFilter>>( return Selector<Settings, Set<CollectionFilter>>(
@ -195,9 +195,10 @@ class CollectionSearchDelegate {
return ExpandableFilterRow( return ExpandableFilterRow(
title: title, title: title,
filters: filters, filters: filters,
expandedNotifier: expandedSectionNotifier, expandedNotifier: _expandedSectionNotifier,
heroTypeBuilder: heroTypeBuilder, heroTypeBuilder: heroTypeBuilder,
onTap: (filter) => _select(context, filter is QueryFilter ? QueryFilter(filter.query) : filter), onTap: (filter) => _select(context, filter is QueryFilter ? QueryFilter(filter.query) : filter),
onLongPress: AvesFilterChip.showDefaultLongPressMenu,
); );
} }

View file

@ -56,7 +56,7 @@ class StatsPage extends StatelessWidget {
entryCountPerPlace[place] = (entryCountPerPlace[place] ?? 0) + 1; entryCountPerPlace[place] = (entryCountPerPlace[place] ?? 0) + 1;
} }
} }
entry.xmpSubjects.forEach((tag) { entry.tags.forEach((tag) {
entryCountPerTag[tag] = (entryCountPerTag[tag] ?? 0) + 1; entryCountPerTag[tag] = (entryCountPerTag[tag] ?? 0) + 1;
}); });
}); });

View file

@ -112,9 +112,10 @@ class ViewerDebugPage extends StatelessWidget {
'isGeotiff': '${entry.isGeotiff}', 'isGeotiff': '${entry.isGeotiff}',
'is360': '${entry.is360}', 'is360': '${entry.is360}',
'canEdit': '${entry.canEdit}', 'canEdit': '${entry.canEdit}',
'canEditExif': '${entry.canEditExif}', 'canEditDate': '${entry.canEditDate}',
'canEditTags': '${entry.canEditTags}',
'canRotateAndFlip': '${entry.canRotateAndFlip}', 'canRotateAndFlip': '${entry.canRotateAndFlip}',
'xmpSubjects': '${entry.xmpSubjects}', 'tags': '${entry.tags}',
}, },
), ),
const Divider(), const Divider(),

View file

@ -20,8 +20,8 @@ import 'package:aves/widgets/common/action_mixins/size_aware.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart'; import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/dialogs/entry_editors/rename_entry_dialog.dart';
import 'package:aves/widgets/dialogs/export_entry_dialog.dart'; import 'package:aves/widgets/dialogs/export_entry_dialog.dart';
import 'package:aves/widgets/dialogs/rename_entry_dialog.dart';
import 'package:aves/widgets/filter_grids/album_pick.dart'; import 'package:aves/widgets/filter_grids/album_pick.dart';
import 'package:aves/widgets/viewer/debug/debug_page.dart'; import 'package:aves/widgets/viewer/debug/debug_page.dart';
import 'package:aves/widgets/viewer/info/notifications.dart'; import 'package:aves/widgets/viewer/info/notifications.dart';
@ -130,15 +130,15 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
Future<void> _flip(BuildContext context, AvesEntry entry) async { Future<void> _flip(BuildContext context, AvesEntry entry) async {
if (!await checkStoragePermission(context, {entry})) return; if (!await checkStoragePermission(context, {entry})) return;
final success = await entry.flip(persist: _isMainMode(context)); final dataTypes = await entry.flip(persist: _isMainMode(context));
if (!success) showFeedback(context, context.l10n.genericFailureFeedback); if (dataTypes.isEmpty) showFeedback(context, context.l10n.genericFailureFeedback);
} }
Future<void> _rotate(BuildContext context, AvesEntry entry, {required bool clockwise}) async { Future<void> _rotate(BuildContext context, AvesEntry entry, {required bool clockwise}) async {
if (!await checkStoragePermission(context, {entry})) return; if (!await checkStoragePermission(context, {entry})) return;
final success = await entry.rotate(clockwise: clockwise, persist: _isMainMode(context)); final dataTypes = await entry.rotate(clockwise: clockwise, persist: _isMainMode(context));
if (!success) showFeedback(context, context.l10n.genericFailureFeedback); if (dataTypes.isEmpty) showFeedback(context, context.l10n.genericFailureFeedback);
} }
Future<void> _rotateScreen(BuildContext context) async { Future<void> _rotateScreen(BuildContext context) async {

View file

@ -169,7 +169,7 @@ class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
// make sure to locate the entry, // make sure to locate the entry,
// so that we can display the address instead of coordinates // so that we can display the address instead of coordinates
// even when initial collection locating has not reached this entry yet // even when initial collection locating has not reached this entry yet
await _entry.catalog(background: false, persist: true, force: false); await _entry.catalog(background: false, force: false, persist: true);
await _entry.locate(background: false, force: false, geocoderLocale: settings.appliedLocale); await _entry.locate(background: false, force: false, geocoderLocale: settings.appliedLocale);
} else { } else {
Navigator.pop(context); Navigator.pop(context);

View file

@ -1,4 +1,5 @@
import 'package:aves/image_providers/app_icon_image_provider.dart'; import 'package:aves/image_providers/app_icon_image_provider.dart';
import 'package:aves/model/actions/entry_info_actions.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/favourites.dart'; import 'package:aves/model/favourites.dart';
import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/album.dart';
@ -9,11 +10,13 @@ import 'package:aves/model/filters/type.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/theme/format.dart'; import 'package:aves/theme/format.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/file_utils.dart'; import 'package:aves/utils/file_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
import 'package:aves/widgets/viewer/info/common.dart'; import 'package:aves/widgets/viewer/info/common.dart';
import 'package:aves/widgets/viewer/info/entry_info_action_delegate.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -22,12 +25,16 @@ import 'package:provider/provider.dart';
class BasicSection extends StatelessWidget { class BasicSection extends StatelessWidget {
final AvesEntry entry; final AvesEntry entry;
final CollectionLens? collection; final CollectionLens? collection;
final EntryInfoActionDelegate actionDelegate;
final ValueNotifier<bool> isEditingTagNotifier;
final FilterCallback onFilter; final FilterCallback onFilter;
const BasicSection({ const BasicSection({
Key? key, Key? key,
required this.entry, required this.entry,
this.collection, this.collection,
required this.actionDelegate,
required this.isEditingTagNotifier,
required this.onFilter, required this.onFilter,
}) : super(key: key); }) : super(key: key);
@ -80,7 +87,7 @@ class BasicSection extends StatelessWidget {
} }
Widget _buildChips(BuildContext context) { Widget _buildChips(BuildContext context) {
final tags = entry.xmpSubjects..sort(compareAsciiUpperCase); final tags = entry.tags.toList()..sort(compareAsciiUpperCase);
final album = entry.directory; final album = entry.directory;
final filters = { final filters = {
MimeFilter(entry.mimeType), MimeFilter(entry.mimeType),
@ -101,19 +108,60 @@ class BasicSection extends StatelessWidget {
...filters, ...filters,
if (entry.isFavourite) FavouriteFilter.instance, if (entry.isFavourite) FavouriteFilter.instance,
]..sort(); ]..sort();
if (effectiveFilters.isEmpty) return const SizedBox.shrink();
return Padding( final children = <Widget>[
padding: const EdgeInsets.symmetric(horizontal: AvesFilterChip.outlineWidth / 2) + const EdgeInsets.only(top: 8), ...effectiveFilters.map((filter) => AvesFilterChip(
child: Wrap( filter: filter,
spacing: 8, onTap: onFilter,
runSpacing: 8, )),
children: effectiveFilters if (actionDelegate.canApply(EntryInfoAction.editTags)) _buildEditTagButton(context),
.map((filter) => AvesFilterChip( ];
filter: filter,
onTap: onFilter, return children.isEmpty
)) ? const SizedBox()
.toList(), : Padding(
), padding: const EdgeInsets.symmetric(horizontal: AvesFilterChip.outlineWidth / 2) + const EdgeInsets.only(top: 8),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: children,
),
);
},
);
}
Widget _buildEditTagButton(BuildContext context) {
const action = EntryInfoAction.editTags;
return ValueListenableBuilder<bool>(
valueListenable: isEditingTagNotifier,
builder: (context, isEditing, child) {
return Stack(
children: [
DecoratedBox(
decoration: const BoxDecoration(
border: Border.fromBorderSide(BorderSide(
color: AvesFilterChip.defaultOutlineColor,
width: AvesFilterChip.outlineWidth,
)),
borderRadius: BorderRadius.all(Radius.circular(AvesFilterChip.defaultRadius)),
),
child: IconButton(
icon: const Icon(AIcons.addTag),
onPressed: isEditing ? null : () => actionDelegate.onActionSelected(context, action),
tooltip: action.getText(context),
),
),
if (isEditing)
const Positioned.fill(
child: Padding(
padding: EdgeInsets.all(1.0),
child: CircularProgressIndicator(
strokeWidth: AvesFilterChip.outlineWidth,
),
),
),
],
); );
}, },
); );

View file

@ -1,8 +1,13 @@
import 'dart:async';
import 'package:aves/app_mode.dart'; import 'package:aves/app_mode.dart';
import 'package:aves/model/actions/entry_info_actions.dart'; import 'package:aves/model/actions/entry_info_actions.dart';
import 'package:aves/model/actions/events.dart';
import 'package:aves/model/entry.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/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/widgets/common/action_mixins/entry_editor.dart'; import 'package:aves/widgets/common/action_mixins/entry_editor.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart';
import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
@ -14,12 +19,17 @@ import 'package:provider/provider.dart';
class EntryInfoActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwareMixin { class EntryInfoActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwareMixin {
final AvesEntry entry; final AvesEntry entry;
const EntryInfoActionDelegate(this.entry); final StreamController<ActionEvent<EntryInfoAction>> _eventStreamController = StreamController<ActionEvent<EntryInfoAction>>.broadcast();
Stream<ActionEvent<EntryInfoAction>> get eventStream => _eventStreamController.stream;
EntryInfoActionDelegate(this.entry);
bool isVisible(EntryInfoAction action) { bool isVisible(EntryInfoAction action) {
switch (action) { switch (action) {
// general // general
case EntryInfoAction.editDate: case EntryInfoAction.editDate:
case EntryInfoAction.editTags:
case EntryInfoAction.removeMetadata: case EntryInfoAction.removeMetadata:
return true; return true;
// motion photo // motion photo
@ -32,7 +42,9 @@ class EntryInfoActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAw
switch (action) { switch (action) {
// general // general
case EntryInfoAction.editDate: case EntryInfoAction.editDate:
return entry.canEditExif; return entry.canEditDate;
case EntryInfoAction.editTags:
return entry.canEditTags;
case EntryInfoAction.removeMetadata: case EntryInfoAction.removeMetadata:
return entry.canRemoveMetadata; return entry.canRemoveMetadata;
// motion photo // motion photo
@ -42,11 +54,15 @@ class EntryInfoActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAw
} }
void onActionSelected(BuildContext context, EntryInfoAction action) async { void onActionSelected(BuildContext context, EntryInfoAction action) async {
_eventStreamController.add(ActionStartedEvent(action));
switch (action) { switch (action) {
// general // general
case EntryInfoAction.editDate: case EntryInfoAction.editDate:
await _editDate(context); await _editDate(context);
break; break;
case EntryInfoAction.editTags:
await _editTags(context);
break;
case EntryInfoAction.removeMetadata: case EntryInfoAction.removeMetadata:
await _removeMetadata(context); await _removeMetadata(context);
break; break;
@ -55,26 +71,37 @@ class EntryInfoActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAw
OpenEmbeddedDataNotification.motionPhotoVideo().dispatch(context); OpenEmbeddedDataNotification.motionPhotoVideo().dispatch(context);
break; break;
} }
_eventStreamController.add(ActionEndedEvent(action));
} }
bool _isMainMode(BuildContext context) => context.read<ValueNotifier<AppMode>>().value == AppMode.main; bool _isMainMode(BuildContext context) => context.read<ValueNotifier<AppMode>>().value == AppMode.main;
Future<void> _edit(BuildContext context, Future<bool> Function() apply) async { Future<void> _edit(BuildContext context, Future<Set<EntryDataType>> Function() apply) async {
if (!await checkStoragePermission(context, {entry})) return; 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 l10n = context.l10n;
final source = context.read<CollectionSource?>(); final source = context.read<CollectionSource?>();
source?.pauseMonitoring(); source?.pauseMonitoring();
final success = await apply();
if (success) { final dataTypes = await apply();
if (_isMainMode(context) && source != null) { final success = dataTypes.isNotEmpty;
await source.refreshEntry(entry); 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 { } else {
await entry.refresh(background: false, persist: false, force: true, geocoderLocale: settings.appliedLocale); showFeedback(context, l10n.genericFailureFeedback);
} }
showFeedback(context, l10n.genericSuccessFeedback); } catch (e, stack) {
} else { await reportService.recordError(e, stack);
showFeedback(context, l10n.genericFailureFeedback);
} }
source?.resumeMonitoring(); source?.resumeMonitoring();
} }
@ -86,6 +113,17 @@ class EntryInfoActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAw
await _edit(context, () => entry.editDate(modifier)); await _edit(context, () => entry.editDate(modifier));
} }
Future<void> _editTags(BuildContext context) async {
final newTagsByEntry = await selectTags(context, {entry});
if (newTagsByEntry == null) return;
final newTags = newTagsByEntry[entry] ?? entry.tags;
final currentTags = entry.tags;
if (newTags.length == currentTags.length && newTags.every(currentTags.contains)) return;
await _edit(context, () => entry.editTags(newTags));
}
Future<void> _removeMetadata(BuildContext context) async { Future<void> _removeMetadata(BuildContext context) async {
final types = await selectMetadataToRemove(context, {entry}); final types = await selectMetadataToRemove(context, {entry});
if (types == null) return; if (types == null) return;

View file

@ -13,19 +13,20 @@ import 'package:flutter/scheduler.dart';
class InfoAppBar extends StatelessWidget { class InfoAppBar extends StatelessWidget {
final AvesEntry entry; final AvesEntry entry;
final EntryInfoActionDelegate actionDelegate;
final ValueNotifier<Map<String, MetadataDirectory>> metadataNotifier; final ValueNotifier<Map<String, MetadataDirectory>> metadataNotifier;
final VoidCallback onBackPressed; final VoidCallback onBackPressed;
const InfoAppBar({ const InfoAppBar({
Key? key, Key? key,
required this.entry, required this.entry,
required this.actionDelegate,
required this.metadataNotifier, required this.metadataNotifier,
required this.onBackPressed, required this.onBackPressed,
}) : super(key: key); }) : super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final actionDelegate = EntryInfoActionDelegate(entry);
final menuActions = EntryInfoActions.all.where(actionDelegate.isVisible); final menuActions = EntryInfoActions.all.where(actionDelegate.isVisible);
return SliverAppBar( return SliverAppBar(

View file

@ -1,3 +1,7 @@
import 'dart:async';
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.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
@ -6,6 +10,7 @@ import 'package:aves/widgets/common/basic/insets.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/viewer/embedded/embedded_data_opener.dart'; import 'package:aves/widgets/viewer/embedded/embedded_data_opener.dart';
import 'package:aves/widgets/viewer/info/basic_section.dart'; import 'package:aves/widgets/viewer/info/basic_section.dart';
import 'package:aves/widgets/viewer/info/entry_info_action_delegate.dart';
import 'package:aves/widgets/viewer/info/info_app_bar.dart'; import 'package:aves/widgets/viewer/info/info_app_bar.dart';
import 'package:aves/widgets/viewer/info/location_section.dart'; import 'package:aves/widgets/viewer/info/location_section.dart';
import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart'; import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart';
@ -142,19 +147,57 @@ class _InfoPageContent extends StatefulWidget {
} }
class _InfoPageContentState extends State<_InfoPageContent> { class _InfoPageContentState extends State<_InfoPageContent> {
static const horizontalPadding = EdgeInsets.symmetric(horizontal: 8); final List<StreamSubscription> _subscriptions = [];
late EntryInfoActionDelegate _actionDelegate;
final ValueNotifier<Map<String, MetadataDirectory>> _metadataNotifier = ValueNotifier({}); final ValueNotifier<Map<String, MetadataDirectory>> _metadataNotifier = ValueNotifier({});
final ValueNotifier<bool> _isEditingTagNotifier = ValueNotifier(false);
static const horizontalPadding = EdgeInsets.symmetric(horizontal: 8);
CollectionLens? get collection => widget.collection; CollectionLens? get collection => widget.collection;
AvesEntry get entry => widget.entry; AvesEntry get entry => widget.entry;
@override
void initState() {
super.initState();
_registerWidget(widget);
}
@override
void didUpdateWidget(covariant _InfoPageContent oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.entry != widget.entry) {
_unregisterWidget(oldWidget);
_registerWidget(widget);
}
}
@override
void dispose() {
_unregisterWidget(widget);
super.dispose();
}
void _registerWidget(_InfoPageContent widget) {
_actionDelegate = EntryInfoActionDelegate(widget.entry);
_subscriptions.add(_actionDelegate.eventStream.listen(_onActionDelegateEvent));
}
void _unregisterWidget(_InfoPageContent widget) {
_subscriptions
..forEach((sub) => sub.cancel())
..clear();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final basicSection = BasicSection( final basicSection = BasicSection(
entry: entry, entry: entry,
collection: collection, collection: collection,
actionDelegate: _actionDelegate,
isEditingTagNotifier: _isEditingTagNotifier,
onFilter: _goToCollection, onFilter: _goToCollection,
); );
final locationAtTop = widget.split && entry.hasGps; final locationAtTop = widget.split && entry.hasGps;
@ -194,6 +237,7 @@ class _InfoPageContentState extends State<_InfoPageContent> {
slivers: [ slivers: [
InfoAppBar( InfoAppBar(
entry: entry, entry: entry,
actionDelegate: _actionDelegate,
metadataNotifier: _metadataNotifier, metadataNotifier: _metadataNotifier,
onBackPressed: widget.goToViewer, onBackPressed: widget.goToViewer,
), ),
@ -210,6 +254,18 @@ 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;
}
});
}
}
void _goToCollection(CollectionFilter filter) { void _goToCollection(CollectionFilter filter) {
if (collection == null) return; if (collection == null) return;
FilterSelectedNotification(filter).dispatch(context); FilterSelectedNotification(filter).dispatch(context);

View file

@ -13,7 +13,7 @@ class FakeMetadataDb extends Fake implements MetadataDb {
Future<void> init() => SynchronousFuture(null); Future<void> init() => SynchronousFuture(null);
@override @override
Future<void> removeIds(Set<int> contentIds, {required bool metadataOnly}) => SynchronousFuture(null); Future<void> removeIds(Set<int> contentIds, {Set<EntryDataType>? dataTypes}) => SynchronousFuture(null);
// entries // entries

View file

@ -125,10 +125,10 @@ void main() {
longitude: australiaLatLng.longitude, longitude: australiaLatLng.longitude,
), ),
); );
expect(image1.xmpSubjects, []); expect(image1.tags, <String>{});
final source = await _initSource(); final source = await _initSource();
expect(image1.xmpSubjects, [aTag]); expect(image1.tags, {aTag});
expect(image1.addressDetails, australiaAddress.copyWith(contentId: image1.contentId)); expect(image1.addressDetails, australiaAddress.copyWith(contentId: image1.contentId));
expect(source.visibleEntries.length, 0); expect(source.visibleEntries.length, 0);

View file

@ -1 +1,17 @@
{} {
"ko": [
"resetButtonTooltip",
"entryInfoActionEditTags",
"tagEditorPageTitle",
"tagEditorPageNewTagFieldLabel",
"tagEditorPageAddTagTooltip"
],
"ru": [
"resetButtonTooltip",
"entryInfoActionEditTags",
"tagEditorPageTitle",
"tagEditorPageNewTagFieldLabel",
"tagEditorPageAddTagTooltip"
]
}