#1 edit tags via XMP & IPTC, for JPEG, GIF, PNG, TIFF
This commit is contained in:
parent
f1aefb2bb1
commit
fbcd8ad208
54 changed files with 1387 additions and 130 deletions
|
@ -149,7 +149,7 @@ dependencies {
|
|||
// forked, built by JitPack, cf https://jitpack.io/p/deckerst/Android-TiffBitmapFactory
|
||||
implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a'
|
||||
// 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'
|
||||
|
||||
kapt 'androidx.annotation:annotation:1.3.0'
|
||||
|
|
|
@ -20,6 +20,8 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
|
|||
"rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) }
|
||||
"flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) }
|
||||
"editDate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::editDate) }
|
||||
"setIptc" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::setIptc) }
|
||||
"setXmp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::setXmp) }
|
||||
"removeTypes" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::removeTypes) }
|
||||
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) {
|
||||
val types = call.argument<List<String>>("types")
|
||||
val entryMap = call.argument<FieldMap>("entry")
|
||||
|
|
|
@ -10,6 +10,8 @@ import android.provider.MediaStore
|
|||
import android.util.Log
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
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.drew.imaging.ImageMetadataReader
|
||||
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) }
|
||||
"getMultiPageInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMultiPageInfo) }
|
||||
"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) }
|
||||
"getContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverProp) }
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
val prop = call.argument<String>("prop")
|
||||
if (prop == null) {
|
||||
|
@ -829,6 +886,11 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
"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
|
||||
private const val KEY_MIME_TYPE = "mimeType"
|
||||
private const val KEY_DATE_MILLIS = "dateMillis"
|
||||
|
|
|
@ -32,12 +32,12 @@ object Metadata {
|
|||
const val DIR_PNG_TEXTUAL_DATA = "PNG Textual Data" // custom
|
||||
|
||||
// types of metadata
|
||||
const val TYPE_COMMENT = "comment"
|
||||
const val TYPE_EXIF = "exif"
|
||||
const val TYPE_ICC_PROFILE = "icc_profile"
|
||||
const val TYPE_IPTC = "iptc"
|
||||
const val TYPE_JFIF = "jfif"
|
||||
const val TYPE_JPEG_ADOBE = "jpeg_adobe"
|
||||
const val TYPE_JPEG_COMMENT = "jpeg_comment"
|
||||
const val TYPE_JPEG_DUCKY = "jpeg_ducky"
|
||||
const val TYPE_PHOTOSHOP_IRB = "photoshop_irb"
|
||||
const val TYPE_XMP = "xmp"
|
||||
|
|
|
@ -1,17 +1,21 @@
|
|||
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_ICC_PROFILE
|
||||
import deckers.thibault.aves.metadata.Metadata.TYPE_IPTC
|
||||
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_COMMENT
|
||||
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_XMP
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import pixy.meta.meta.Metadata
|
||||
import pixy.meta.meta.MetadataEntry
|
||||
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.xmp.XMP
|
||||
import pixy.meta.string.XMLUtils
|
||||
|
@ -50,9 +54,46 @@ object PixyMetaHelper {
|
|||
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 setXmp(input: InputStream, output: OutputStream, xmpString: String, extendedXmpString: String?) {
|
||||
fun setXmp(
|
||||
input: InputStream,
|
||||
output: OutputStream,
|
||||
xmpString: String?,
|
||||
extendedXmpString: String?
|
||||
) {
|
||||
if (extendedXmpString != null) {
|
||||
JPGMeta.insertXMP(input, output, xmpString, extendedXmpString)
|
||||
} else {
|
||||
|
@ -70,12 +111,12 @@ object PixyMetaHelper {
|
|||
}
|
||||
|
||||
private fun toMetadataType(typeString: String): MetadataType? = when (typeString) {
|
||||
TYPE_COMMENT -> MetadataType.COMMENT
|
||||
TYPE_EXIF -> MetadataType.EXIF
|
||||
TYPE_ICC_PROFILE -> MetadataType.ICC_PROFILE
|
||||
TYPE_IPTC -> MetadataType.IPTC
|
||||
TYPE_JFIF -> MetadataType.JPG_JFIF
|
||||
TYPE_JPEG_ADOBE -> MetadataType.JPG_ADOBE
|
||||
TYPE_JPEG_COMMENT -> MetadataType.COMMENT
|
||||
TYPE_JPEG_DUCKY -> MetadataType.JPG_DUCKY
|
||||
TYPE_PHOTOSHOP_IRB -> MetadataType.PHOTOSHOP_IRB
|
||||
TYPE_XMP -> MetadataType.XMP
|
||||
|
|
|
@ -27,6 +27,7 @@ import deckers.thibault.aves.model.FieldMap
|
|||
import deckers.thibault.aves.model.NameConflictStrategy
|
||||
import deckers.thibault.aves.utils.*
|
||||
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.canRemoveMetadata
|
||||
import deckers.thibault.aves.utils.MimeTypes.extensionFor
|
||||
|
@ -460,6 +461,94 @@ abstract class ImageProvider {
|
|||
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(
|
||||
context: Context,
|
||||
path: String,
|
||||
|
@ -467,7 +556,9 @@ abstract class ImageProvider {
|
|||
mimeType: String,
|
||||
callback: ImageOpCallback,
|
||||
trailerDiff: Int = 0,
|
||||
edit: (xmp: String) -> String,
|
||||
coreXmp: String? = null,
|
||||
extendedXmp: String? = null,
|
||||
editCoreXmp: ((xmp: String) -> String)? = null,
|
||||
): Boolean {
|
||||
if (!canEditXmp(mimeType)) {
|
||||
callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType"))
|
||||
|
@ -479,18 +570,34 @@ abstract class ImageProvider {
|
|||
val editableFile = File.createTempFile("aves", null).apply {
|
||||
deleteOnExit()
|
||||
try {
|
||||
val xmp = StorageUtils.openInputStream(context, uri)?.use { input -> PixyMetaHelper.getXmp(input) }
|
||||
if (xmp == null) {
|
||||
callback.onFailure(Exception("failed to find XMP for path=$path, uri=$uri"))
|
||||
return false
|
||||
var editedXmpString = coreXmp
|
||||
var editedExtendedXmp = extendedXmp
|
||||
if (editCoreXmp != null) {
|
||||
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 ->
|
||||
// reopen input to read from start
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
val editedXmpString = edit(xmp.xmpDocString())
|
||||
val extendedXmpString = if (xmp.hasExtendedXmp()) xmp.extendedXmpDocString() else null
|
||||
PixyMetaHelper.setXmp(input, output, editedXmpString, extendedXmpString)
|
||||
if (editedXmpString != null) {
|
||||
if (editedExtendedXmp != null && mimeType != MimeTypes.JPEG) {
|
||||
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) {
|
||||
|
@ -538,7 +645,7 @@ abstract class ImageProvider {
|
|||
"We need to edit XMP to adjust trailer video offset by $diff bytes."
|
||||
)
|
||||
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(
|
||||
// GCamera motion photo
|
||||
"${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}=\"$newTrailerOffset\"",
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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(
|
||||
context: Context,
|
||||
path: String,
|
||||
|
|
|
@ -110,7 +110,16 @@ object MimeTypes {
|
|||
}
|
||||
|
||||
// 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
|
||||
fun canRemoveMetadata(mimeType: String) = when (mimeType) {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.5.31'
|
||||
ext.kotlin_version = '1.6.0'
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
|
|
|
@ -53,6 +53,8 @@
|
|||
"@hideTooltip": {},
|
||||
"removeTooltip": "Remove",
|
||||
"@removeTooltip": {},
|
||||
"resetButtonTooltip": "Reset",
|
||||
"@resetButtonTooltip": {},
|
||||
|
||||
"doubleBackExitMessage": "Tap “back” again to exit.",
|
||||
"@doubleBackExitMessage": {},
|
||||
|
@ -145,6 +147,8 @@
|
|||
|
||||
"entryInfoActionEditDate": "Edit date & time",
|
||||
"@entryInfoActionEditDate": {},
|
||||
"entryInfoActionEditTags": "Edit tags",
|
||||
"@entryInfoActionEditTags": {},
|
||||
"entryInfoActionRemoveMetadata": "Remove metadata",
|
||||
"@entryInfoActionRemoveMetadata": {},
|
||||
|
||||
|
@ -1026,6 +1030,13 @@
|
|||
"viewerInfoSearchSuggestionRights": "Rights",
|
||||
"@viewerInfoSearchSuggestionRights": {},
|
||||
|
||||
"tagEditorPageTitle": "Edit Tags",
|
||||
"@tagEditorPageTitle": {},
|
||||
"tagEditorPageNewTagFieldLabel": "New tag",
|
||||
"@tagEditorPageNewTagFieldLabel": {},
|
||||
"tagEditorPageAddTagTooltip": "Add tag",
|
||||
"@tagEditorPageAddTagTooltip": {},
|
||||
|
||||
"panoramaEnableSensorControl": "Enable sensor control",
|
||||
"@panoramaEnableSensorControl": {},
|
||||
"panoramaDisableSensorControl": "Disable sensor control",
|
||||
|
|
|
@ -5,6 +5,7 @@ import 'package:flutter/widgets.dart';
|
|||
enum EntryInfoAction {
|
||||
// general
|
||||
editDate,
|
||||
editTags,
|
||||
removeMetadata,
|
||||
// motion photo
|
||||
viewMotionPhotoVideo,
|
||||
|
@ -13,6 +14,7 @@ enum EntryInfoAction {
|
|||
class EntryInfoActions {
|
||||
static const all = [
|
||||
EntryInfoAction.editDate,
|
||||
EntryInfoAction.editTags,
|
||||
EntryInfoAction.removeMetadata,
|
||||
EntryInfoAction.viewMotionPhotoVideo,
|
||||
];
|
||||
|
@ -24,6 +26,8 @@ extension ExtraEntryInfoAction on EntryInfoAction {
|
|||
// general
|
||||
case EntryInfoAction.editDate:
|
||||
return context.l10n.entryInfoActionEditDate;
|
||||
case EntryInfoAction.editTags:
|
||||
return context.l10n.entryInfoActionEditTags;
|
||||
case EntryInfoAction.removeMetadata:
|
||||
return context.l10n.entryInfoActionRemoveMetadata;
|
||||
// motion photo
|
||||
|
@ -41,6 +45,8 @@ extension ExtraEntryInfoAction on EntryInfoAction {
|
|||
// general
|
||||
case EntryInfoAction.editDate:
|
||||
return AIcons.date;
|
||||
case EntryInfoAction.editTags:
|
||||
return AIcons.addTag;
|
||||
case EntryInfoAction.removeMetadata:
|
||||
return AIcons.clear;
|
||||
// motion photo
|
||||
|
|
|
@ -26,6 +26,7 @@ enum EntrySetAction {
|
|||
rotateCW,
|
||||
flip,
|
||||
editDate,
|
||||
editTags,
|
||||
removeMetadata,
|
||||
}
|
||||
|
||||
|
@ -104,6 +105,8 @@ extension ExtraEntrySetAction on EntrySetAction {
|
|||
return context.l10n.entryActionFlip;
|
||||
case EntrySetAction.editDate:
|
||||
return context.l10n.entryInfoActionEditDate;
|
||||
case EntrySetAction.editTags:
|
||||
return context.l10n.entryInfoActionEditTags;
|
||||
case EntrySetAction.removeMetadata:
|
||||
return context.l10n.entryInfoActionRemoveMetadata;
|
||||
}
|
||||
|
@ -158,6 +161,8 @@ extension ExtraEntrySetAction on EntrySetAction {
|
|||
return AIcons.flip;
|
||||
case EntrySetAction.editDate:
|
||||
return AIcons.date;
|
||||
case EntrySetAction.editTags:
|
||||
return AIcons.addTag;
|
||||
case EntrySetAction.removeMetadata:
|
||||
return AIcons.clear;
|
||||
}
|
||||
|
|
18
lib/model/actions/events.dart
Normal file
18
lib/model/actions/events.dart
Normal 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);
|
||||
}
|
|
@ -24,6 +24,8 @@ import 'package:country_code/country_code.dart';
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
enum EntryDataType { basic, catalog, address, references }
|
||||
|
||||
class AvesEntry {
|
||||
String uri;
|
||||
String? _path, _directory, _filename, _extension;
|
||||
|
@ -235,6 +237,10 @@ class AvesEntry {
|
|||
|
||||
bool get canEdit => path != null;
|
||||
|
||||
bool get canEditDate => canEdit && canEditExif;
|
||||
|
||||
bool get canEditTags => canEdit && canEditXmp;
|
||||
|
||||
bool get canRotateAndFlip => canEdit && canEditExif;
|
||||
|
||||
// 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
|
||||
bool get canRemoveMetadata {
|
||||
switch (mimeType.toLowerCase()) {
|
||||
|
@ -394,11 +424,11 @@ class AvesEntry {
|
|||
|
||||
LatLng? get latLng => hasGps ? LatLng(_catalogMetadata!.latitude!, _catalogMetadata!.longitude!) : null;
|
||||
|
||||
List<String>? _xmpSubjects;
|
||||
Set<String>? _tags;
|
||||
|
||||
List<String> get xmpSubjects {
|
||||
_xmpSubjects ??= _catalogMetadata?.xmpSubjects?.split(';').where((tag) => tag.isNotEmpty).toList() ?? [];
|
||||
return _xmpSubjects!;
|
||||
Set<String> get tags {
|
||||
_tags ??= _catalogMetadata?.xmpSubjects?.split(';').where((tag) => tag.isNotEmpty).toSet() ?? {};
|
||||
return _tags!;
|
||||
}
|
||||
|
||||
String? _bestTitle;
|
||||
|
@ -423,7 +453,7 @@ class AvesEntry {
|
|||
catalogDateMillis = newMetadata?.dateMillis;
|
||||
_catalogMetadata = newMetadata;
|
||||
_bestTitle = null;
|
||||
_xmpSubjects = null;
|
||||
_tags = null;
|
||||
metadataChangeNotifier.notifyListeners();
|
||||
|
||||
_onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
|
||||
|
@ -434,7 +464,7 @@ class AvesEntry {
|
|||
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 (isSvg) {
|
||||
// vector image sizing is not essential, so we should not spend time for it during loading
|
||||
|
@ -593,58 +623,80 @@ class AvesEntry {
|
|||
metadataChangeNotifier.notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> refresh({required bool background, required bool persist, required bool force, required Locale geocoderLocale}) async {
|
||||
_catalogMetadata = null;
|
||||
_addressDetails = null;
|
||||
Future<void> refresh({
|
||||
required bool background,
|
||||
required bool persist,
|
||||
required Set<EntryDataType> dataTypes,
|
||||
required Locale geocoderLocale,
|
||||
}) async {
|
||||
// clear derived fields
|
||||
_bestDate = null;
|
||||
_bestTitle = null;
|
||||
_xmpSubjects = null;
|
||||
_tags = null;
|
||||
|
||||
if (persist) {
|
||||
await metadataDb.removeIds({contentId!}, metadataOnly: true);
|
||||
await metadataDb.removeIds({contentId!}, dataTypes: dataTypes);
|
||||
}
|
||||
|
||||
final updated = await mediaFileService.getEntry(uri, mimeType);
|
||||
if (updated != null) {
|
||||
await _applyNewFields(updated.toMap(), persist: persist);
|
||||
await catalog(background: background, persist: persist, force: force);
|
||||
await locate(background: background, force: force, geocoderLocale: geocoderLocale);
|
||||
final updatedEntry = await mediaFileService.getEntry(uri, mimeType);
|
||||
if (updatedEntry != null) {
|
||||
await _applyNewFields(updatedEntry.toMap(), persist: persist);
|
||||
}
|
||||
await catalog(background: background, force: dataTypes.contains(EntryDataType.catalog), persist: persist);
|
||||
await locate(background: background, force: dataTypes.contains(EntryDataType.address), geocoderLocale: geocoderLocale);
|
||||
}
|
||||
|
||||
Future<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);
|
||||
if (newFields.isEmpty) return false;
|
||||
if (newFields.isEmpty) return {};
|
||||
|
||||
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);
|
||||
if (newFields.isEmpty) return false;
|
||||
if (newFields.isEmpty) return {};
|
||||
|
||||
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) {
|
||||
final _title = bestTitle;
|
||||
if (_title == null) return false;
|
||||
if (_title == null) return {};
|
||||
final date = parseUnknownDateFormat(_title);
|
||||
if (date == null) {
|
||||
await reportService.recordError('failed to parse date from title=$_title', null);
|
||||
return false;
|
||||
return {};
|
||||
}
|
||||
modifier = DateModifier(DateEditAction.set, modifier.fields, dateTime: date);
|
||||
}
|
||||
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);
|
||||
return newFields.isNotEmpty;
|
||||
return newFields.isEmpty
|
||||
? {}
|
||||
: {
|
||||
EntryDataType.basic,
|
||||
EntryDataType.catalog,
|
||||
EntryDataType.address,
|
||||
};
|
||||
}
|
||||
|
||||
Future<bool> delete() {
|
||||
|
|
|
@ -9,7 +9,7 @@ import 'package:aves/model/entry_cache.dart';
|
|||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
extension ExtraAvesEntry on AvesEntry {
|
||||
extension ExtraAvesEntryImages on AvesEntry {
|
||||
bool isThumbnailReady({double extent = 0}) => _isReady(_getThumbnailProviderKey(extent));
|
||||
|
||||
ThumbnailProvider getThumbnail({double extent = 0}) {
|
||||
|
|
237
lib/model/entry_xmp_iptc.dart
Normal file
237
lib/model/entry_xmp_iptc.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -14,9 +14,9 @@ class TagFilter extends CollectionFilter {
|
|||
|
||||
TagFilter(this.tag) {
|
||||
if (tag.isEmpty) {
|
||||
_test = (entry) => entry.xmpSubjects.isEmpty;
|
||||
_test = (entry) => entry.tags.isEmpty;
|
||||
} else {
|
||||
_test = (entry) => entry.xmpSubjects.contains(tag);
|
||||
_test = (entry) => entry.tags.contains(tag);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -13,6 +13,8 @@ enum DateEditAction {
|
|||
}
|
||||
|
||||
enum MetadataType {
|
||||
// JPEG COM marker or GIF comment
|
||||
comment,
|
||||
// Exif: https://en.wikipedia.org/wiki/Exif
|
||||
exif,
|
||||
// ICC profile: https://en.wikipedia.org/wiki/ICC_profile
|
||||
|
@ -23,8 +25,6 @@ enum MetadataType {
|
|||
jfif,
|
||||
// JPEG APP14 / Adobe: https://www.exiftool.org/TagNames/JPEG.html#Adobe
|
||||
jpegAdobe,
|
||||
// JPEG COM marker
|
||||
jpegComment,
|
||||
// JPEG APP12 / Ducky: https://www.exiftool.org/TagNames/APP12.html#Ducky
|
||||
jpegDucky,
|
||||
// Photoshop IRB: https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/
|
||||
|
@ -42,6 +42,7 @@ class MetadataTypes {
|
|||
static const common = {
|
||||
MetadataType.exif,
|
||||
MetadataType.xmp,
|
||||
MetadataType.comment,
|
||||
MetadataType.iccProfile,
|
||||
MetadataType.iptc,
|
||||
MetadataType.photoshopIrb,
|
||||
|
@ -50,7 +51,6 @@ class MetadataTypes {
|
|||
static const jpeg = {
|
||||
MetadataType.jfif,
|
||||
MetadataType.jpegAdobe,
|
||||
MetadataType.jpegComment,
|
||||
MetadataType.jpegDucky,
|
||||
};
|
||||
}
|
||||
|
@ -59,6 +59,8 @@ extension ExtraMetadataType on MetadataType {
|
|||
// match `ExifInterface` directory names
|
||||
String getText() {
|
||||
switch (this) {
|
||||
case MetadataType.comment:
|
||||
return 'Comment';
|
||||
case MetadataType.exif:
|
||||
return 'Exif';
|
||||
case MetadataType.iccProfile:
|
||||
|
@ -69,8 +71,6 @@ extension ExtraMetadataType on MetadataType {
|
|||
return 'JFIF';
|
||||
case MetadataType.jpegAdobe:
|
||||
return 'Adobe JPEG';
|
||||
case MetadataType.jpegComment:
|
||||
return 'JpegComment';
|
||||
case MetadataType.jpegDucky:
|
||||
return 'Ducky';
|
||||
case MetadataType.photoshopIrb:
|
||||
|
|
|
@ -20,7 +20,7 @@ abstract class MetadataDb {
|
|||
|
||||
Future<void> reset();
|
||||
|
||||
Future<void> removeIds(Set<int> contentIds, {required bool metadataOnly});
|
||||
Future<void> removeIds(Set<int> contentIds, {Set<EntryDataType>? dataTypes});
|
||||
|
||||
// entries
|
||||
|
||||
|
@ -187,20 +187,28 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
}
|
||||
|
||||
@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;
|
||||
|
||||
final _dataTypes = dataTypes ?? EntryDataType.values.toSet();
|
||||
|
||||
final db = await _database;
|
||||
// using array in `whereArgs` and using it with `where contentId IN ?` is a pain, so we prefer `batch` instead
|
||||
final batch = db.batch();
|
||||
const where = 'contentId = ?';
|
||||
contentIds.forEach((id) {
|
||||
final whereArgs = [id];
|
||||
if (_dataTypes.contains(EntryDataType.basic)) {
|
||||
batch.delete(entryTable, where: where, whereArgs: whereArgs);
|
||||
}
|
||||
if (_dataTypes.contains(EntryDataType.catalog)) {
|
||||
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 (!metadataOnly) {
|
||||
}
|
||||
if (_dataTypes.contains(EntryDataType.references)) {
|
||||
batch.delete(favouriteTable, where: where, whereArgs: whereArgs);
|
||||
batch.delete(coverTable, where: where, whereArgs: whereArgs);
|
||||
batch.delete(videoPlaybackTable, where: where, whereArgs: whereArgs);
|
||||
|
|
|
@ -284,8 +284,8 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
|
||||
Future<Set<String>> refreshUris(Set<String> changedUris, {AnalysisController? analysisController});
|
||||
|
||||
Future<void> refreshEntry(AvesEntry entry) async {
|
||||
await entry.refresh(background: false, persist: true, force: true, geocoderLocale: settings.appliedLocale);
|
||||
Future<void> refreshEntry(AvesEntry entry, Set<EntryDataType> dataTypes) async {
|
||||
await entry.refresh(background: false, persist: true, dataTypes: dataTypes, geocoderLocale: settings.appliedLocale);
|
||||
updateDerivedFilters({entry});
|
||||
eventBus.fire(EntryRefreshedEvent({entry}));
|
||||
}
|
||||
|
|
|
@ -67,7 +67,7 @@ class MediaStoreSource extends CollectionSource {
|
|||
|
||||
// clean up 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`
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} check obsolete paths');
|
||||
|
|
|
@ -38,7 +38,7 @@ mixin TagMixin on SourceBase {
|
|||
var stopCheckCount = 0;
|
||||
final newMetadata = <CatalogMetadata>{};
|
||||
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) {
|
||||
newMetadata.add(entry.catalogMetadata!);
|
||||
if (newMetadata.length >= commitCountThreshold) {
|
||||
|
@ -63,7 +63,7 @@ mixin TagMixin on SourceBase {
|
|||
}
|
||||
|
||||
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)) {
|
||||
sortedTags = List.unmodifiable(updatedTags);
|
||||
invalidateTagFilterSummary();
|
||||
|
@ -85,7 +85,7 @@ mixin TagMixin on SourceBase {
|
|||
_filterEntryCountMap.clear();
|
||||
_filterRecentEntryMap.clear();
|
||||
} 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);
|
||||
}
|
||||
eventBus.fire(TagSummaryInvalidatedEvent(tags));
|
||||
|
|
6
lib/ref/iptc.dart
Normal file
6
lib/ref/iptc.dart
Normal file
|
@ -0,0 +1,6 @@
|
|||
class IPTC {
|
||||
static const int applicationRecord = 2;
|
||||
|
||||
// ApplicationRecord tags
|
||||
static const int keywordsTag = 25;
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/entry_xmp_iptc.dart';
|
||||
import 'package:aves/model/metadata/date_modifier.dart';
|
||||
import 'package:aves/model/metadata/enums.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
|
@ -13,6 +14,10 @@ abstract class MetadataEditService {
|
|||
|
||||
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);
|
||||
}
|
||||
|
||||
|
@ -85,6 +90,40 @@ class PlatformMetadataEditService implements MetadataEditService {
|
|||
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
|
||||
Future<Map<String, dynamic>> removeTypes(AvesEntry entry, Set<MetadataType> types) async {
|
||||
try {
|
||||
|
@ -116,6 +155,8 @@ class PlatformMetadataEditService implements MetadataEditService {
|
|||
|
||||
String _toPlatformMetadataType(MetadataType type) {
|
||||
switch (type) {
|
||||
case MetadataType.comment:
|
||||
return 'comment';
|
||||
case MetadataType.exif:
|
||||
return 'exif';
|
||||
case MetadataType.iccProfile:
|
||||
|
@ -126,8 +167,6 @@ class PlatformMetadataEditService implements MetadataEditService {
|
|||
return 'jfif';
|
||||
case MetadataType.jpegAdobe:
|
||||
return 'jpeg_adobe';
|
||||
case MetadataType.jpegComment:
|
||||
return 'jpeg_comment';
|
||||
case MetadataType.jpegDucky:
|
||||
return 'jpeg_ducky';
|
||||
case MetadataType.photoshopIrb:
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
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/overlay.dart';
|
||||
import 'package:aves/model/multipage.dart';
|
||||
|
@ -20,6 +21,10 @@ abstract class MetadataFetchService {
|
|||
|
||||
Future<PanoramaInfo?> getPanoramaInfo(AvesEntry entry);
|
||||
|
||||
Future<List<Map<String, dynamic>>?> getIptc(AvesEntry entry);
|
||||
|
||||
Future<AvesXmp?> getXmp(AvesEntry entry);
|
||||
|
||||
Future<bool> hasContentResolverProp(String prop);
|
||||
|
||||
Future<String?> getContentResolverProp(AvesEntry entry, String prop);
|
||||
|
@ -151,6 +156,39 @@ class PlatformMetadataFetchService implements MetadataFetchService {
|
|||
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 = {};
|
||||
|
||||
@override
|
||||
|
|
|
@ -46,6 +46,7 @@ class Durations {
|
|||
// info animations
|
||||
static const mapStyleSwitchAnimation = Duration(milliseconds: 300);
|
||||
static const xmpStructArrayCardTransition = Duration(milliseconds: 300);
|
||||
static const tagEditorTransition = Duration(milliseconds: 200);
|
||||
|
||||
// settings animations
|
||||
static const quickActionListAnimation = Duration(milliseconds: 200);
|
||||
|
|
|
@ -33,6 +33,7 @@ class AIcons {
|
|||
// actions
|
||||
static const IconData add = Icons.add_circle_outline;
|
||||
static const IconData addShortcut = Icons.add_to_home_screen_outlined;
|
||||
static const IconData addTag = MdiIcons.tagPlusOutline;
|
||||
static const IconData replay10 = Icons.replay_10_outlined;
|
||||
static const IconData skip10 = Icons.forward_10_outlined;
|
||||
static const IconData captureFrame = Icons.screenshot_outlined;
|
||||
|
@ -66,6 +67,7 @@ class AIcons {
|
|||
static const IconData print = Icons.print_outlined;
|
||||
static const IconData refresh = Icons.refresh_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 rotateRight = Icons.rotate_right_outlined;
|
||||
static const IconData rotateScreen = Icons.screen_rotation_outlined;
|
||||
|
|
|
@ -269,6 +269,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
_buildRotateAndFlipMenuItems(context, canApply: canApply),
|
||||
...[
|
||||
EntrySetAction.editDate,
|
||||
EntrySetAction.editTags,
|
||||
EntrySetAction.removeMetadata,
|
||||
].map((action) => _toMenuItem(action, enabled: canApply(action))),
|
||||
],
|
||||
|
@ -439,6 +440,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
case EntrySetAction.rotateCW:
|
||||
case EntrySetAction.flip:
|
||||
case EntrySetAction.editDate:
|
||||
case EntrySetAction.editTags:
|
||||
case EntrySetAction.removeMetadata:
|
||||
_actionDelegate.onActionSelected(context, action);
|
||||
break;
|
||||
|
|
|
@ -5,6 +5,7 @@ import 'package:aves/app_mode.dart';
|
|||
import 'package:aves/model/actions/entry_set_actions.dart';
|
||||
import 'package:aves/model/actions/move_type.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/filters.dart';
|
||||
import 'package:aves/model/highlight.dart';
|
||||
|
@ -81,6 +82,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
|||
case EntrySetAction.rotateCW:
|
||||
case EntrySetAction.flip:
|
||||
case EntrySetAction.editDate:
|
||||
case EntrySetAction.editTags:
|
||||
case EntrySetAction.removeMetadata:
|
||||
return appMode == AppMode.main && isSelecting;
|
||||
}
|
||||
|
@ -122,6 +124,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
|||
case EntrySetAction.rotateCW:
|
||||
case EntrySetAction.flip:
|
||||
case EntrySetAction.editDate:
|
||||
case EntrySetAction.editTags:
|
||||
case EntrySetAction.removeMetadata:
|
||||
return hasSelection;
|
||||
}
|
||||
|
@ -181,6 +184,9 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
|||
case EntrySetAction.editDate:
|
||||
_editDate(context);
|
||||
break;
|
||||
case EntrySetAction.editTags:
|
||||
_editTags(context);
|
||||
break;
|
||||
case EntrySetAction.removeMetadata:
|
||||
_removeMetadata(context);
|
||||
break;
|
||||
|
@ -399,7 +405,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
|||
BuildContext context,
|
||||
Selection<AvesEntry> selection,
|
||||
Set<AvesEntry> todoItems,
|
||||
Future<bool> Function(AvesEntry entry) op,
|
||||
Future<Set<EntryDataType>> Function(AvesEntry entry) op,
|
||||
) async {
|
||||
final selectionDirs = todoItems.map((e) => e.directory).whereNotNull().toSet();
|
||||
final todoCount = todoItems.length;
|
||||
|
@ -411,8 +417,8 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
|||
showOpReport<ImageOpEvent>(
|
||||
context: context,
|
||||
opStream: Stream.fromIterable(todoItems).asyncMap((entry) async {
|
||||
final success = await op(entry);
|
||||
return ImageOpEvent(success: success, uri: entry.uri);
|
||||
final dataTypes = await op(entry);
|
||||
return ImageOpEvent(success: dataTypes.isNotEmpty, uri: entry.uri);
|
||||
}).asBroadcastStream(),
|
||||
itemCount: todoCount,
|
||||
onDone: (processed) async {
|
||||
|
@ -470,6 +476,8 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
|||
);
|
||||
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;
|
||||
}
|
||||
|
||||
|
@ -497,7 +505,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
|||
final selection = context.read<Selection<AvesEntry>>();
|
||||
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;
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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 {
|
||||
final selection = context.read<Selection<AvesEntry>>();
|
||||
final selectedItems = _getExpandedSelectedItems(selection);
|
||||
|
|
|
@ -4,8 +4,9 @@ import 'package:aves/model/metadata/enums.dart';
|
|||
import 'package:aves/ref/mime_types.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/edit_entry_date_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/remove_entry_metadata_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/entry_editors/edit_entry_date_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';
|
||||
|
||||
mixin EntryEditorMixin {
|
||||
|
@ -21,6 +22,23 @@ mixin EntryEditorMixin {
|
|||
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 {
|
||||
if (entries.isEmpty) return null;
|
||||
|
||||
|
|
|
@ -40,7 +40,7 @@ class AvesFilterChip extends StatefulWidget {
|
|||
final bool removable, showGenericIcon, useFilterColor;
|
||||
final AvesFilterDecoration? decoration;
|
||||
final String? banner;
|
||||
final Widget? details;
|
||||
final Widget? leadingOverride, details;
|
||||
final double padding, maxWidth;
|
||||
final HeroType heroType;
|
||||
final FilterCallback? onTap;
|
||||
|
@ -64,6 +64,7 @@ class AvesFilterChip extends StatefulWidget {
|
|||
this.useFilterColor = true,
|
||||
this.decoration,
|
||||
this.banner,
|
||||
this.leadingOverride,
|
||||
this.details,
|
||||
this.padding = 6.0,
|
||||
this.maxWidth = defaultMaxChipWidth,
|
||||
|
@ -162,7 +163,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
|||
final chipBackground = Theme.of(context).scaffoldBackgroundColor;
|
||||
final textScaleFactor = MediaQuery.textScaleFactorOf(context);
|
||||
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 decoration = widget.decoration;
|
||||
|
|
|
@ -9,7 +9,7 @@ import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'aves_dialog.dart';
|
||||
import '../aves_dialog.dart';
|
||||
|
||||
class EditEntryDateDialog extends StatefulWidget {
|
||||
final AvesEntry entry;
|
278
lib/widgets/dialogs/entry_editors/edit_entry_tags_dialog.dart
Normal file
278
lib/widgets/dialogs/entry_editors/edit_entry_tags_dialog.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -9,7 +9,7 @@ import 'package:collection/collection.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'aves_dialog.dart';
|
||||
import '../aves_dialog.dart';
|
||||
|
||||
class RemoveEntryMetadataDialog extends StatefulWidget {
|
||||
final bool showJpegTypes;
|
|
@ -5,7 +5,7 @@ import 'package:aves/services/common/services.dart';
|
|||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'aves_dialog.dart';
|
||||
import '../aves_dialog.dart';
|
||||
|
||||
class RenameEntryDialog extends StatefulWidget {
|
||||
final AvesEntry entry;
|
|
@ -8,7 +8,7 @@ import 'package:collection/collection.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'aves_dialog.dart';
|
||||
import '../aves_dialog.dart';
|
||||
|
||||
class CreateAlbumDialog extends StatefulWidget {
|
||||
const CreateAlbumDialog({Key? key}) : super(key: key);
|
|
@ -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/identity/empty.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/common/action_delegates/album_set.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart';
|
||||
|
|
|
@ -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/dialogs/aves_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/rename_album_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/filter_editors/create_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:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
|
|
@ -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/dialogs/aves_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/search/search_delegate.dart';
|
||||
import 'package:aves/widgets/stats/stats_page.dart';
|
||||
|
|
|
@ -136,7 +136,7 @@ class _HomePageState extends State<HomePage> {
|
|||
final entry = await mediaFileService.getEntry(uri, mimeType);
|
||||
if (entry != null) {
|
||||
// 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;
|
||||
}
|
||||
|
|
|
@ -9,16 +9,20 @@ class ExpandableFilterRow extends StatelessWidget {
|
|||
final String? title;
|
||||
final Iterable<CollectionFilter> filters;
|
||||
final ValueNotifier<String?> expandedNotifier;
|
||||
final bool showGenericIcon;
|
||||
final HeroType Function(CollectionFilter filter)? heroTypeBuilder;
|
||||
final FilterCallback onTap;
|
||||
final OffsetFilterCallback? onLongPress;
|
||||
|
||||
const ExpandableFilterRow({
|
||||
Key? key,
|
||||
this.title,
|
||||
required this.filters,
|
||||
required this.expandedNotifier,
|
||||
this.showGenericIcon = true,
|
||||
this.heroTypeBuilder,
|
||||
required this.onTap,
|
||||
required this.onLongPress,
|
||||
}) : super(key: key);
|
||||
|
||||
static const double horizontalPadding = 8;
|
||||
|
@ -109,8 +113,10 @@ class ExpandableFilterRow extends StatelessWidget {
|
|||
// key `album-{path}` is expected by test driver
|
||||
key: Key(filter.key),
|
||||
filter: filter,
|
||||
showGenericIcon: showGenericIcon,
|
||||
heroType: heroTypeBuilder?.call(filter) ?? HeroType.onTap,
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,10 +27,10 @@ import 'package:provider/provider.dart';
|
|||
class CollectionSearchDelegate {
|
||||
final CollectionSource source;
|
||||
final CollectionLens? parentCollection;
|
||||
final ValueNotifier<String?> expandedSectionNotifier = ValueNotifier(null);
|
||||
final ValueNotifier<String?> _expandedSectionNotifier = ValueNotifier(null);
|
||||
final bool canPop;
|
||||
|
||||
static const searchHistoryCount = 10;
|
||||
static const int searchHistoryCount = 10;
|
||||
static final typeFilters = [
|
||||
FavouriteFilter.instance,
|
||||
MimeFilter.image,
|
||||
|
@ -90,7 +90,7 @@ class CollectionSearchDelegate {
|
|||
bool containQuery(String s) => s.toUpperCase().contains(upQuery);
|
||||
return SafeArea(
|
||||
child: ValueListenableBuilder<String?>(
|
||||
valueListenable: expandedSectionNotifier,
|
||||
valueListenable: _expandedSectionNotifier,
|
||||
builder: (context, expandedSection, child) {
|
||||
final queryFilter = _buildQueryFilter(false);
|
||||
return Selector<Settings, Set<CollectionFilter>>(
|
||||
|
@ -195,9 +195,10 @@ class CollectionSearchDelegate {
|
|||
return ExpandableFilterRow(
|
||||
title: title,
|
||||
filters: filters,
|
||||
expandedNotifier: expandedSectionNotifier,
|
||||
expandedNotifier: _expandedSectionNotifier,
|
||||
heroTypeBuilder: heroTypeBuilder,
|
||||
onTap: (filter) => _select(context, filter is QueryFilter ? QueryFilter(filter.query) : filter),
|
||||
onLongPress: AvesFilterChip.showDefaultLongPressMenu,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -56,7 +56,7 @@ class StatsPage extends StatelessWidget {
|
|||
entryCountPerPlace[place] = (entryCountPerPlace[place] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
entry.xmpSubjects.forEach((tag) {
|
||||
entry.tags.forEach((tag) {
|
||||
entryCountPerTag[tag] = (entryCountPerTag[tag] ?? 0) + 1;
|
||||
});
|
||||
});
|
||||
|
|
|
@ -112,9 +112,10 @@ class ViewerDebugPage extends StatelessWidget {
|
|||
'isGeotiff': '${entry.isGeotiff}',
|
||||
'is360': '${entry.is360}',
|
||||
'canEdit': '${entry.canEdit}',
|
||||
'canEditExif': '${entry.canEditExif}',
|
||||
'canEditDate': '${entry.canEditDate}',
|
||||
'canEditTags': '${entry.canEditTags}',
|
||||
'canRotateAndFlip': '${entry.canRotateAndFlip}',
|
||||
'xmpSubjects': '${entry.xmpSubjects}',
|
||||
'tags': '${entry.tags}',
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
|
|
|
@ -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/dialogs/add_shortcut_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/rename_entry_dialog.dart';
|
||||
import 'package:aves/widgets/filter_grids/album_pick.dart';
|
||||
import 'package:aves/widgets/viewer/debug/debug_page.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 {
|
||||
if (!await checkStoragePermission(context, {entry})) return;
|
||||
|
||||
final success = await entry.flip(persist: _isMainMode(context));
|
||||
if (!success) showFeedback(context, context.l10n.genericFailureFeedback);
|
||||
final dataTypes = await entry.flip(persist: _isMainMode(context));
|
||||
if (dataTypes.isEmpty) showFeedback(context, context.l10n.genericFailureFeedback);
|
||||
}
|
||||
|
||||
Future<void> _rotate(BuildContext context, AvesEntry entry, {required bool clockwise}) async {
|
||||
if (!await checkStoragePermission(context, {entry})) return;
|
||||
|
||||
final success = await entry.rotate(clockwise: clockwise, persist: _isMainMode(context));
|
||||
if (!success) showFeedback(context, context.l10n.genericFailureFeedback);
|
||||
final dataTypes = await entry.rotate(clockwise: clockwise, persist: _isMainMode(context));
|
||||
if (dataTypes.isEmpty) showFeedback(context, context.l10n.genericFailureFeedback);
|
||||
}
|
||||
|
||||
Future<void> _rotateScreen(BuildContext context) async {
|
||||
|
|
|
@ -169,7 +169,7 @@ class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
|
|||
// make sure to locate the entry,
|
||||
// so that we can display the address instead of coordinates
|
||||
// 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);
|
||||
} else {
|
||||
Navigator.pop(context);
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
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/favourites.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/services/common/services.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/file_utils.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
|
||||
import 'package:aves/widgets/viewer/info/common.dart';
|
||||
import 'package:aves/widgets/viewer/info/entry_info_action_delegate.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
@ -22,12 +25,16 @@ import 'package:provider/provider.dart';
|
|||
class BasicSection extends StatelessWidget {
|
||||
final AvesEntry entry;
|
||||
final CollectionLens? collection;
|
||||
final EntryInfoActionDelegate actionDelegate;
|
||||
final ValueNotifier<bool> isEditingTagNotifier;
|
||||
final FilterCallback onFilter;
|
||||
|
||||
const BasicSection({
|
||||
Key? key,
|
||||
required this.entry,
|
||||
this.collection,
|
||||
required this.actionDelegate,
|
||||
required this.isEditingTagNotifier,
|
||||
required this.onFilter,
|
||||
}) : super(key: key);
|
||||
|
||||
|
@ -80,7 +87,7 @@ class BasicSection extends StatelessWidget {
|
|||
}
|
||||
|
||||
Widget _buildChips(BuildContext context) {
|
||||
final tags = entry.xmpSubjects..sort(compareAsciiUpperCase);
|
||||
final tags = entry.tags.toList()..sort(compareAsciiUpperCase);
|
||||
final album = entry.directory;
|
||||
final filters = {
|
||||
MimeFilter(entry.mimeType),
|
||||
|
@ -101,24 +108,65 @@ class BasicSection extends StatelessWidget {
|
|||
...filters,
|
||||
if (entry.isFavourite) FavouriteFilter.instance,
|
||||
]..sort();
|
||||
if (effectiveFilters.isEmpty) return const SizedBox.shrink();
|
||||
return Padding(
|
||||
|
||||
final children = <Widget>[
|
||||
...effectiveFilters.map((filter) => AvesFilterChip(
|
||||
filter: filter,
|
||||
onTap: onFilter,
|
||||
)),
|
||||
if (actionDelegate.canApply(EntryInfoAction.editTags)) _buildEditTagButton(context),
|
||||
];
|
||||
|
||||
return children.isEmpty
|
||||
? const SizedBox()
|
||||
: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AvesFilterChip.outlineWidth / 2) + const EdgeInsets.only(top: 8),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: effectiveFilters
|
||||
.map((filter) => AvesFilterChip(
|
||||
filter: filter,
|
||||
onTap: onFilter,
|
||||
))
|
||||
.toList(),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, String> _buildVideoRows(BuildContext context) {
|
||||
return {
|
||||
context.l10n.viewerInfoLabelDuration: entry.durationText,
|
||||
|
|
|
@ -1,8 +1,13 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:aves/app_mode.dart';
|
||||
import 'package:aves/model/actions/entry_info_actions.dart';
|
||||
import 'package:aves/model/actions/events.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/entry_xmp_iptc.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/entry_editor.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
|
||||
|
@ -14,12 +19,17 @@ import 'package:provider/provider.dart';
|
|||
class EntryInfoActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwareMixin {
|
||||
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) {
|
||||
switch (action) {
|
||||
// general
|
||||
case EntryInfoAction.editDate:
|
||||
case EntryInfoAction.editTags:
|
||||
case EntryInfoAction.removeMetadata:
|
||||
return true;
|
||||
// motion photo
|
||||
|
@ -32,7 +42,9 @@ class EntryInfoActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAw
|
|||
switch (action) {
|
||||
// general
|
||||
case EntryInfoAction.editDate:
|
||||
return entry.canEditExif;
|
||||
return entry.canEditDate;
|
||||
case EntryInfoAction.editTags:
|
||||
return entry.canEditTags;
|
||||
case EntryInfoAction.removeMetadata:
|
||||
return entry.canRemoveMetadata;
|
||||
// motion photo
|
||||
|
@ -42,11 +54,15 @@ class EntryInfoActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAw
|
|||
}
|
||||
|
||||
void onActionSelected(BuildContext context, EntryInfoAction action) async {
|
||||
_eventStreamController.add(ActionStartedEvent(action));
|
||||
switch (action) {
|
||||
// general
|
||||
case EntryInfoAction.editDate:
|
||||
await _editDate(context);
|
||||
break;
|
||||
case EntryInfoAction.editTags:
|
||||
await _editTags(context);
|
||||
break;
|
||||
case EntryInfoAction.removeMetadata:
|
||||
await _removeMetadata(context);
|
||||
break;
|
||||
|
@ -55,27 +71,38 @@ class EntryInfoActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAw
|
|||
OpenEmbeddedDataNotification.motionPhotoVideo().dispatch(context);
|
||||
break;
|
||||
}
|
||||
_eventStreamController.add(ActionEndedEvent(action));
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// check before applying, because it relies on provider
|
||||
// but the widget tree may be disposed if the user navigated away
|
||||
final isMainMode = _isMainMode(context);
|
||||
|
||||
final l10n = context.l10n;
|
||||
final source = context.read<CollectionSource?>();
|
||||
source?.pauseMonitoring();
|
||||
final success = await apply();
|
||||
|
||||
final dataTypes = await apply();
|
||||
final success = dataTypes.isNotEmpty;
|
||||
try {
|
||||
if (success) {
|
||||
if (_isMainMode(context) && source != null) {
|
||||
await source.refreshEntry(entry);
|
||||
if (isMainMode && source != null) {
|
||||
await source.refreshEntry(entry, dataTypes);
|
||||
} else {
|
||||
await entry.refresh(background: false, persist: false, force: true, geocoderLocale: settings.appliedLocale);
|
||||
await entry.refresh(background: false, persist: false, dataTypes: dataTypes, geocoderLocale: settings.appliedLocale);
|
||||
}
|
||||
showFeedback(context, l10n.genericSuccessFeedback);
|
||||
} else {
|
||||
showFeedback(context, l10n.genericFailureFeedback);
|
||||
}
|
||||
} catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
source?.resumeMonitoring();
|
||||
}
|
||||
|
||||
|
@ -86,6 +113,17 @@ class EntryInfoActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAw
|
|||
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 {
|
||||
final types = await selectMetadataToRemove(context, {entry});
|
||||
if (types == null) return;
|
||||
|
|
|
@ -13,19 +13,20 @@ import 'package:flutter/scheduler.dart';
|
|||
|
||||
class InfoAppBar extends StatelessWidget {
|
||||
final AvesEntry entry;
|
||||
final EntryInfoActionDelegate actionDelegate;
|
||||
final ValueNotifier<Map<String, MetadataDirectory>> metadataNotifier;
|
||||
final VoidCallback onBackPressed;
|
||||
|
||||
const InfoAppBar({
|
||||
Key? key,
|
||||
required this.entry,
|
||||
required this.actionDelegate,
|
||||
required this.metadataNotifier,
|
||||
required this.onBackPressed,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final actionDelegate = EntryInfoActionDelegate(entry);
|
||||
final menuActions = EntryInfoActions.all.where(actionDelegate.isVisible);
|
||||
|
||||
return SliverAppBar(
|
||||
|
|
|
@ -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/filters/filters.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/viewer/embedded/embedded_data_opener.dart';
|
||||
import 'package:aves/widgets/viewer/info/basic_section.dart';
|
||||
import 'package:aves/widgets/viewer/info/entry_info_action_delegate.dart';
|
||||
import 'package:aves/widgets/viewer/info/info_app_bar.dart';
|
||||
import 'package:aves/widgets/viewer/info/location_section.dart';
|
||||
import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart';
|
||||
|
@ -142,19 +147,57 @@ class _InfoPageContent extends StatefulWidget {
|
|||
}
|
||||
|
||||
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<bool> _isEditingTagNotifier = ValueNotifier(false);
|
||||
|
||||
static const horizontalPadding = EdgeInsets.symmetric(horizontal: 8);
|
||||
|
||||
CollectionLens? get collection => widget.collection;
|
||||
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
final basicSection = BasicSection(
|
||||
entry: entry,
|
||||
collection: collection,
|
||||
actionDelegate: _actionDelegate,
|
||||
isEditingTagNotifier: _isEditingTagNotifier,
|
||||
onFilter: _goToCollection,
|
||||
);
|
||||
final locationAtTop = widget.split && entry.hasGps;
|
||||
|
@ -194,6 +237,7 @@ class _InfoPageContentState extends State<_InfoPageContent> {
|
|||
slivers: [
|
||||
InfoAppBar(
|
||||
entry: entry,
|
||||
actionDelegate: _actionDelegate,
|
||||
metadataNotifier: _metadataNotifier,
|
||||
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) {
|
||||
if (collection == null) return;
|
||||
FilterSelectedNotification(filter).dispatch(context);
|
||||
|
|
|
@ -13,7 +13,7 @@ class FakeMetadataDb extends Fake implements MetadataDb {
|
|||
Future<void> init() => SynchronousFuture(null);
|
||||
|
||||
@override
|
||||
Future<void> removeIds(Set<int> contentIds, {required bool metadataOnly}) => SynchronousFuture(null);
|
||||
Future<void> removeIds(Set<int> contentIds, {Set<EntryDataType>? dataTypes}) => SynchronousFuture(null);
|
||||
|
||||
// entries
|
||||
|
||||
|
|
|
@ -125,10 +125,10 @@ void main() {
|
|||
longitude: australiaLatLng.longitude,
|
||||
),
|
||||
);
|
||||
expect(image1.xmpSubjects, []);
|
||||
expect(image1.tags, <String>{});
|
||||
|
||||
final source = await _initSource();
|
||||
expect(image1.xmpSubjects, [aTag]);
|
||||
expect(image1.tags, {aTag});
|
||||
expect(image1.addressDetails, australiaAddress.copyWith(contentId: image1.contentId));
|
||||
|
||||
expect(source.visibleEntries.length, 0);
|
||||
|
|
|
@ -1 +1,17 @@
|
|||
{}
|
||||
{
|
||||
"ko": [
|
||||
"resetButtonTooltip",
|
||||
"entryInfoActionEditTags",
|
||||
"tagEditorPageTitle",
|
||||
"tagEditorPageNewTagFieldLabel",
|
||||
"tagEditorPageAddTagTooltip"
|
||||
],
|
||||
|
||||
"ru": [
|
||||
"resetButtonTooltip",
|
||||
"entryInfoActionEditTags",
|
||||
"tagEditorPageTitle",
|
||||
"tagEditorPageNewTagFieldLabel",
|
||||
"tagEditorPageAddTagTooltip"
|
||||
]
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue