#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
|
// forked, built by JitPack, cf https://jitpack.io/p/deckerst/Android-TiffBitmapFactory
|
||||||
implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a'
|
implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a'
|
||||||
// forked, built by JitPack, cf https://jitpack.io/p/deckerst/pixymeta-android
|
// forked, built by JitPack, cf https://jitpack.io/p/deckerst/pixymeta-android
|
||||||
implementation 'com.github.deckerst:pixymeta-android:0bea51ead2'
|
implementation 'com.github.deckerst:pixymeta-android:a86b1b8e4c'
|
||||||
implementation 'com.github.bumptech.glide:glide:4.12.0'
|
implementation 'com.github.bumptech.glide:glide:4.12.0'
|
||||||
|
|
||||||
kapt 'androidx.annotation:annotation:1.3.0'
|
kapt 'androidx.annotation:annotation:1.3.0'
|
||||||
|
|
|
@ -20,6 +20,8 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
|
||||||
"rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) }
|
"rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) }
|
||||||
"flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) }
|
"flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) }
|
||||||
"editDate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::editDate) }
|
"editDate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::editDate) }
|
||||||
|
"setIptc" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::setIptc) }
|
||||||
|
"setXmp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::setXmp) }
|
||||||
"removeTypes" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::removeTypes) }
|
"removeTypes" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::removeTypes) }
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
|
@ -97,6 +99,64 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun setIptc(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
val iptc = call.argument<List<FieldMap>>("iptc")
|
||||||
|
val entryMap = call.argument<FieldMap>("entry")
|
||||||
|
val postEditScan = call.argument<Boolean>("postEditScan")
|
||||||
|
if (entryMap == null || postEditScan == null) {
|
||||||
|
result.error("setIptc-args", "failed because of missing arguments", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) }
|
||||||
|
val path = entryMap["path"] as String?
|
||||||
|
val mimeType = entryMap["mimeType"] as String?
|
||||||
|
if (uri == null || path == null || mimeType == null) {
|
||||||
|
result.error("setIptc-args", "failed because entry fields are missing", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val provider = getProvider(uri)
|
||||||
|
if (provider == null) {
|
||||||
|
result.error("setIptc-provider", "failed to find provider for uri=$uri", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
provider.setIptc(activity, path, uri, mimeType, postEditScan, iptc = iptc, callback = object : ImageOpCallback {
|
||||||
|
override fun onSuccess(fields: FieldMap) = result.success(fields)
|
||||||
|
override fun onFailure(throwable: Throwable) = result.error("setIptc-failure", "failed to set IPTC for mimeType=$mimeType uri=$uri", throwable.message)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setXmp(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
val xmp = call.argument<String>("xmp")
|
||||||
|
val extendedXmp = call.argument<String>("extendedXmp")
|
||||||
|
val entryMap = call.argument<FieldMap>("entry")
|
||||||
|
if (entryMap == null) {
|
||||||
|
result.error("setXmp-args", "failed because of missing arguments", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) }
|
||||||
|
val path = entryMap["path"] as String?
|
||||||
|
val mimeType = entryMap["mimeType"] as String?
|
||||||
|
if (uri == null || path == null || mimeType == null) {
|
||||||
|
result.error("setXmp-args", "failed because entry fields are missing", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val provider = getProvider(uri)
|
||||||
|
if (provider == null) {
|
||||||
|
result.error("setXmp-provider", "failed to find provider for uri=$uri", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
provider.setXmp(activity, path, uri, mimeType, coreXmp = xmp, extendedXmp = extendedXmp, callback = object : ImageOpCallback {
|
||||||
|
override fun onSuccess(fields: FieldMap) = result.success(fields)
|
||||||
|
override fun onFailure(throwable: Throwable) = result.error("setXmp-failure", "failed to set XMP for mimeType=$mimeType uri=$uri", throwable.message)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
private fun removeTypes(call: MethodCall, result: MethodChannel.Result) {
|
private fun removeTypes(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val types = call.argument<List<String>>("types")
|
val types = call.argument<List<String>>("types")
|
||||||
val entryMap = call.argument<FieldMap>("entry")
|
val entryMap = call.argument<FieldMap>("entry")
|
||||||
|
|
|
@ -10,6 +10,8 @@ import android.provider.MediaStore
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.exifinterface.media.ExifInterface
|
import androidx.exifinterface.media.ExifInterface
|
||||||
import com.adobe.internal.xmp.XMPException
|
import com.adobe.internal.xmp.XMPException
|
||||||
|
import com.adobe.internal.xmp.XMPMetaFactory
|
||||||
|
import com.adobe.internal.xmp.options.SerializeOptions
|
||||||
import com.adobe.internal.xmp.properties.XMPPropertyInfo
|
import com.adobe.internal.xmp.properties.XMPPropertyInfo
|
||||||
import com.drew.imaging.ImageMetadataReader
|
import com.drew.imaging.ImageMetadataReader
|
||||||
import com.drew.lang.KeyValuePair
|
import com.drew.lang.KeyValuePair
|
||||||
|
@ -84,6 +86,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
"getOverlayMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getOverlayMetadata) }
|
"getOverlayMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getOverlayMetadata) }
|
||||||
"getMultiPageInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMultiPageInfo) }
|
"getMultiPageInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMultiPageInfo) }
|
||||||
"getPanoramaInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPanoramaInfo) }
|
"getPanoramaInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPanoramaInfo) }
|
||||||
|
"getIptc" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getIptc) }
|
||||||
|
"getXmp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getXmp) }
|
||||||
"hasContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::hasContentResolverProp) }
|
"hasContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::hasContentResolverProp) }
|
||||||
"getContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverProp) }
|
"getContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverProp) }
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
|
@ -734,6 +738,59 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
result.error("getPanoramaInfo-empty", "failed to read XMP for mimeType=$mimeType uri=$uri", null)
|
result.error("getPanoramaInfo-empty", "failed to read XMP for mimeType=$mimeType uri=$uri", null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getIptc(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
val mimeType = call.argument<String>("mimeType")
|
||||||
|
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||||
|
if (mimeType == null || uri == null) {
|
||||||
|
result.error("getIptc-args", "failed because of missing arguments", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MimeTypes.canReadWithPixyMeta(mimeType)) {
|
||||||
|
try {
|
||||||
|
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||||
|
val iptcDataList = PixyMetaHelper.getIptc(input)
|
||||||
|
result.success(iptcDataList)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
result.error("getIptc-exception", "failed to read IPTC for mimeType=$mimeType uri=$uri", e.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getXmp(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
val mimeType = call.argument<String>("mimeType")
|
||||||
|
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||||
|
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
|
if (mimeType == null || uri == null) {
|
||||||
|
result.error("getXmp-args", "failed because of missing arguments", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canReadWithMetadataExtractor(mimeType)) {
|
||||||
|
try {
|
||||||
|
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||||
|
val metadata = ImageMetadataReader.readMetadata(input)
|
||||||
|
val xmpStrings = metadata.getDirectoriesOfType(XmpDirectory::class.java).map { XMPMetaFactory.serializeToString(it.xmpMeta, xmpSerializeOptions) }.filterNotNull()
|
||||||
|
result.success(xmpStrings.toMutableList())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
result.error("getXmp-exception", "failed to read XMP for mimeType=$mimeType uri=$uri", e.message)
|
||||||
|
return
|
||||||
|
} catch (e: NoClassDefFoundError) {
|
||||||
|
result.error("getXmp-error", "failed to read XMP for mimeType=$mimeType uri=$uri", e.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
|
||||||
private fun hasContentResolverProp(call: MethodCall, result: MethodChannel.Result) {
|
private fun hasContentResolverProp(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val prop = call.argument<String>("prop")
|
val prop = call.argument<String>("prop")
|
||||||
if (prop == null) {
|
if (prop == null) {
|
||||||
|
@ -829,6 +886,11 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
"XMP",
|
"XMP",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private val xmpSerializeOptions = SerializeOptions().apply {
|
||||||
|
omitPacketWrapper = true // e.g. <?xpacket begin="..." id="W5M0MpCehiHzreSzNTczkc9d"?>...<?xpacket end="r"?>
|
||||||
|
omitXmpMetaElement = false // e.g. <x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core Test.SNAPSHOT">...</x:xmpmeta>
|
||||||
|
}
|
||||||
|
|
||||||
// catalog metadata
|
// catalog metadata
|
||||||
private const val KEY_MIME_TYPE = "mimeType"
|
private const val KEY_MIME_TYPE = "mimeType"
|
||||||
private const val KEY_DATE_MILLIS = "dateMillis"
|
private const val KEY_DATE_MILLIS = "dateMillis"
|
||||||
|
|
|
@ -32,12 +32,12 @@ object Metadata {
|
||||||
const val DIR_PNG_TEXTUAL_DATA = "PNG Textual Data" // custom
|
const val DIR_PNG_TEXTUAL_DATA = "PNG Textual Data" // custom
|
||||||
|
|
||||||
// types of metadata
|
// types of metadata
|
||||||
|
const val TYPE_COMMENT = "comment"
|
||||||
const val TYPE_EXIF = "exif"
|
const val TYPE_EXIF = "exif"
|
||||||
const val TYPE_ICC_PROFILE = "icc_profile"
|
const val TYPE_ICC_PROFILE = "icc_profile"
|
||||||
const val TYPE_IPTC = "iptc"
|
const val TYPE_IPTC = "iptc"
|
||||||
const val TYPE_JFIF = "jfif"
|
const val TYPE_JFIF = "jfif"
|
||||||
const val TYPE_JPEG_ADOBE = "jpeg_adobe"
|
const val TYPE_JPEG_ADOBE = "jpeg_adobe"
|
||||||
const val TYPE_JPEG_COMMENT = "jpeg_comment"
|
|
||||||
const val TYPE_JPEG_DUCKY = "jpeg_ducky"
|
const val TYPE_JPEG_DUCKY = "jpeg_ducky"
|
||||||
const val TYPE_PHOTOSHOP_IRB = "photoshop_irb"
|
const val TYPE_PHOTOSHOP_IRB = "photoshop_irb"
|
||||||
const val TYPE_XMP = "xmp"
|
const val TYPE_XMP = "xmp"
|
||||||
|
|
|
@ -1,17 +1,21 @@
|
||||||
package deckers.thibault.aves.metadata
|
package deckers.thibault.aves.metadata
|
||||||
|
|
||||||
|
import deckers.thibault.aves.metadata.Metadata.TYPE_COMMENT
|
||||||
import deckers.thibault.aves.metadata.Metadata.TYPE_EXIF
|
import deckers.thibault.aves.metadata.Metadata.TYPE_EXIF
|
||||||
import deckers.thibault.aves.metadata.Metadata.TYPE_ICC_PROFILE
|
import deckers.thibault.aves.metadata.Metadata.TYPE_ICC_PROFILE
|
||||||
import deckers.thibault.aves.metadata.Metadata.TYPE_IPTC
|
import deckers.thibault.aves.metadata.Metadata.TYPE_IPTC
|
||||||
import deckers.thibault.aves.metadata.Metadata.TYPE_JFIF
|
import deckers.thibault.aves.metadata.Metadata.TYPE_JFIF
|
||||||
import deckers.thibault.aves.metadata.Metadata.TYPE_JPEG_ADOBE
|
import deckers.thibault.aves.metadata.Metadata.TYPE_JPEG_ADOBE
|
||||||
import deckers.thibault.aves.metadata.Metadata.TYPE_JPEG_COMMENT
|
|
||||||
import deckers.thibault.aves.metadata.Metadata.TYPE_JPEG_DUCKY
|
import deckers.thibault.aves.metadata.Metadata.TYPE_JPEG_DUCKY
|
||||||
import deckers.thibault.aves.metadata.Metadata.TYPE_PHOTOSHOP_IRB
|
import deckers.thibault.aves.metadata.Metadata.TYPE_PHOTOSHOP_IRB
|
||||||
import deckers.thibault.aves.metadata.Metadata.TYPE_XMP
|
import deckers.thibault.aves.metadata.Metadata.TYPE_XMP
|
||||||
|
import deckers.thibault.aves.model.FieldMap
|
||||||
import pixy.meta.meta.Metadata
|
import pixy.meta.meta.Metadata
|
||||||
import pixy.meta.meta.MetadataEntry
|
import pixy.meta.meta.MetadataEntry
|
||||||
import pixy.meta.meta.MetadataType
|
import pixy.meta.meta.MetadataType
|
||||||
|
import pixy.meta.meta.iptc.IPTC
|
||||||
|
import pixy.meta.meta.iptc.IPTCDataSet
|
||||||
|
import pixy.meta.meta.iptc.IPTCRecord
|
||||||
import pixy.meta.meta.jpeg.JPGMeta
|
import pixy.meta.meta.jpeg.JPGMeta
|
||||||
import pixy.meta.meta.xmp.XMP
|
import pixy.meta.meta.xmp.XMP
|
||||||
import pixy.meta.string.XMLUtils
|
import pixy.meta.string.XMLUtils
|
||||||
|
@ -50,9 +54,46 @@ object PixyMetaHelper {
|
||||||
return metadataMap
|
return metadataMap
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getIptc(input: InputStream): List<FieldMap>? {
|
||||||
|
val iptc = Metadata.readMetadata(input)[MetadataType.IPTC] as IPTC? ?: return null
|
||||||
|
|
||||||
|
val iptcDataList = ArrayList<FieldMap>()
|
||||||
|
iptc.dataSets.forEach { dataSetEntry ->
|
||||||
|
val tag = dataSetEntry.key
|
||||||
|
val dataSets = dataSetEntry.value
|
||||||
|
iptcDataList.add(
|
||||||
|
hashMapOf(
|
||||||
|
"record" to tag.recordNumber,
|
||||||
|
"tag" to tag.tag,
|
||||||
|
"values" to dataSets.map { it.data }.toMutableList(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return iptcDataList
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setIptc(
|
||||||
|
input: InputStream,
|
||||||
|
output: OutputStream,
|
||||||
|
iptcDataList: List<FieldMap>?,
|
||||||
|
) {
|
||||||
|
val iptc = iptcDataList?.flatMap {
|
||||||
|
val record = it["record"] as Int
|
||||||
|
val tag = it["tag"] as Int
|
||||||
|
val values = it["values"] as List<*>
|
||||||
|
values.map { data -> IPTCDataSet(IPTCRecord.fromRecordNumber(record), tag, data as ByteArray) }
|
||||||
|
} ?: ArrayList<IPTCDataSet>()
|
||||||
|
Metadata.insertIPTC(input, output, iptc)
|
||||||
|
}
|
||||||
|
|
||||||
fun getXmp(input: InputStream): XMP? = Metadata.readMetadata(input)[MetadataType.XMP] as XMP?
|
fun getXmp(input: InputStream): XMP? = Metadata.readMetadata(input)[MetadataType.XMP] as XMP?
|
||||||
|
|
||||||
fun setXmp(input: InputStream, output: OutputStream, xmpString: String, extendedXmpString: String?) {
|
fun setXmp(
|
||||||
|
input: InputStream,
|
||||||
|
output: OutputStream,
|
||||||
|
xmpString: String?,
|
||||||
|
extendedXmpString: String?
|
||||||
|
) {
|
||||||
if (extendedXmpString != null) {
|
if (extendedXmpString != null) {
|
||||||
JPGMeta.insertXMP(input, output, xmpString, extendedXmpString)
|
JPGMeta.insertXMP(input, output, xmpString, extendedXmpString)
|
||||||
} else {
|
} else {
|
||||||
|
@ -70,12 +111,12 @@ object PixyMetaHelper {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun toMetadataType(typeString: String): MetadataType? = when (typeString) {
|
private fun toMetadataType(typeString: String): MetadataType? = when (typeString) {
|
||||||
|
TYPE_COMMENT -> MetadataType.COMMENT
|
||||||
TYPE_EXIF -> MetadataType.EXIF
|
TYPE_EXIF -> MetadataType.EXIF
|
||||||
TYPE_ICC_PROFILE -> MetadataType.ICC_PROFILE
|
TYPE_ICC_PROFILE -> MetadataType.ICC_PROFILE
|
||||||
TYPE_IPTC -> MetadataType.IPTC
|
TYPE_IPTC -> MetadataType.IPTC
|
||||||
TYPE_JFIF -> MetadataType.JPG_JFIF
|
TYPE_JFIF -> MetadataType.JPG_JFIF
|
||||||
TYPE_JPEG_ADOBE -> MetadataType.JPG_ADOBE
|
TYPE_JPEG_ADOBE -> MetadataType.JPG_ADOBE
|
||||||
TYPE_JPEG_COMMENT -> MetadataType.COMMENT
|
|
||||||
TYPE_JPEG_DUCKY -> MetadataType.JPG_DUCKY
|
TYPE_JPEG_DUCKY -> MetadataType.JPG_DUCKY
|
||||||
TYPE_PHOTOSHOP_IRB -> MetadataType.PHOTOSHOP_IRB
|
TYPE_PHOTOSHOP_IRB -> MetadataType.PHOTOSHOP_IRB
|
||||||
TYPE_XMP -> MetadataType.XMP
|
TYPE_XMP -> MetadataType.XMP
|
||||||
|
|
|
@ -27,6 +27,7 @@ import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.model.NameConflictStrategy
|
import deckers.thibault.aves.model.NameConflictStrategy
|
||||||
import deckers.thibault.aves.utils.*
|
import deckers.thibault.aves.utils.*
|
||||||
import deckers.thibault.aves.utils.MimeTypes.canEditExif
|
import deckers.thibault.aves.utils.MimeTypes.canEditExif
|
||||||
|
import deckers.thibault.aves.utils.MimeTypes.canEditIptc
|
||||||
import deckers.thibault.aves.utils.MimeTypes.canEditXmp
|
import deckers.thibault.aves.utils.MimeTypes.canEditXmp
|
||||||
import deckers.thibault.aves.utils.MimeTypes.canRemoveMetadata
|
import deckers.thibault.aves.utils.MimeTypes.canRemoveMetadata
|
||||||
import deckers.thibault.aves.utils.MimeTypes.extensionFor
|
import deckers.thibault.aves.utils.MimeTypes.extensionFor
|
||||||
|
@ -460,6 +461,94 @@ abstract class ImageProvider {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun editIptc(
|
||||||
|
context: Context,
|
||||||
|
path: String,
|
||||||
|
uri: Uri,
|
||||||
|
mimeType: String,
|
||||||
|
callback: ImageOpCallback,
|
||||||
|
trailerDiff: Int = 0,
|
||||||
|
iptc: List<FieldMap>?,
|
||||||
|
): Boolean {
|
||||||
|
if (!canEditIptc(mimeType)) {
|
||||||
|
callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType"))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val originalFileSize = File(path).length()
|
||||||
|
val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff }
|
||||||
|
var videoBytes: ByteArray? = null
|
||||||
|
val editableFile = File.createTempFile("aves", null).apply {
|
||||||
|
deleteOnExit()
|
||||||
|
try {
|
||||||
|
outputStream().use { output ->
|
||||||
|
if (videoSize != null) {
|
||||||
|
// handle motion photo and embedded video separately
|
||||||
|
val imageSize = (originalFileSize - videoSize).toInt()
|
||||||
|
videoBytes = ByteArray(videoSize)
|
||||||
|
|
||||||
|
StorageUtils.openInputStream(context, uri)?.let { input ->
|
||||||
|
val imageBytes = ByteArray(imageSize)
|
||||||
|
input.read(imageBytes, 0, imageSize)
|
||||||
|
input.read(videoBytes, 0, videoSize)
|
||||||
|
|
||||||
|
// copy only the image to a temporary file for editing
|
||||||
|
// video will be appended after metadata modification
|
||||||
|
ByteArrayInputStream(imageBytes).use { imageInput ->
|
||||||
|
imageInput.copyTo(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// copy original file to a temporary file for editing
|
||||||
|
StorageUtils.openInputStream(context, uri)?.use { imageInput ->
|
||||||
|
imageInput.copyTo(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
callback.onFailure(e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
editableFile.outputStream().use { output ->
|
||||||
|
// reopen input to read from start
|
||||||
|
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||||
|
when {
|
||||||
|
iptc != null ->
|
||||||
|
PixyMetaHelper.setIptc(input, output, iptc)
|
||||||
|
canRemoveMetadata(mimeType) ->
|
||||||
|
PixyMetaHelper.removeMetadata(input, output, setOf(Metadata.TYPE_IPTC))
|
||||||
|
else -> {
|
||||||
|
Log.w(LOG_TAG, "setting empty IPTC for mimeType=$mimeType")
|
||||||
|
PixyMetaHelper.setIptc(input, output, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (videoBytes != null) {
|
||||||
|
// append trailer video, if any
|
||||||
|
editableFile.appendBytes(videoBytes!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
// copy the edited temporary file back to the original
|
||||||
|
copyTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path)
|
||||||
|
|
||||||
|
if (!checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
callback.onFailure(e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// provide `editCoreXmp` to modify existing core XMP,
|
||||||
|
// or provide `coreXmp` and `extendedXmp` to set them
|
||||||
private fun editXmp(
|
private fun editXmp(
|
||||||
context: Context,
|
context: Context,
|
||||||
path: String,
|
path: String,
|
||||||
|
@ -467,7 +556,9 @@ abstract class ImageProvider {
|
||||||
mimeType: String,
|
mimeType: String,
|
||||||
callback: ImageOpCallback,
|
callback: ImageOpCallback,
|
||||||
trailerDiff: Int = 0,
|
trailerDiff: Int = 0,
|
||||||
edit: (xmp: String) -> String,
|
coreXmp: String? = null,
|
||||||
|
extendedXmp: String? = null,
|
||||||
|
editCoreXmp: ((xmp: String) -> String)? = null,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
if (!canEditXmp(mimeType)) {
|
if (!canEditXmp(mimeType)) {
|
||||||
callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType"))
|
callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType"))
|
||||||
|
@ -479,18 +570,34 @@ abstract class ImageProvider {
|
||||||
val editableFile = File.createTempFile("aves", null).apply {
|
val editableFile = File.createTempFile("aves", null).apply {
|
||||||
deleteOnExit()
|
deleteOnExit()
|
||||||
try {
|
try {
|
||||||
val xmp = StorageUtils.openInputStream(context, uri)?.use { input -> PixyMetaHelper.getXmp(input) }
|
var editedXmpString = coreXmp
|
||||||
if (xmp == null) {
|
var editedExtendedXmp = extendedXmp
|
||||||
callback.onFailure(Exception("failed to find XMP for path=$path, uri=$uri"))
|
if (editCoreXmp != null) {
|
||||||
return false
|
val pixyXmp = StorageUtils.openInputStream(context, uri)?.use { input -> PixyMetaHelper.getXmp(input) }
|
||||||
|
if (pixyXmp != null) {
|
||||||
|
editedXmpString = editCoreXmp(pixyXmp.xmpDocString())
|
||||||
|
if (pixyXmp.hasExtendedXmp()) {
|
||||||
|
editedExtendedXmp = pixyXmp.extendedXmpDocString()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
outputStream().use { output ->
|
outputStream().use { output ->
|
||||||
// reopen input to read from start
|
// reopen input to read from start
|
||||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||||
val editedXmpString = edit(xmp.xmpDocString())
|
if (editedXmpString != null) {
|
||||||
val extendedXmpString = if (xmp.hasExtendedXmp()) xmp.extendedXmpDocString() else null
|
if (editedExtendedXmp != null && mimeType != MimeTypes.JPEG) {
|
||||||
PixyMetaHelper.setXmp(input, output, editedXmpString, extendedXmpString)
|
Log.w(LOG_TAG, "extended XMP is not supported by mimeType=$mimeType")
|
||||||
|
PixyMetaHelper.setXmp(input, output, editedXmpString, null)
|
||||||
|
} else {
|
||||||
|
PixyMetaHelper.setXmp(input, output, editedXmpString, editedExtendedXmp)
|
||||||
|
}
|
||||||
|
} else if (canRemoveMetadata(mimeType)) {
|
||||||
|
PixyMetaHelper.removeMetadata(input, output, setOf(Metadata.TYPE_XMP))
|
||||||
|
} else {
|
||||||
|
Log.w(LOG_TAG, "setting empty XMP for mimeType=$mimeType")
|
||||||
|
PixyMetaHelper.setXmp(input, output, null, null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
@ -538,7 +645,7 @@ abstract class ImageProvider {
|
||||||
"We need to edit XMP to adjust trailer video offset by $diff bytes."
|
"We need to edit XMP to adjust trailer video offset by $diff bytes."
|
||||||
)
|
)
|
||||||
val newTrailerOffset = trailerOffset + diff
|
val newTrailerOffset = trailerOffset + diff
|
||||||
return editXmp(context, path, uri, mimeType, callback, trailerDiff = diff) { xmp ->
|
return editXmp(context, path, uri, mimeType, callback, trailerDiff = diff, editCoreXmp = { xmp ->
|
||||||
xmp.replace(
|
xmp.replace(
|
||||||
// GCamera motion photo
|
// GCamera motion photo
|
||||||
"${XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME}=\"$trailerOffset\"",
|
"${XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME}=\"$trailerOffset\"",
|
||||||
|
@ -548,7 +655,7 @@ abstract class ImageProvider {
|
||||||
"${XMP.CONTAINER_ITEM_LENGTH_PROP_NAME}=\"$trailerOffset\"",
|
"${XMP.CONTAINER_ITEM_LENGTH_PROP_NAME}=\"$trailerOffset\"",
|
||||||
"${XMP.CONTAINER_ITEM_LENGTH_PROP_NAME}=\"$newTrailerOffset\"",
|
"${XMP.CONTAINER_ITEM_LENGTH_PROP_NAME}=\"$newTrailerOffset\"",
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fun editOrientation(
|
fun editOrientation(
|
||||||
|
@ -679,6 +786,65 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setIptc(
|
||||||
|
context: Context,
|
||||||
|
path: String,
|
||||||
|
uri: Uri,
|
||||||
|
mimeType: String,
|
||||||
|
postEditScan: Boolean,
|
||||||
|
callback: ImageOpCallback,
|
||||||
|
iptc: List<FieldMap>? = null,
|
||||||
|
) {
|
||||||
|
val newFields = HashMap<String, Any?>()
|
||||||
|
|
||||||
|
val success = editIptc(
|
||||||
|
context = context,
|
||||||
|
path = path,
|
||||||
|
uri = uri,
|
||||||
|
mimeType = mimeType,
|
||||||
|
callback = callback,
|
||||||
|
iptc = iptc,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
if (postEditScan) {
|
||||||
|
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
|
||||||
|
} else {
|
||||||
|
callback.onSuccess(HashMap())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
callback.onFailure(Exception("failed to set IPTC"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setXmp(
|
||||||
|
context: Context,
|
||||||
|
path: String,
|
||||||
|
uri: Uri,
|
||||||
|
mimeType: String,
|
||||||
|
callback: ImageOpCallback,
|
||||||
|
coreXmp: String? = null,
|
||||||
|
extendedXmp: String? = null,
|
||||||
|
) {
|
||||||
|
val newFields = HashMap<String, Any?>()
|
||||||
|
|
||||||
|
val success = editXmp(
|
||||||
|
context = context,
|
||||||
|
path = path,
|
||||||
|
uri = uri,
|
||||||
|
mimeType = mimeType,
|
||||||
|
callback = callback,
|
||||||
|
coreXmp = coreXmp,
|
||||||
|
extendedXmp = extendedXmp,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
|
||||||
|
} else {
|
||||||
|
callback.onFailure(Exception("failed to set XMP"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun removeMetadataTypes(
|
fun removeMetadataTypes(
|
||||||
context: Context,
|
context: Context,
|
||||||
path: String,
|
path: String,
|
||||||
|
|
|
@ -110,7 +110,16 @@ object MimeTypes {
|
||||||
}
|
}
|
||||||
|
|
||||||
// as of latest PixyMeta
|
// as of latest PixyMeta
|
||||||
fun canEditXmp(mimeType: String) = canReadWithPixyMeta(mimeType)
|
fun canEditIptc(mimeType: String) = when (mimeType) {
|
||||||
|
JPEG, TIFF -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
|
||||||
|
// as of latest PixyMeta
|
||||||
|
fun canEditXmp(mimeType: String) = when (mimeType) {
|
||||||
|
JPEG, TIFF, PNG, GIF -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
|
||||||
// as of latest PixyMeta
|
// as of latest PixyMeta
|
||||||
fun canRemoveMetadata(mimeType: String) = when (mimeType) {
|
fun canRemoveMetadata(mimeType: String) = when (mimeType) {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
buildscript {
|
buildscript {
|
||||||
ext.kotlin_version = '1.5.31'
|
ext.kotlin_version = '1.6.0'
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
|
|
@ -53,6 +53,8 @@
|
||||||
"@hideTooltip": {},
|
"@hideTooltip": {},
|
||||||
"removeTooltip": "Remove",
|
"removeTooltip": "Remove",
|
||||||
"@removeTooltip": {},
|
"@removeTooltip": {},
|
||||||
|
"resetButtonTooltip": "Reset",
|
||||||
|
"@resetButtonTooltip": {},
|
||||||
|
|
||||||
"doubleBackExitMessage": "Tap “back” again to exit.",
|
"doubleBackExitMessage": "Tap “back” again to exit.",
|
||||||
"@doubleBackExitMessage": {},
|
"@doubleBackExitMessage": {},
|
||||||
|
@ -145,6 +147,8 @@
|
||||||
|
|
||||||
"entryInfoActionEditDate": "Edit date & time",
|
"entryInfoActionEditDate": "Edit date & time",
|
||||||
"@entryInfoActionEditDate": {},
|
"@entryInfoActionEditDate": {},
|
||||||
|
"entryInfoActionEditTags": "Edit tags",
|
||||||
|
"@entryInfoActionEditTags": {},
|
||||||
"entryInfoActionRemoveMetadata": "Remove metadata",
|
"entryInfoActionRemoveMetadata": "Remove metadata",
|
||||||
"@entryInfoActionRemoveMetadata": {},
|
"@entryInfoActionRemoveMetadata": {},
|
||||||
|
|
||||||
|
@ -1026,6 +1030,13 @@
|
||||||
"viewerInfoSearchSuggestionRights": "Rights",
|
"viewerInfoSearchSuggestionRights": "Rights",
|
||||||
"@viewerInfoSearchSuggestionRights": {},
|
"@viewerInfoSearchSuggestionRights": {},
|
||||||
|
|
||||||
|
"tagEditorPageTitle": "Edit Tags",
|
||||||
|
"@tagEditorPageTitle": {},
|
||||||
|
"tagEditorPageNewTagFieldLabel": "New tag",
|
||||||
|
"@tagEditorPageNewTagFieldLabel": {},
|
||||||
|
"tagEditorPageAddTagTooltip": "Add tag",
|
||||||
|
"@tagEditorPageAddTagTooltip": {},
|
||||||
|
|
||||||
"panoramaEnableSensorControl": "Enable sensor control",
|
"panoramaEnableSensorControl": "Enable sensor control",
|
||||||
"@panoramaEnableSensorControl": {},
|
"@panoramaEnableSensorControl": {},
|
||||||
"panoramaDisableSensorControl": "Disable sensor control",
|
"panoramaDisableSensorControl": "Disable sensor control",
|
||||||
|
|
|
@ -5,6 +5,7 @@ import 'package:flutter/widgets.dart';
|
||||||
enum EntryInfoAction {
|
enum EntryInfoAction {
|
||||||
// general
|
// general
|
||||||
editDate,
|
editDate,
|
||||||
|
editTags,
|
||||||
removeMetadata,
|
removeMetadata,
|
||||||
// motion photo
|
// motion photo
|
||||||
viewMotionPhotoVideo,
|
viewMotionPhotoVideo,
|
||||||
|
@ -13,6 +14,7 @@ enum EntryInfoAction {
|
||||||
class EntryInfoActions {
|
class EntryInfoActions {
|
||||||
static const all = [
|
static const all = [
|
||||||
EntryInfoAction.editDate,
|
EntryInfoAction.editDate,
|
||||||
|
EntryInfoAction.editTags,
|
||||||
EntryInfoAction.removeMetadata,
|
EntryInfoAction.removeMetadata,
|
||||||
EntryInfoAction.viewMotionPhotoVideo,
|
EntryInfoAction.viewMotionPhotoVideo,
|
||||||
];
|
];
|
||||||
|
@ -24,6 +26,8 @@ extension ExtraEntryInfoAction on EntryInfoAction {
|
||||||
// general
|
// general
|
||||||
case EntryInfoAction.editDate:
|
case EntryInfoAction.editDate:
|
||||||
return context.l10n.entryInfoActionEditDate;
|
return context.l10n.entryInfoActionEditDate;
|
||||||
|
case EntryInfoAction.editTags:
|
||||||
|
return context.l10n.entryInfoActionEditTags;
|
||||||
case EntryInfoAction.removeMetadata:
|
case EntryInfoAction.removeMetadata:
|
||||||
return context.l10n.entryInfoActionRemoveMetadata;
|
return context.l10n.entryInfoActionRemoveMetadata;
|
||||||
// motion photo
|
// motion photo
|
||||||
|
@ -41,6 +45,8 @@ extension ExtraEntryInfoAction on EntryInfoAction {
|
||||||
// general
|
// general
|
||||||
case EntryInfoAction.editDate:
|
case EntryInfoAction.editDate:
|
||||||
return AIcons.date;
|
return AIcons.date;
|
||||||
|
case EntryInfoAction.editTags:
|
||||||
|
return AIcons.addTag;
|
||||||
case EntryInfoAction.removeMetadata:
|
case EntryInfoAction.removeMetadata:
|
||||||
return AIcons.clear;
|
return AIcons.clear;
|
||||||
// motion photo
|
// motion photo
|
||||||
|
|
|
@ -26,6 +26,7 @@ enum EntrySetAction {
|
||||||
rotateCW,
|
rotateCW,
|
||||||
flip,
|
flip,
|
||||||
editDate,
|
editDate,
|
||||||
|
editTags,
|
||||||
removeMetadata,
|
removeMetadata,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -104,6 +105,8 @@ extension ExtraEntrySetAction on EntrySetAction {
|
||||||
return context.l10n.entryActionFlip;
|
return context.l10n.entryActionFlip;
|
||||||
case EntrySetAction.editDate:
|
case EntrySetAction.editDate:
|
||||||
return context.l10n.entryInfoActionEditDate;
|
return context.l10n.entryInfoActionEditDate;
|
||||||
|
case EntrySetAction.editTags:
|
||||||
|
return context.l10n.entryInfoActionEditTags;
|
||||||
case EntrySetAction.removeMetadata:
|
case EntrySetAction.removeMetadata:
|
||||||
return context.l10n.entryInfoActionRemoveMetadata;
|
return context.l10n.entryInfoActionRemoveMetadata;
|
||||||
}
|
}
|
||||||
|
@ -158,6 +161,8 @@ extension ExtraEntrySetAction on EntrySetAction {
|
||||||
return AIcons.flip;
|
return AIcons.flip;
|
||||||
case EntrySetAction.editDate:
|
case EntrySetAction.editDate:
|
||||||
return AIcons.date;
|
return AIcons.date;
|
||||||
|
case EntrySetAction.editTags:
|
||||||
|
return AIcons.addTag;
|
||||||
case EntrySetAction.removeMetadata:
|
case EntrySetAction.removeMetadata:
|
||||||
return AIcons.clear;
|
return AIcons.clear;
|
||||||
}
|
}
|
||||||
|
|
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:flutter/foundation.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
|
enum EntryDataType { basic, catalog, address, references }
|
||||||
|
|
||||||
class AvesEntry {
|
class AvesEntry {
|
||||||
String uri;
|
String uri;
|
||||||
String? _path, _directory, _filename, _extension;
|
String? _path, _directory, _filename, _extension;
|
||||||
|
@ -235,6 +237,10 @@ class AvesEntry {
|
||||||
|
|
||||||
bool get canEdit => path != null;
|
bool get canEdit => path != null;
|
||||||
|
|
||||||
|
bool get canEditDate => canEdit && canEditExif;
|
||||||
|
|
||||||
|
bool get canEditTags => canEdit && canEditXmp;
|
||||||
|
|
||||||
bool get canRotateAndFlip => canEdit && canEditExif;
|
bool get canRotateAndFlip => canEdit && canEditExif;
|
||||||
|
|
||||||
// as of androidx.exifinterface:exifinterface:1.3.3
|
// as of androidx.exifinterface:exifinterface:1.3.3
|
||||||
|
@ -250,6 +256,30 @@ class AvesEntry {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// as of latest PixyMeta
|
||||||
|
bool get canEditIptc {
|
||||||
|
switch (mimeType.toLowerCase()) {
|
||||||
|
case MimeTypes.jpeg:
|
||||||
|
case MimeTypes.tiff:
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// as of latest PixyMeta
|
||||||
|
bool get canEditXmp {
|
||||||
|
switch (mimeType.toLowerCase()) {
|
||||||
|
case MimeTypes.gif:
|
||||||
|
case MimeTypes.jpeg:
|
||||||
|
case MimeTypes.png:
|
||||||
|
case MimeTypes.tiff:
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// as of latest PixyMeta
|
// as of latest PixyMeta
|
||||||
bool get canRemoveMetadata {
|
bool get canRemoveMetadata {
|
||||||
switch (mimeType.toLowerCase()) {
|
switch (mimeType.toLowerCase()) {
|
||||||
|
@ -394,11 +424,11 @@ class AvesEntry {
|
||||||
|
|
||||||
LatLng? get latLng => hasGps ? LatLng(_catalogMetadata!.latitude!, _catalogMetadata!.longitude!) : null;
|
LatLng? get latLng => hasGps ? LatLng(_catalogMetadata!.latitude!, _catalogMetadata!.longitude!) : null;
|
||||||
|
|
||||||
List<String>? _xmpSubjects;
|
Set<String>? _tags;
|
||||||
|
|
||||||
List<String> get xmpSubjects {
|
Set<String> get tags {
|
||||||
_xmpSubjects ??= _catalogMetadata?.xmpSubjects?.split(';').where((tag) => tag.isNotEmpty).toList() ?? [];
|
_tags ??= _catalogMetadata?.xmpSubjects?.split(';').where((tag) => tag.isNotEmpty).toSet() ?? {};
|
||||||
return _xmpSubjects!;
|
return _tags!;
|
||||||
}
|
}
|
||||||
|
|
||||||
String? _bestTitle;
|
String? _bestTitle;
|
||||||
|
@ -423,7 +453,7 @@ class AvesEntry {
|
||||||
catalogDateMillis = newMetadata?.dateMillis;
|
catalogDateMillis = newMetadata?.dateMillis;
|
||||||
_catalogMetadata = newMetadata;
|
_catalogMetadata = newMetadata;
|
||||||
_bestTitle = null;
|
_bestTitle = null;
|
||||||
_xmpSubjects = null;
|
_tags = null;
|
||||||
metadataChangeNotifier.notifyListeners();
|
metadataChangeNotifier.notifyListeners();
|
||||||
|
|
||||||
_onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
|
_onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
|
||||||
|
@ -434,7 +464,7 @@ class AvesEntry {
|
||||||
addressDetails = null;
|
addressDetails = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> catalog({required bool background, required bool persist, required bool force}) async {
|
Future<void> catalog({required bool background, required bool force, required bool persist}) async {
|
||||||
if (isCatalogued && !force) return;
|
if (isCatalogued && !force) return;
|
||||||
if (isSvg) {
|
if (isSvg) {
|
||||||
// vector image sizing is not essential, so we should not spend time for it during loading
|
// vector image sizing is not essential, so we should not spend time for it during loading
|
||||||
|
@ -593,58 +623,80 @@ class AvesEntry {
|
||||||
metadataChangeNotifier.notifyListeners();
|
metadataChangeNotifier.notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> refresh({required bool background, required bool persist, required bool force, required Locale geocoderLocale}) async {
|
Future<void> refresh({
|
||||||
_catalogMetadata = null;
|
required bool background,
|
||||||
_addressDetails = null;
|
required bool persist,
|
||||||
|
required Set<EntryDataType> dataTypes,
|
||||||
|
required Locale geocoderLocale,
|
||||||
|
}) async {
|
||||||
|
// clear derived fields
|
||||||
_bestDate = null;
|
_bestDate = null;
|
||||||
_bestTitle = null;
|
_bestTitle = null;
|
||||||
_xmpSubjects = null;
|
_tags = null;
|
||||||
|
|
||||||
if (persist) {
|
if (persist) {
|
||||||
await metadataDb.removeIds({contentId!}, metadataOnly: true);
|
await metadataDb.removeIds({contentId!}, dataTypes: dataTypes);
|
||||||
}
|
}
|
||||||
|
|
||||||
final updated = await mediaFileService.getEntry(uri, mimeType);
|
final updatedEntry = await mediaFileService.getEntry(uri, mimeType);
|
||||||
if (updated != null) {
|
if (updatedEntry != null) {
|
||||||
await _applyNewFields(updated.toMap(), persist: persist);
|
await _applyNewFields(updatedEntry.toMap(), persist: persist);
|
||||||
await catalog(background: background, persist: persist, force: force);
|
|
||||||
await locate(background: background, force: force, geocoderLocale: geocoderLocale);
|
|
||||||
}
|
}
|
||||||
|
await catalog(background: background, force: dataTypes.contains(EntryDataType.catalog), persist: persist);
|
||||||
|
await locate(background: background, force: dataTypes.contains(EntryDataType.address), geocoderLocale: geocoderLocale);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> rotate({required bool clockwise, required bool persist}) async {
|
Future<Set<EntryDataType>> rotate({required bool clockwise, required bool persist}) async {
|
||||||
final newFields = await metadataEditService.rotate(this, clockwise: clockwise);
|
final newFields = await metadataEditService.rotate(this, clockwise: clockwise);
|
||||||
if (newFields.isEmpty) return false;
|
if (newFields.isEmpty) return {};
|
||||||
|
|
||||||
await _applyNewFields(newFields, persist: persist);
|
await _applyNewFields(newFields, persist: persist);
|
||||||
return true;
|
return {
|
||||||
|
EntryDataType.basic,
|
||||||
|
EntryDataType.catalog,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> flip({required bool persist}) async {
|
Future<Set<EntryDataType>> flip({required bool persist}) async {
|
||||||
final newFields = await metadataEditService.flip(this);
|
final newFields = await metadataEditService.flip(this);
|
||||||
if (newFields.isEmpty) return false;
|
if (newFields.isEmpty) return {};
|
||||||
|
|
||||||
await _applyNewFields(newFields, persist: persist);
|
await _applyNewFields(newFields, persist: persist);
|
||||||
return true;
|
return {
|
||||||
|
EntryDataType.basic,
|
||||||
|
EntryDataType.catalog,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> editDate(DateModifier modifier) async {
|
Future<Set<EntryDataType>> editDate(DateModifier modifier) async {
|
||||||
if (modifier.action == DateEditAction.extractFromTitle) {
|
if (modifier.action == DateEditAction.extractFromTitle) {
|
||||||
final _title = bestTitle;
|
final _title = bestTitle;
|
||||||
if (_title == null) return false;
|
if (_title == null) return {};
|
||||||
final date = parseUnknownDateFormat(_title);
|
final date = parseUnknownDateFormat(_title);
|
||||||
if (date == null) {
|
if (date == null) {
|
||||||
await reportService.recordError('failed to parse date from title=$_title', null);
|
await reportService.recordError('failed to parse date from title=$_title', null);
|
||||||
return false;
|
return {};
|
||||||
}
|
}
|
||||||
modifier = DateModifier(DateEditAction.set, modifier.fields, dateTime: date);
|
modifier = DateModifier(DateEditAction.set, modifier.fields, dateTime: date);
|
||||||
}
|
}
|
||||||
final newFields = await metadataEditService.editDate(this, modifier);
|
final newFields = await metadataEditService.editDate(this, modifier);
|
||||||
return newFields.isNotEmpty;
|
return newFields.isEmpty
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
EntryDataType.basic,
|
||||||
|
EntryDataType.catalog,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> removeMetadata(Set<MetadataType> types) async {
|
Future<Set<EntryDataType>> removeMetadata(Set<MetadataType> types) async {
|
||||||
final newFields = await metadataEditService.removeTypes(this, types);
|
final newFields = await metadataEditService.removeTypes(this, types);
|
||||||
return newFields.isNotEmpty;
|
return newFields.isEmpty
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
EntryDataType.basic,
|
||||||
|
EntryDataType.catalog,
|
||||||
|
EntryDataType.address,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> delete() {
|
Future<bool> delete() {
|
||||||
|
|
|
@ -9,7 +9,7 @@ import 'package:aves/model/entry_cache.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
extension ExtraAvesEntry on AvesEntry {
|
extension ExtraAvesEntryImages on AvesEntry {
|
||||||
bool isThumbnailReady({double extent = 0}) => _isReady(_getThumbnailProviderKey(extent));
|
bool isThumbnailReady({double extent = 0}) => _isReady(_getThumbnailProviderKey(extent));
|
||||||
|
|
||||||
ThumbnailProvider getThumbnail({double extent = 0}) {
|
ThumbnailProvider getThumbnail({double extent = 0}) {
|
||||||
|
|
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) {
|
TagFilter(this.tag) {
|
||||||
if (tag.isEmpty) {
|
if (tag.isEmpty) {
|
||||||
_test = (entry) => entry.xmpSubjects.isEmpty;
|
_test = (entry) => entry.tags.isEmpty;
|
||||||
} else {
|
} else {
|
||||||
_test = (entry) => entry.xmpSubjects.contains(tag);
|
_test = (entry) => entry.tags.contains(tag);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -13,6 +13,8 @@ enum DateEditAction {
|
||||||
}
|
}
|
||||||
|
|
||||||
enum MetadataType {
|
enum MetadataType {
|
||||||
|
// JPEG COM marker or GIF comment
|
||||||
|
comment,
|
||||||
// Exif: https://en.wikipedia.org/wiki/Exif
|
// Exif: https://en.wikipedia.org/wiki/Exif
|
||||||
exif,
|
exif,
|
||||||
// ICC profile: https://en.wikipedia.org/wiki/ICC_profile
|
// ICC profile: https://en.wikipedia.org/wiki/ICC_profile
|
||||||
|
@ -23,8 +25,6 @@ enum MetadataType {
|
||||||
jfif,
|
jfif,
|
||||||
// JPEG APP14 / Adobe: https://www.exiftool.org/TagNames/JPEG.html#Adobe
|
// JPEG APP14 / Adobe: https://www.exiftool.org/TagNames/JPEG.html#Adobe
|
||||||
jpegAdobe,
|
jpegAdobe,
|
||||||
// JPEG COM marker
|
|
||||||
jpegComment,
|
|
||||||
// JPEG APP12 / Ducky: https://www.exiftool.org/TagNames/APP12.html#Ducky
|
// JPEG APP12 / Ducky: https://www.exiftool.org/TagNames/APP12.html#Ducky
|
||||||
jpegDucky,
|
jpegDucky,
|
||||||
// Photoshop IRB: https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/
|
// Photoshop IRB: https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/
|
||||||
|
@ -42,6 +42,7 @@ class MetadataTypes {
|
||||||
static const common = {
|
static const common = {
|
||||||
MetadataType.exif,
|
MetadataType.exif,
|
||||||
MetadataType.xmp,
|
MetadataType.xmp,
|
||||||
|
MetadataType.comment,
|
||||||
MetadataType.iccProfile,
|
MetadataType.iccProfile,
|
||||||
MetadataType.iptc,
|
MetadataType.iptc,
|
||||||
MetadataType.photoshopIrb,
|
MetadataType.photoshopIrb,
|
||||||
|
@ -50,7 +51,6 @@ class MetadataTypes {
|
||||||
static const jpeg = {
|
static const jpeg = {
|
||||||
MetadataType.jfif,
|
MetadataType.jfif,
|
||||||
MetadataType.jpegAdobe,
|
MetadataType.jpegAdobe,
|
||||||
MetadataType.jpegComment,
|
|
||||||
MetadataType.jpegDucky,
|
MetadataType.jpegDucky,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -59,6 +59,8 @@ extension ExtraMetadataType on MetadataType {
|
||||||
// match `ExifInterface` directory names
|
// match `ExifInterface` directory names
|
||||||
String getText() {
|
String getText() {
|
||||||
switch (this) {
|
switch (this) {
|
||||||
|
case MetadataType.comment:
|
||||||
|
return 'Comment';
|
||||||
case MetadataType.exif:
|
case MetadataType.exif:
|
||||||
return 'Exif';
|
return 'Exif';
|
||||||
case MetadataType.iccProfile:
|
case MetadataType.iccProfile:
|
||||||
|
@ -69,8 +71,6 @@ extension ExtraMetadataType on MetadataType {
|
||||||
return 'JFIF';
|
return 'JFIF';
|
||||||
case MetadataType.jpegAdobe:
|
case MetadataType.jpegAdobe:
|
||||||
return 'Adobe JPEG';
|
return 'Adobe JPEG';
|
||||||
case MetadataType.jpegComment:
|
|
||||||
return 'JpegComment';
|
|
||||||
case MetadataType.jpegDucky:
|
case MetadataType.jpegDucky:
|
||||||
return 'Ducky';
|
return 'Ducky';
|
||||||
case MetadataType.photoshopIrb:
|
case MetadataType.photoshopIrb:
|
||||||
|
|
|
@ -20,7 +20,7 @@ abstract class MetadataDb {
|
||||||
|
|
||||||
Future<void> reset();
|
Future<void> reset();
|
||||||
|
|
||||||
Future<void> removeIds(Set<int> contentIds, {required bool metadataOnly});
|
Future<void> removeIds(Set<int> contentIds, {Set<EntryDataType>? dataTypes});
|
||||||
|
|
||||||
// entries
|
// entries
|
||||||
|
|
||||||
|
@ -187,20 +187,28 @@ class SqfliteMetadataDb implements MetadataDb {
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> removeIds(Set<int> contentIds, {required bool metadataOnly}) async {
|
Future<void> removeIds(Set<int> contentIds, {Set<EntryDataType>? dataTypes}) async {
|
||||||
if (contentIds.isEmpty) return;
|
if (contentIds.isEmpty) return;
|
||||||
|
|
||||||
|
final _dataTypes = dataTypes ?? EntryDataType.values.toSet();
|
||||||
|
|
||||||
final db = await _database;
|
final db = await _database;
|
||||||
// using array in `whereArgs` and using it with `where contentId IN ?` is a pain, so we prefer `batch` instead
|
// using array in `whereArgs` and using it with `where contentId IN ?` is a pain, so we prefer `batch` instead
|
||||||
final batch = db.batch();
|
final batch = db.batch();
|
||||||
const where = 'contentId = ?';
|
const where = 'contentId = ?';
|
||||||
contentIds.forEach((id) {
|
contentIds.forEach((id) {
|
||||||
final whereArgs = [id];
|
final whereArgs = [id];
|
||||||
batch.delete(entryTable, where: where, whereArgs: whereArgs);
|
if (_dataTypes.contains(EntryDataType.basic)) {
|
||||||
batch.delete(dateTakenTable, where: where, whereArgs: whereArgs);
|
batch.delete(entryTable, where: where, whereArgs: whereArgs);
|
||||||
batch.delete(metadataTable, where: where, whereArgs: whereArgs);
|
}
|
||||||
batch.delete(addressTable, where: where, whereArgs: whereArgs);
|
if (_dataTypes.contains(EntryDataType.catalog)) {
|
||||||
if (!metadataOnly) {
|
batch.delete(dateTakenTable, where: where, whereArgs: whereArgs);
|
||||||
|
batch.delete(metadataTable, where: where, whereArgs: whereArgs);
|
||||||
|
}
|
||||||
|
if (_dataTypes.contains(EntryDataType.address)) {
|
||||||
|
batch.delete(addressTable, where: where, whereArgs: whereArgs);
|
||||||
|
}
|
||||||
|
if (_dataTypes.contains(EntryDataType.references)) {
|
||||||
batch.delete(favouriteTable, where: where, whereArgs: whereArgs);
|
batch.delete(favouriteTable, where: where, whereArgs: whereArgs);
|
||||||
batch.delete(coverTable, where: where, whereArgs: whereArgs);
|
batch.delete(coverTable, where: where, whereArgs: whereArgs);
|
||||||
batch.delete(videoPlaybackTable, where: where, whereArgs: whereArgs);
|
batch.delete(videoPlaybackTable, where: where, whereArgs: whereArgs);
|
||||||
|
|
|
@ -284,8 +284,8 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
||||||
|
|
||||||
Future<Set<String>> refreshUris(Set<String> changedUris, {AnalysisController? analysisController});
|
Future<Set<String>> refreshUris(Set<String> changedUris, {AnalysisController? analysisController});
|
||||||
|
|
||||||
Future<void> refreshEntry(AvesEntry entry) async {
|
Future<void> refreshEntry(AvesEntry entry, Set<EntryDataType> dataTypes) async {
|
||||||
await entry.refresh(background: false, persist: true, force: true, geocoderLocale: settings.appliedLocale);
|
await entry.refresh(background: false, persist: true, dataTypes: dataTypes, geocoderLocale: settings.appliedLocale);
|
||||||
updateDerivedFilters({entry});
|
updateDerivedFilters({entry});
|
||||||
eventBus.fire(EntryRefreshedEvent({entry}));
|
eventBus.fire(EntryRefreshedEvent({entry}));
|
||||||
}
|
}
|
||||||
|
|
|
@ -67,7 +67,7 @@ class MediaStoreSource extends CollectionSource {
|
||||||
|
|
||||||
// clean up obsolete entries
|
// clean up obsolete entries
|
||||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} remove obsolete entries');
|
debugPrint('$runtimeType refresh ${stopwatch.elapsed} remove obsolete entries');
|
||||||
await metadataDb.removeIds(obsoleteContentIds, metadataOnly: false);
|
await metadataDb.removeIds(obsoleteContentIds);
|
||||||
|
|
||||||
// verify paths because some apps move files without updating their `last modified date`
|
// verify paths because some apps move files without updating their `last modified date`
|
||||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} check obsolete paths');
|
debugPrint('$runtimeType refresh ${stopwatch.elapsed} check obsolete paths');
|
||||||
|
|
|
@ -38,7 +38,7 @@ mixin TagMixin on SourceBase {
|
||||||
var stopCheckCount = 0;
|
var stopCheckCount = 0;
|
||||||
final newMetadata = <CatalogMetadata>{};
|
final newMetadata = <CatalogMetadata>{};
|
||||||
for (final entry in todo) {
|
for (final entry in todo) {
|
||||||
await entry.catalog(background: true, persist: true, force: force);
|
await entry.catalog(background: true, force: force, persist: true);
|
||||||
if (entry.isCatalogued) {
|
if (entry.isCatalogued) {
|
||||||
newMetadata.add(entry.catalogMetadata!);
|
newMetadata.add(entry.catalogMetadata!);
|
||||||
if (newMetadata.length >= commitCountThreshold) {
|
if (newMetadata.length >= commitCountThreshold) {
|
||||||
|
@ -63,7 +63,7 @@ mixin TagMixin on SourceBase {
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateTags() {
|
void updateTags() {
|
||||||
final updatedTags = visibleEntries.expand((entry) => entry.xmpSubjects).toSet().toList()..sort(compareAsciiUpperCase);
|
final updatedTags = visibleEntries.expand((entry) => entry.tags).toSet().toList()..sort(compareAsciiUpperCase);
|
||||||
if (!listEquals(updatedTags, sortedTags)) {
|
if (!listEquals(updatedTags, sortedTags)) {
|
||||||
sortedTags = List.unmodifiable(updatedTags);
|
sortedTags = List.unmodifiable(updatedTags);
|
||||||
invalidateTagFilterSummary();
|
invalidateTagFilterSummary();
|
||||||
|
@ -85,7 +85,7 @@ mixin TagMixin on SourceBase {
|
||||||
_filterEntryCountMap.clear();
|
_filterEntryCountMap.clear();
|
||||||
_filterRecentEntryMap.clear();
|
_filterRecentEntryMap.clear();
|
||||||
} else {
|
} else {
|
||||||
tags = entries.where((entry) => entry.isCatalogued).expand((entry) => entry.xmpSubjects).toSet();
|
tags = entries.where((entry) => entry.isCatalogued).expand((entry) => entry.tags).toSet();
|
||||||
tags.forEach(_filterEntryCountMap.remove);
|
tags.forEach(_filterEntryCountMap.remove);
|
||||||
}
|
}
|
||||||
eventBus.fire(TagSummaryInvalidatedEvent(tags));
|
eventBus.fire(TagSummaryInvalidatedEvent(tags));
|
||||||
|
|
6
lib/ref/iptc.dart
Normal file
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 'dart:async';
|
||||||
|
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
|
import 'package:aves/model/entry_xmp_iptc.dart';
|
||||||
import 'package:aves/model/metadata/date_modifier.dart';
|
import 'package:aves/model/metadata/date_modifier.dart';
|
||||||
import 'package:aves/model/metadata/enums.dart';
|
import 'package:aves/model/metadata/enums.dart';
|
||||||
import 'package:aves/services/common/services.dart';
|
import 'package:aves/services/common/services.dart';
|
||||||
|
@ -13,6 +14,10 @@ abstract class MetadataEditService {
|
||||||
|
|
||||||
Future<Map<String, dynamic>> editDate(AvesEntry entry, DateModifier modifier);
|
Future<Map<String, dynamic>> editDate(AvesEntry entry, DateModifier modifier);
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> setIptc(AvesEntry entry, List<Map<String, dynamic>>? iptc, {required bool postEditScan});
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> setXmp(AvesEntry entry, AvesXmp? xmp);
|
||||||
|
|
||||||
Future<Map<String, dynamic>> removeTypes(AvesEntry entry, Set<MetadataType> types);
|
Future<Map<String, dynamic>> removeTypes(AvesEntry entry, Set<MetadataType> types);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -85,6 +90,40 @@ class PlatformMetadataEditService implements MetadataEditService {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map<String, dynamic>> setIptc(AvesEntry entry, List<Map<String, dynamic>>? iptc, {required bool postEditScan}) async {
|
||||||
|
try {
|
||||||
|
final result = await platform.invokeMethod('setIptc', <String, dynamic>{
|
||||||
|
'entry': _toPlatformEntryMap(entry),
|
||||||
|
'iptc': iptc,
|
||||||
|
'postEditScan': postEditScan,
|
||||||
|
});
|
||||||
|
if (result != null) return (result as Map).cast<String, dynamic>();
|
||||||
|
} on PlatformException catch (e, stack) {
|
||||||
|
if (!entry.isMissingAtPath) {
|
||||||
|
await reportService.recordError(e, stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map<String, dynamic>> setXmp(AvesEntry entry, AvesXmp? xmp) async {
|
||||||
|
try {
|
||||||
|
final result = await platform.invokeMethod('setXmp', <String, dynamic>{
|
||||||
|
'entry': _toPlatformEntryMap(entry),
|
||||||
|
'xmp': xmp?.xmpString,
|
||||||
|
'extendedXmp': xmp?.extendedXmpString,
|
||||||
|
});
|
||||||
|
if (result != null) return (result as Map).cast<String, dynamic>();
|
||||||
|
} on PlatformException catch (e, stack) {
|
||||||
|
if (!entry.isMissingAtPath) {
|
||||||
|
await reportService.recordError(e, stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Map<String, dynamic>> removeTypes(AvesEntry entry, Set<MetadataType> types) async {
|
Future<Map<String, dynamic>> removeTypes(AvesEntry entry, Set<MetadataType> types) async {
|
||||||
try {
|
try {
|
||||||
|
@ -116,6 +155,8 @@ class PlatformMetadataEditService implements MetadataEditService {
|
||||||
|
|
||||||
String _toPlatformMetadataType(MetadataType type) {
|
String _toPlatformMetadataType(MetadataType type) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
case MetadataType.comment:
|
||||||
|
return 'comment';
|
||||||
case MetadataType.exif:
|
case MetadataType.exif:
|
||||||
return 'exif';
|
return 'exif';
|
||||||
case MetadataType.iccProfile:
|
case MetadataType.iccProfile:
|
||||||
|
@ -126,8 +167,6 @@ class PlatformMetadataEditService implements MetadataEditService {
|
||||||
return 'jfif';
|
return 'jfif';
|
||||||
case MetadataType.jpegAdobe:
|
case MetadataType.jpegAdobe:
|
||||||
return 'jpeg_adobe';
|
return 'jpeg_adobe';
|
||||||
case MetadataType.jpegComment:
|
|
||||||
return 'jpeg_comment';
|
|
||||||
case MetadataType.jpegDucky:
|
case MetadataType.jpegDucky:
|
||||||
return 'jpeg_ducky';
|
return 'jpeg_ducky';
|
||||||
case MetadataType.photoshopIrb:
|
case MetadataType.photoshopIrb:
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
|
import 'package:aves/model/entry_xmp_iptc.dart';
|
||||||
import 'package:aves/model/metadata/catalog.dart';
|
import 'package:aves/model/metadata/catalog.dart';
|
||||||
import 'package:aves/model/metadata/overlay.dart';
|
import 'package:aves/model/metadata/overlay.dart';
|
||||||
import 'package:aves/model/multipage.dart';
|
import 'package:aves/model/multipage.dart';
|
||||||
|
@ -20,6 +21,10 @@ abstract class MetadataFetchService {
|
||||||
|
|
||||||
Future<PanoramaInfo?> getPanoramaInfo(AvesEntry entry);
|
Future<PanoramaInfo?> getPanoramaInfo(AvesEntry entry);
|
||||||
|
|
||||||
|
Future<List<Map<String, dynamic>>?> getIptc(AvesEntry entry);
|
||||||
|
|
||||||
|
Future<AvesXmp?> getXmp(AvesEntry entry);
|
||||||
|
|
||||||
Future<bool> hasContentResolverProp(String prop);
|
Future<bool> hasContentResolverProp(String prop);
|
||||||
|
|
||||||
Future<String?> getContentResolverProp(AvesEntry entry, String prop);
|
Future<String?> getContentResolverProp(AvesEntry entry, String prop);
|
||||||
|
@ -151,6 +156,39 @@ class PlatformMetadataFetchService implements MetadataFetchService {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Map<String, dynamic>>?> getIptc(AvesEntry entry) async {
|
||||||
|
try {
|
||||||
|
final result = await platform.invokeMethod('getIptc', <String, dynamic>{
|
||||||
|
'mimeType': entry.mimeType,
|
||||||
|
'uri': entry.uri,
|
||||||
|
});
|
||||||
|
if (result != null) return (result as List).cast<Map>().map((fields) => fields.cast<String, dynamic>()).toList();
|
||||||
|
} on PlatformException catch (e, stack) {
|
||||||
|
if (!entry.isMissingAtPath) {
|
||||||
|
await reportService.recordError(e, stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AvesXmp?> getXmp(AvesEntry entry) async {
|
||||||
|
try {
|
||||||
|
final result = await platform.invokeMethod('getXmp', <String, dynamic>{
|
||||||
|
'mimeType': entry.mimeType,
|
||||||
|
'uri': entry.uri,
|
||||||
|
'sizeBytes': entry.sizeBytes,
|
||||||
|
});
|
||||||
|
if (result != null) return AvesXmp.fromList((result as List).cast<String>());
|
||||||
|
} on PlatformException catch (e, stack) {
|
||||||
|
if (!entry.isMissingAtPath) {
|
||||||
|
await reportService.recordError(e, stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
final Map<String, bool> _contentResolverProps = {};
|
final Map<String, bool> _contentResolverProps = {};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
@ -46,6 +46,7 @@ class Durations {
|
||||||
// info animations
|
// info animations
|
||||||
static const mapStyleSwitchAnimation = Duration(milliseconds: 300);
|
static const mapStyleSwitchAnimation = Duration(milliseconds: 300);
|
||||||
static const xmpStructArrayCardTransition = Duration(milliseconds: 300);
|
static const xmpStructArrayCardTransition = Duration(milliseconds: 300);
|
||||||
|
static const tagEditorTransition = Duration(milliseconds: 200);
|
||||||
|
|
||||||
// settings animations
|
// settings animations
|
||||||
static const quickActionListAnimation = Duration(milliseconds: 200);
|
static const quickActionListAnimation = Duration(milliseconds: 200);
|
||||||
|
|
|
@ -33,6 +33,7 @@ class AIcons {
|
||||||
// actions
|
// actions
|
||||||
static const IconData add = Icons.add_circle_outline;
|
static const IconData add = Icons.add_circle_outline;
|
||||||
static const IconData addShortcut = Icons.add_to_home_screen_outlined;
|
static const IconData addShortcut = Icons.add_to_home_screen_outlined;
|
||||||
|
static const IconData addTag = MdiIcons.tagPlusOutline;
|
||||||
static const IconData replay10 = Icons.replay_10_outlined;
|
static const IconData replay10 = Icons.replay_10_outlined;
|
||||||
static const IconData skip10 = Icons.forward_10_outlined;
|
static const IconData skip10 = Icons.forward_10_outlined;
|
||||||
static const IconData captureFrame = Icons.screenshot_outlined;
|
static const IconData captureFrame = Icons.screenshot_outlined;
|
||||||
|
@ -66,6 +67,7 @@ class AIcons {
|
||||||
static const IconData print = Icons.print_outlined;
|
static const IconData print = Icons.print_outlined;
|
||||||
static const IconData refresh = Icons.refresh_outlined;
|
static const IconData refresh = Icons.refresh_outlined;
|
||||||
static const IconData rename = Icons.title_outlined;
|
static const IconData rename = Icons.title_outlined;
|
||||||
|
static const IconData reset = Icons.restart_alt_outlined;
|
||||||
static const IconData rotateLeft = Icons.rotate_left_outlined;
|
static const IconData rotateLeft = Icons.rotate_left_outlined;
|
||||||
static const IconData rotateRight = Icons.rotate_right_outlined;
|
static const IconData rotateRight = Icons.rotate_right_outlined;
|
||||||
static const IconData rotateScreen = Icons.screen_rotation_outlined;
|
static const IconData rotateScreen = Icons.screen_rotation_outlined;
|
||||||
|
|
|
@ -269,6 +269,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
||||||
_buildRotateAndFlipMenuItems(context, canApply: canApply),
|
_buildRotateAndFlipMenuItems(context, canApply: canApply),
|
||||||
...[
|
...[
|
||||||
EntrySetAction.editDate,
|
EntrySetAction.editDate,
|
||||||
|
EntrySetAction.editTags,
|
||||||
EntrySetAction.removeMetadata,
|
EntrySetAction.removeMetadata,
|
||||||
].map((action) => _toMenuItem(action, enabled: canApply(action))),
|
].map((action) => _toMenuItem(action, enabled: canApply(action))),
|
||||||
],
|
],
|
||||||
|
@ -439,6 +440,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
||||||
case EntrySetAction.rotateCW:
|
case EntrySetAction.rotateCW:
|
||||||
case EntrySetAction.flip:
|
case EntrySetAction.flip:
|
||||||
case EntrySetAction.editDate:
|
case EntrySetAction.editDate:
|
||||||
|
case EntrySetAction.editTags:
|
||||||
case EntrySetAction.removeMetadata:
|
case EntrySetAction.removeMetadata:
|
||||||
_actionDelegate.onActionSelected(context, action);
|
_actionDelegate.onActionSelected(context, action);
|
||||||
break;
|
break;
|
||||||
|
|
|
@ -5,6 +5,7 @@ import 'package:aves/app_mode.dart';
|
||||||
import 'package:aves/model/actions/entry_set_actions.dart';
|
import 'package:aves/model/actions/entry_set_actions.dart';
|
||||||
import 'package:aves/model/actions/move_type.dart';
|
import 'package:aves/model/actions/move_type.dart';
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
|
import 'package:aves/model/entry_xmp_iptc.dart';
|
||||||
import 'package:aves/model/filters/album.dart';
|
import 'package:aves/model/filters/album.dart';
|
||||||
import 'package:aves/model/filters/filters.dart';
|
import 'package:aves/model/filters/filters.dart';
|
||||||
import 'package:aves/model/highlight.dart';
|
import 'package:aves/model/highlight.dart';
|
||||||
|
@ -81,6 +82,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
||||||
case EntrySetAction.rotateCW:
|
case EntrySetAction.rotateCW:
|
||||||
case EntrySetAction.flip:
|
case EntrySetAction.flip:
|
||||||
case EntrySetAction.editDate:
|
case EntrySetAction.editDate:
|
||||||
|
case EntrySetAction.editTags:
|
||||||
case EntrySetAction.removeMetadata:
|
case EntrySetAction.removeMetadata:
|
||||||
return appMode == AppMode.main && isSelecting;
|
return appMode == AppMode.main && isSelecting;
|
||||||
}
|
}
|
||||||
|
@ -122,6 +124,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
||||||
case EntrySetAction.rotateCW:
|
case EntrySetAction.rotateCW:
|
||||||
case EntrySetAction.flip:
|
case EntrySetAction.flip:
|
||||||
case EntrySetAction.editDate:
|
case EntrySetAction.editDate:
|
||||||
|
case EntrySetAction.editTags:
|
||||||
case EntrySetAction.removeMetadata:
|
case EntrySetAction.removeMetadata:
|
||||||
return hasSelection;
|
return hasSelection;
|
||||||
}
|
}
|
||||||
|
@ -181,6 +184,9 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
||||||
case EntrySetAction.editDate:
|
case EntrySetAction.editDate:
|
||||||
_editDate(context);
|
_editDate(context);
|
||||||
break;
|
break;
|
||||||
|
case EntrySetAction.editTags:
|
||||||
|
_editTags(context);
|
||||||
|
break;
|
||||||
case EntrySetAction.removeMetadata:
|
case EntrySetAction.removeMetadata:
|
||||||
_removeMetadata(context);
|
_removeMetadata(context);
|
||||||
break;
|
break;
|
||||||
|
@ -399,7 +405,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Selection<AvesEntry> selection,
|
Selection<AvesEntry> selection,
|
||||||
Set<AvesEntry> todoItems,
|
Set<AvesEntry> todoItems,
|
||||||
Future<bool> Function(AvesEntry entry) op,
|
Future<Set<EntryDataType>> Function(AvesEntry entry) op,
|
||||||
) async {
|
) async {
|
||||||
final selectionDirs = todoItems.map((e) => e.directory).whereNotNull().toSet();
|
final selectionDirs = todoItems.map((e) => e.directory).whereNotNull().toSet();
|
||||||
final todoCount = todoItems.length;
|
final todoCount = todoItems.length;
|
||||||
|
@ -411,8 +417,8 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
||||||
showOpReport<ImageOpEvent>(
|
showOpReport<ImageOpEvent>(
|
||||||
context: context,
|
context: context,
|
||||||
opStream: Stream.fromIterable(todoItems).asyncMap((entry) async {
|
opStream: Stream.fromIterable(todoItems).asyncMap((entry) async {
|
||||||
final success = await op(entry);
|
final dataTypes = await op(entry);
|
||||||
return ImageOpEvent(success: success, uri: entry.uri);
|
return ImageOpEvent(success: dataTypes.isNotEmpty, uri: entry.uri);
|
||||||
}).asBroadcastStream(),
|
}).asBroadcastStream(),
|
||||||
itemCount: todoCount,
|
itemCount: todoCount,
|
||||||
onDone: (processed) async {
|
onDone: (processed) async {
|
||||||
|
@ -470,6 +476,8 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
||||||
);
|
);
|
||||||
if (confirmed == null || !confirmed) return null;
|
if (confirmed == null || !confirmed) return null;
|
||||||
|
|
||||||
|
// wait for the dialog to hide as applying the change may block the UI
|
||||||
|
await Future.delayed(Durations.dialogTransitionAnimation);
|
||||||
return supported;
|
return supported;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -497,7 +505,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
||||||
final selection = context.read<Selection<AvesEntry>>();
|
final selection = context.read<Selection<AvesEntry>>();
|
||||||
final selectedItems = _getExpandedSelectedItems(selection);
|
final selectedItems = _getExpandedSelectedItems(selection);
|
||||||
|
|
||||||
final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canEditExif);
|
final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canEditDate);
|
||||||
if (todoItems == null || todoItems.isEmpty) return;
|
if (todoItems == null || todoItems.isEmpty) return;
|
||||||
|
|
||||||
final modifier = await selectDateModifier(context, todoItems);
|
final modifier = await selectDateModifier(context, todoItems);
|
||||||
|
@ -506,6 +514,28 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
||||||
await _edit(context, selection, todoItems, (entry) => entry.editDate(modifier));
|
await _edit(context, selection, todoItems, (entry) => entry.editDate(modifier));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _editTags(BuildContext context) async {
|
||||||
|
final selection = context.read<Selection<AvesEntry>>();
|
||||||
|
final selectedItems = _getExpandedSelectedItems(selection);
|
||||||
|
|
||||||
|
final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canEditTags);
|
||||||
|
if (todoItems == null || todoItems.isEmpty) return;
|
||||||
|
|
||||||
|
final newTagsByEntry = await selectTags(context, todoItems);
|
||||||
|
if (newTagsByEntry == null) return;
|
||||||
|
|
||||||
|
// only process modified items
|
||||||
|
todoItems.removeWhere((entry) {
|
||||||
|
final newTags = newTagsByEntry[entry] ?? entry.tags;
|
||||||
|
final currentTags = entry.tags;
|
||||||
|
return newTags.length == currentTags.length && newTags.every(currentTags.contains);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (todoItems.isEmpty) return;
|
||||||
|
|
||||||
|
await _edit(context, selection, todoItems, (entry) => entry.editTags(newTagsByEntry[entry]!));
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _removeMetadata(BuildContext context) async {
|
Future<void> _removeMetadata(BuildContext context) async {
|
||||||
final selection = context.read<Selection<AvesEntry>>();
|
final selection = context.read<Selection<AvesEntry>>();
|
||||||
final selectedItems = _getExpandedSelectedItems(selection);
|
final selectedItems = _getExpandedSelectedItems(selection);
|
||||||
|
|
|
@ -4,8 +4,9 @@ import 'package:aves/model/metadata/enums.dart';
|
||||||
import 'package:aves/ref/mime_types.dart';
|
import 'package:aves/ref/mime_types.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||||
import 'package:aves/widgets/dialogs/edit_entry_date_dialog.dart';
|
import 'package:aves/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart';
|
||||||
import 'package:aves/widgets/dialogs/remove_entry_metadata_dialog.dart';
|
import 'package:aves/widgets/dialogs/entry_editors/edit_entry_tags_dialog.dart';
|
||||||
|
import 'package:aves/widgets/dialogs/entry_editors/remove_entry_metadata_dialog.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
mixin EntryEditorMixin {
|
mixin EntryEditorMixin {
|
||||||
|
@ -21,6 +22,23 @@ mixin EntryEditorMixin {
|
||||||
return modifier;
|
return modifier;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Map<AvesEntry, Set<String>>?> selectTags(BuildContext context, Set<AvesEntry> entries) async {
|
||||||
|
if (entries.isEmpty) return null;
|
||||||
|
|
||||||
|
final tagsByEntry = Map.fromEntries(entries.map((v) => MapEntry(v, v.tags.toSet())));
|
||||||
|
await Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
settings: const RouteSettings(name: TagEditorPage.routeName),
|
||||||
|
builder: (context) => TagEditorPage(
|
||||||
|
tagsByEntry: tagsByEntry,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return tagsByEntry;
|
||||||
|
}
|
||||||
|
|
||||||
Future<Set<MetadataType>?> selectMetadataToRemove(BuildContext context, Set<AvesEntry> entries) async {
|
Future<Set<MetadataType>?> selectMetadataToRemove(BuildContext context, Set<AvesEntry> entries) async {
|
||||||
if (entries.isEmpty) return null;
|
if (entries.isEmpty) return null;
|
||||||
|
|
||||||
|
|
|
@ -40,7 +40,7 @@ class AvesFilterChip extends StatefulWidget {
|
||||||
final bool removable, showGenericIcon, useFilterColor;
|
final bool removable, showGenericIcon, useFilterColor;
|
||||||
final AvesFilterDecoration? decoration;
|
final AvesFilterDecoration? decoration;
|
||||||
final String? banner;
|
final String? banner;
|
||||||
final Widget? details;
|
final Widget? leadingOverride, details;
|
||||||
final double padding, maxWidth;
|
final double padding, maxWidth;
|
||||||
final HeroType heroType;
|
final HeroType heroType;
|
||||||
final FilterCallback? onTap;
|
final FilterCallback? onTap;
|
||||||
|
@ -64,6 +64,7 @@ class AvesFilterChip extends StatefulWidget {
|
||||||
this.useFilterColor = true,
|
this.useFilterColor = true,
|
||||||
this.decoration,
|
this.decoration,
|
||||||
this.banner,
|
this.banner,
|
||||||
|
this.leadingOverride,
|
||||||
this.details,
|
this.details,
|
||||||
this.padding = 6.0,
|
this.padding = 6.0,
|
||||||
this.maxWidth = defaultMaxChipWidth,
|
this.maxWidth = defaultMaxChipWidth,
|
||||||
|
@ -162,7 +163,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
||||||
final chipBackground = Theme.of(context).scaffoldBackgroundColor;
|
final chipBackground = Theme.of(context).scaffoldBackgroundColor;
|
||||||
final textScaleFactor = MediaQuery.textScaleFactorOf(context);
|
final textScaleFactor = MediaQuery.textScaleFactorOf(context);
|
||||||
final iconSize = AvesFilterChip.iconSize * textScaleFactor;
|
final iconSize = AvesFilterChip.iconSize * textScaleFactor;
|
||||||
final leading = filter.iconBuilder(context, iconSize, showGenericIcon: widget.showGenericIcon);
|
final leading = widget.leadingOverride ?? filter.iconBuilder(context, iconSize, showGenericIcon: widget.showGenericIcon);
|
||||||
final trailing = widget.removable ? Icon(AIcons.clear, size: iconSize) : null;
|
final trailing = widget.removable ? Icon(AIcons.clear, size: iconSize) : null;
|
||||||
|
|
||||||
final decoration = widget.decoration;
|
final decoration = widget.decoration;
|
||||||
|
|
|
@ -9,7 +9,7 @@ import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import 'aves_dialog.dart';
|
import '../aves_dialog.dart';
|
||||||
|
|
||||||
class EditEntryDateDialog extends StatefulWidget {
|
class EditEntryDateDialog extends StatefulWidget {
|
||||||
final AvesEntry entry;
|
final AvesEntry entry;
|
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:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import 'aves_dialog.dart';
|
import '../aves_dialog.dart';
|
||||||
|
|
||||||
class RemoveEntryMetadataDialog extends StatefulWidget {
|
class RemoveEntryMetadataDialog extends StatefulWidget {
|
||||||
final bool showJpegTypes;
|
final bool showJpegTypes;
|
|
@ -5,7 +5,7 @@ import 'package:aves/services/common/services.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'aves_dialog.dart';
|
import '../aves_dialog.dart';
|
||||||
|
|
||||||
class RenameEntryDialog extends StatefulWidget {
|
class RenameEntryDialog extends StatefulWidget {
|
||||||
final AvesEntry entry;
|
final AvesEntry entry;
|
|
@ -8,7 +8,7 @@ import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
import 'aves_dialog.dart';
|
import '../aves_dialog.dart';
|
||||||
|
|
||||||
class CreateAlbumDialog extends StatefulWidget {
|
class CreateAlbumDialog extends StatefulWidget {
|
||||||
const CreateAlbumDialog({Key? key}) : super(key: key);
|
const CreateAlbumDialog({Key? key}) : super(key: key);
|
|
@ -14,7 +14,7 @@ import 'package:aves/widgets/common/basic/query_bar.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:aves/widgets/common/identity/empty.dart';
|
import 'package:aves/widgets/common/identity/empty.dart';
|
||||||
import 'package:aves/widgets/common/providers/selection_provider.dart';
|
import 'package:aves/widgets/common/providers/selection_provider.dart';
|
||||||
import 'package:aves/widgets/dialogs/create_album_dialog.dart';
|
import 'package:aves/widgets/dialogs/filter_editors/create_album_dialog.dart';
|
||||||
import 'package:aves/widgets/filter_grids/albums_page.dart';
|
import 'package:aves/widgets/filter_grids/albums_page.dart';
|
||||||
import 'package:aves/widgets/filter_grids/common/action_delegates/album_set.dart';
|
import 'package:aves/widgets/filter_grids/common/action_delegates/album_set.dart';
|
||||||
import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart';
|
import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart';
|
||||||
|
|
|
@ -18,8 +18,8 @@ import 'package:aves/utils/android_file_utils.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||||
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
|
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
|
||||||
import 'package:aves/widgets/dialogs/create_album_dialog.dart';
|
import 'package:aves/widgets/dialogs/filter_editors/create_album_dialog.dart';
|
||||||
import 'package:aves/widgets/dialogs/rename_album_dialog.dart';
|
import 'package:aves/widgets/dialogs/filter_editors/rename_album_dialog.dart';
|
||||||
import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart';
|
import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
|
@ -15,7 +15,7 @@ import 'package:aves/widgets/common/action_mixins/size_aware.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||||
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
|
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
|
||||||
import 'package:aves/widgets/dialogs/cover_selection_dialog.dart';
|
import 'package:aves/widgets/dialogs/filter_editors/cover_selection_dialog.dart';
|
||||||
import 'package:aves/widgets/map/map_page.dart';
|
import 'package:aves/widgets/map/map_page.dart';
|
||||||
import 'package:aves/widgets/search/search_delegate.dart';
|
import 'package:aves/widgets/search/search_delegate.dart';
|
||||||
import 'package:aves/widgets/stats/stats_page.dart';
|
import 'package:aves/widgets/stats/stats_page.dart';
|
||||||
|
|
|
@ -136,7 +136,7 @@ class _HomePageState extends State<HomePage> {
|
||||||
final entry = await mediaFileService.getEntry(uri, mimeType);
|
final entry = await mediaFileService.getEntry(uri, mimeType);
|
||||||
if (entry != null) {
|
if (entry != null) {
|
||||||
// cataloguing is essential for coordinates and video rotation
|
// cataloguing is essential for coordinates and video rotation
|
||||||
await entry.catalog(background: false, persist: false, force: false);
|
await entry.catalog(background: false, force: false, persist: false);
|
||||||
}
|
}
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,16 +9,20 @@ class ExpandableFilterRow extends StatelessWidget {
|
||||||
final String? title;
|
final String? title;
|
||||||
final Iterable<CollectionFilter> filters;
|
final Iterable<CollectionFilter> filters;
|
||||||
final ValueNotifier<String?> expandedNotifier;
|
final ValueNotifier<String?> expandedNotifier;
|
||||||
|
final bool showGenericIcon;
|
||||||
final HeroType Function(CollectionFilter filter)? heroTypeBuilder;
|
final HeroType Function(CollectionFilter filter)? heroTypeBuilder;
|
||||||
final FilterCallback onTap;
|
final FilterCallback onTap;
|
||||||
|
final OffsetFilterCallback? onLongPress;
|
||||||
|
|
||||||
const ExpandableFilterRow({
|
const ExpandableFilterRow({
|
||||||
Key? key,
|
Key? key,
|
||||||
this.title,
|
this.title,
|
||||||
required this.filters,
|
required this.filters,
|
||||||
required this.expandedNotifier,
|
required this.expandedNotifier,
|
||||||
|
this.showGenericIcon = true,
|
||||||
this.heroTypeBuilder,
|
this.heroTypeBuilder,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
|
required this.onLongPress,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
static const double horizontalPadding = 8;
|
static const double horizontalPadding = 8;
|
||||||
|
@ -109,8 +113,10 @@ class ExpandableFilterRow extends StatelessWidget {
|
||||||
// key `album-{path}` is expected by test driver
|
// key `album-{path}` is expected by test driver
|
||||||
key: Key(filter.key),
|
key: Key(filter.key),
|
||||||
filter: filter,
|
filter: filter,
|
||||||
|
showGenericIcon: showGenericIcon,
|
||||||
heroType: heroTypeBuilder?.call(filter) ?? HeroType.onTap,
|
heroType: heroTypeBuilder?.call(filter) ?? HeroType.onTap,
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
|
onLongPress: onLongPress,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,10 +27,10 @@ import 'package:provider/provider.dart';
|
||||||
class CollectionSearchDelegate {
|
class CollectionSearchDelegate {
|
||||||
final CollectionSource source;
|
final CollectionSource source;
|
||||||
final CollectionLens? parentCollection;
|
final CollectionLens? parentCollection;
|
||||||
final ValueNotifier<String?> expandedSectionNotifier = ValueNotifier(null);
|
final ValueNotifier<String?> _expandedSectionNotifier = ValueNotifier(null);
|
||||||
final bool canPop;
|
final bool canPop;
|
||||||
|
|
||||||
static const searchHistoryCount = 10;
|
static const int searchHistoryCount = 10;
|
||||||
static final typeFilters = [
|
static final typeFilters = [
|
||||||
FavouriteFilter.instance,
|
FavouriteFilter.instance,
|
||||||
MimeFilter.image,
|
MimeFilter.image,
|
||||||
|
@ -90,7 +90,7 @@ class CollectionSearchDelegate {
|
||||||
bool containQuery(String s) => s.toUpperCase().contains(upQuery);
|
bool containQuery(String s) => s.toUpperCase().contains(upQuery);
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
child: ValueListenableBuilder<String?>(
|
child: ValueListenableBuilder<String?>(
|
||||||
valueListenable: expandedSectionNotifier,
|
valueListenable: _expandedSectionNotifier,
|
||||||
builder: (context, expandedSection, child) {
|
builder: (context, expandedSection, child) {
|
||||||
final queryFilter = _buildQueryFilter(false);
|
final queryFilter = _buildQueryFilter(false);
|
||||||
return Selector<Settings, Set<CollectionFilter>>(
|
return Selector<Settings, Set<CollectionFilter>>(
|
||||||
|
@ -195,9 +195,10 @@ class CollectionSearchDelegate {
|
||||||
return ExpandableFilterRow(
|
return ExpandableFilterRow(
|
||||||
title: title,
|
title: title,
|
||||||
filters: filters,
|
filters: filters,
|
||||||
expandedNotifier: expandedSectionNotifier,
|
expandedNotifier: _expandedSectionNotifier,
|
||||||
heroTypeBuilder: heroTypeBuilder,
|
heroTypeBuilder: heroTypeBuilder,
|
||||||
onTap: (filter) => _select(context, filter is QueryFilter ? QueryFilter(filter.query) : filter),
|
onTap: (filter) => _select(context, filter is QueryFilter ? QueryFilter(filter.query) : filter),
|
||||||
|
onLongPress: AvesFilterChip.showDefaultLongPressMenu,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -56,7 +56,7 @@ class StatsPage extends StatelessWidget {
|
||||||
entryCountPerPlace[place] = (entryCountPerPlace[place] ?? 0) + 1;
|
entryCountPerPlace[place] = (entryCountPerPlace[place] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
entry.xmpSubjects.forEach((tag) {
|
entry.tags.forEach((tag) {
|
||||||
entryCountPerTag[tag] = (entryCountPerTag[tag] ?? 0) + 1;
|
entryCountPerTag[tag] = (entryCountPerTag[tag] ?? 0) + 1;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -112,9 +112,10 @@ class ViewerDebugPage extends StatelessWidget {
|
||||||
'isGeotiff': '${entry.isGeotiff}',
|
'isGeotiff': '${entry.isGeotiff}',
|
||||||
'is360': '${entry.is360}',
|
'is360': '${entry.is360}',
|
||||||
'canEdit': '${entry.canEdit}',
|
'canEdit': '${entry.canEdit}',
|
||||||
'canEditExif': '${entry.canEditExif}',
|
'canEditDate': '${entry.canEditDate}',
|
||||||
|
'canEditTags': '${entry.canEditTags}',
|
||||||
'canRotateAndFlip': '${entry.canRotateAndFlip}',
|
'canRotateAndFlip': '${entry.canRotateAndFlip}',
|
||||||
'xmpSubjects': '${entry.xmpSubjects}',
|
'tags': '${entry.tags}',
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
|
|
|
@ -20,8 +20,8 @@ import 'package:aves/widgets/common/action_mixins/size_aware.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart';
|
import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart';
|
||||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||||
|
import 'package:aves/widgets/dialogs/entry_editors/rename_entry_dialog.dart';
|
||||||
import 'package:aves/widgets/dialogs/export_entry_dialog.dart';
|
import 'package:aves/widgets/dialogs/export_entry_dialog.dart';
|
||||||
import 'package:aves/widgets/dialogs/rename_entry_dialog.dart';
|
|
||||||
import 'package:aves/widgets/filter_grids/album_pick.dart';
|
import 'package:aves/widgets/filter_grids/album_pick.dart';
|
||||||
import 'package:aves/widgets/viewer/debug/debug_page.dart';
|
import 'package:aves/widgets/viewer/debug/debug_page.dart';
|
||||||
import 'package:aves/widgets/viewer/info/notifications.dart';
|
import 'package:aves/widgets/viewer/info/notifications.dart';
|
||||||
|
@ -130,15 +130,15 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
||||||
Future<void> _flip(BuildContext context, AvesEntry entry) async {
|
Future<void> _flip(BuildContext context, AvesEntry entry) async {
|
||||||
if (!await checkStoragePermission(context, {entry})) return;
|
if (!await checkStoragePermission(context, {entry})) return;
|
||||||
|
|
||||||
final success = await entry.flip(persist: _isMainMode(context));
|
final dataTypes = await entry.flip(persist: _isMainMode(context));
|
||||||
if (!success) showFeedback(context, context.l10n.genericFailureFeedback);
|
if (dataTypes.isEmpty) showFeedback(context, context.l10n.genericFailureFeedback);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _rotate(BuildContext context, AvesEntry entry, {required bool clockwise}) async {
|
Future<void> _rotate(BuildContext context, AvesEntry entry, {required bool clockwise}) async {
|
||||||
if (!await checkStoragePermission(context, {entry})) return;
|
if (!await checkStoragePermission(context, {entry})) return;
|
||||||
|
|
||||||
final success = await entry.rotate(clockwise: clockwise, persist: _isMainMode(context));
|
final dataTypes = await entry.rotate(clockwise: clockwise, persist: _isMainMode(context));
|
||||||
if (!success) showFeedback(context, context.l10n.genericFailureFeedback);
|
if (dataTypes.isEmpty) showFeedback(context, context.l10n.genericFailureFeedback);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _rotateScreen(BuildContext context) async {
|
Future<void> _rotateScreen(BuildContext context) async {
|
||||||
|
|
|
@ -169,7 +169,7 @@ class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
|
||||||
// make sure to locate the entry,
|
// make sure to locate the entry,
|
||||||
// so that we can display the address instead of coordinates
|
// so that we can display the address instead of coordinates
|
||||||
// even when initial collection locating has not reached this entry yet
|
// even when initial collection locating has not reached this entry yet
|
||||||
await _entry.catalog(background: false, persist: true, force: false);
|
await _entry.catalog(background: false, force: false, persist: true);
|
||||||
await _entry.locate(background: false, force: false, geocoderLocale: settings.appliedLocale);
|
await _entry.locate(background: false, force: false, geocoderLocale: settings.appliedLocale);
|
||||||
} else {
|
} else {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:aves/image_providers/app_icon_image_provider.dart';
|
import 'package:aves/image_providers/app_icon_image_provider.dart';
|
||||||
|
import 'package:aves/model/actions/entry_info_actions.dart';
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/favourites.dart';
|
import 'package:aves/model/favourites.dart';
|
||||||
import 'package:aves/model/filters/album.dart';
|
import 'package:aves/model/filters/album.dart';
|
||||||
|
@ -9,11 +10,13 @@ import 'package:aves/model/filters/type.dart';
|
||||||
import 'package:aves/model/source/collection_lens.dart';
|
import 'package:aves/model/source/collection_lens.dart';
|
||||||
import 'package:aves/services/common/services.dart';
|
import 'package:aves/services/common/services.dart';
|
||||||
import 'package:aves/theme/format.dart';
|
import 'package:aves/theme/format.dart';
|
||||||
|
import 'package:aves/theme/icons.dart';
|
||||||
import 'package:aves/utils/android_file_utils.dart';
|
import 'package:aves/utils/android_file_utils.dart';
|
||||||
import 'package:aves/utils/file_utils.dart';
|
import 'package:aves/utils/file_utils.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
|
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
|
||||||
import 'package:aves/widgets/viewer/info/common.dart';
|
import 'package:aves/widgets/viewer/info/common.dart';
|
||||||
|
import 'package:aves/widgets/viewer/info/entry_info_action_delegate.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
@ -22,12 +25,16 @@ import 'package:provider/provider.dart';
|
||||||
class BasicSection extends StatelessWidget {
|
class BasicSection extends StatelessWidget {
|
||||||
final AvesEntry entry;
|
final AvesEntry entry;
|
||||||
final CollectionLens? collection;
|
final CollectionLens? collection;
|
||||||
|
final EntryInfoActionDelegate actionDelegate;
|
||||||
|
final ValueNotifier<bool> isEditingTagNotifier;
|
||||||
final FilterCallback onFilter;
|
final FilterCallback onFilter;
|
||||||
|
|
||||||
const BasicSection({
|
const BasicSection({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.entry,
|
required this.entry,
|
||||||
this.collection,
|
this.collection,
|
||||||
|
required this.actionDelegate,
|
||||||
|
required this.isEditingTagNotifier,
|
||||||
required this.onFilter,
|
required this.onFilter,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@ -80,7 +87,7 @@ class BasicSection extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildChips(BuildContext context) {
|
Widget _buildChips(BuildContext context) {
|
||||||
final tags = entry.xmpSubjects..sort(compareAsciiUpperCase);
|
final tags = entry.tags.toList()..sort(compareAsciiUpperCase);
|
||||||
final album = entry.directory;
|
final album = entry.directory;
|
||||||
final filters = {
|
final filters = {
|
||||||
MimeFilter(entry.mimeType),
|
MimeFilter(entry.mimeType),
|
||||||
|
@ -101,19 +108,60 @@ class BasicSection extends StatelessWidget {
|
||||||
...filters,
|
...filters,
|
||||||
if (entry.isFavourite) FavouriteFilter.instance,
|
if (entry.isFavourite) FavouriteFilter.instance,
|
||||||
]..sort();
|
]..sort();
|
||||||
if (effectiveFilters.isEmpty) return const SizedBox.shrink();
|
|
||||||
return Padding(
|
final children = <Widget>[
|
||||||
padding: const EdgeInsets.symmetric(horizontal: AvesFilterChip.outlineWidth / 2) + const EdgeInsets.only(top: 8),
|
...effectiveFilters.map((filter) => AvesFilterChip(
|
||||||
child: Wrap(
|
filter: filter,
|
||||||
spacing: 8,
|
onTap: onFilter,
|
||||||
runSpacing: 8,
|
)),
|
||||||
children: effectiveFilters
|
if (actionDelegate.canApply(EntryInfoAction.editTags)) _buildEditTagButton(context),
|
||||||
.map((filter) => AvesFilterChip(
|
];
|
||||||
filter: filter,
|
|
||||||
onTap: onFilter,
|
return children.isEmpty
|
||||||
))
|
? const SizedBox()
|
||||||
.toList(),
|
: Padding(
|
||||||
),
|
padding: const EdgeInsets.symmetric(horizontal: AvesFilterChip.outlineWidth / 2) + const EdgeInsets.only(top: 8),
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: children,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEditTagButton(BuildContext context) {
|
||||||
|
const action = EntryInfoAction.editTags;
|
||||||
|
return ValueListenableBuilder<bool>(
|
||||||
|
valueListenable: isEditingTagNotifier,
|
||||||
|
builder: (context, isEditing, child) {
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
DecoratedBox(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
border: Border.fromBorderSide(BorderSide(
|
||||||
|
color: AvesFilterChip.defaultOutlineColor,
|
||||||
|
width: AvesFilterChip.outlineWidth,
|
||||||
|
)),
|
||||||
|
borderRadius: BorderRadius.all(Radius.circular(AvesFilterChip.defaultRadius)),
|
||||||
|
),
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(AIcons.addTag),
|
||||||
|
onPressed: isEditing ? null : () => actionDelegate.onActionSelected(context, action),
|
||||||
|
tooltip: action.getText(context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isEditing)
|
||||||
|
const Positioned.fill(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(1.0),
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: AvesFilterChip.outlineWidth,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,8 +1,13 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:aves/app_mode.dart';
|
import 'package:aves/app_mode.dart';
|
||||||
import 'package:aves/model/actions/entry_info_actions.dart';
|
import 'package:aves/model/actions/entry_info_actions.dart';
|
||||||
|
import 'package:aves/model/actions/events.dart';
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
|
import 'package:aves/model/entry_xmp_iptc.dart';
|
||||||
import 'package:aves/model/settings/settings.dart';
|
import 'package:aves/model/settings/settings.dart';
|
||||||
import 'package:aves/model/source/collection_source.dart';
|
import 'package:aves/model/source/collection_source.dart';
|
||||||
|
import 'package:aves/services/common/services.dart';
|
||||||
import 'package:aves/widgets/common/action_mixins/entry_editor.dart';
|
import 'package:aves/widgets/common/action_mixins/entry_editor.dart';
|
||||||
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
||||||
import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
|
import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
|
||||||
|
@ -14,12 +19,17 @@ import 'package:provider/provider.dart';
|
||||||
class EntryInfoActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwareMixin {
|
class EntryInfoActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwareMixin {
|
||||||
final AvesEntry entry;
|
final AvesEntry entry;
|
||||||
|
|
||||||
const EntryInfoActionDelegate(this.entry);
|
final StreamController<ActionEvent<EntryInfoAction>> _eventStreamController = StreamController<ActionEvent<EntryInfoAction>>.broadcast();
|
||||||
|
|
||||||
|
Stream<ActionEvent<EntryInfoAction>> get eventStream => _eventStreamController.stream;
|
||||||
|
|
||||||
|
EntryInfoActionDelegate(this.entry);
|
||||||
|
|
||||||
bool isVisible(EntryInfoAction action) {
|
bool isVisible(EntryInfoAction action) {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
// general
|
// general
|
||||||
case EntryInfoAction.editDate:
|
case EntryInfoAction.editDate:
|
||||||
|
case EntryInfoAction.editTags:
|
||||||
case EntryInfoAction.removeMetadata:
|
case EntryInfoAction.removeMetadata:
|
||||||
return true;
|
return true;
|
||||||
// motion photo
|
// motion photo
|
||||||
|
@ -32,7 +42,9 @@ class EntryInfoActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAw
|
||||||
switch (action) {
|
switch (action) {
|
||||||
// general
|
// general
|
||||||
case EntryInfoAction.editDate:
|
case EntryInfoAction.editDate:
|
||||||
return entry.canEditExif;
|
return entry.canEditDate;
|
||||||
|
case EntryInfoAction.editTags:
|
||||||
|
return entry.canEditTags;
|
||||||
case EntryInfoAction.removeMetadata:
|
case EntryInfoAction.removeMetadata:
|
||||||
return entry.canRemoveMetadata;
|
return entry.canRemoveMetadata;
|
||||||
// motion photo
|
// motion photo
|
||||||
|
@ -42,11 +54,15 @@ class EntryInfoActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAw
|
||||||
}
|
}
|
||||||
|
|
||||||
void onActionSelected(BuildContext context, EntryInfoAction action) async {
|
void onActionSelected(BuildContext context, EntryInfoAction action) async {
|
||||||
|
_eventStreamController.add(ActionStartedEvent(action));
|
||||||
switch (action) {
|
switch (action) {
|
||||||
// general
|
// general
|
||||||
case EntryInfoAction.editDate:
|
case EntryInfoAction.editDate:
|
||||||
await _editDate(context);
|
await _editDate(context);
|
||||||
break;
|
break;
|
||||||
|
case EntryInfoAction.editTags:
|
||||||
|
await _editTags(context);
|
||||||
|
break;
|
||||||
case EntryInfoAction.removeMetadata:
|
case EntryInfoAction.removeMetadata:
|
||||||
await _removeMetadata(context);
|
await _removeMetadata(context);
|
||||||
break;
|
break;
|
||||||
|
@ -55,26 +71,37 @@ class EntryInfoActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAw
|
||||||
OpenEmbeddedDataNotification.motionPhotoVideo().dispatch(context);
|
OpenEmbeddedDataNotification.motionPhotoVideo().dispatch(context);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
_eventStreamController.add(ActionEndedEvent(action));
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _isMainMode(BuildContext context) => context.read<ValueNotifier<AppMode>>().value == AppMode.main;
|
bool _isMainMode(BuildContext context) => context.read<ValueNotifier<AppMode>>().value == AppMode.main;
|
||||||
|
|
||||||
Future<void> _edit(BuildContext context, Future<bool> Function() apply) async {
|
Future<void> _edit(BuildContext context, Future<Set<EntryDataType>> Function() apply) async {
|
||||||
if (!await checkStoragePermission(context, {entry})) return;
|
if (!await checkStoragePermission(context, {entry})) return;
|
||||||
|
|
||||||
|
// check before applying, because it relies on provider
|
||||||
|
// but the widget tree may be disposed if the user navigated away
|
||||||
|
final isMainMode = _isMainMode(context);
|
||||||
|
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
final source = context.read<CollectionSource?>();
|
final source = context.read<CollectionSource?>();
|
||||||
source?.pauseMonitoring();
|
source?.pauseMonitoring();
|
||||||
final success = await apply();
|
|
||||||
if (success) {
|
final dataTypes = await apply();
|
||||||
if (_isMainMode(context) && source != null) {
|
final success = dataTypes.isNotEmpty;
|
||||||
await source.refreshEntry(entry);
|
try {
|
||||||
|
if (success) {
|
||||||
|
if (isMainMode && source != null) {
|
||||||
|
await source.refreshEntry(entry, dataTypes);
|
||||||
|
} else {
|
||||||
|
await entry.refresh(background: false, persist: false, dataTypes: dataTypes, geocoderLocale: settings.appliedLocale);
|
||||||
|
}
|
||||||
|
showFeedback(context, l10n.genericSuccessFeedback);
|
||||||
} else {
|
} else {
|
||||||
await entry.refresh(background: false, persist: false, force: true, geocoderLocale: settings.appliedLocale);
|
showFeedback(context, l10n.genericFailureFeedback);
|
||||||
}
|
}
|
||||||
showFeedback(context, l10n.genericSuccessFeedback);
|
} catch (e, stack) {
|
||||||
} else {
|
await reportService.recordError(e, stack);
|
||||||
showFeedback(context, l10n.genericFailureFeedback);
|
|
||||||
}
|
}
|
||||||
source?.resumeMonitoring();
|
source?.resumeMonitoring();
|
||||||
}
|
}
|
||||||
|
@ -86,6 +113,17 @@ class EntryInfoActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAw
|
||||||
await _edit(context, () => entry.editDate(modifier));
|
await _edit(context, () => entry.editDate(modifier));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _editTags(BuildContext context) async {
|
||||||
|
final newTagsByEntry = await selectTags(context, {entry});
|
||||||
|
if (newTagsByEntry == null) return;
|
||||||
|
|
||||||
|
final newTags = newTagsByEntry[entry] ?? entry.tags;
|
||||||
|
final currentTags = entry.tags;
|
||||||
|
if (newTags.length == currentTags.length && newTags.every(currentTags.contains)) return;
|
||||||
|
|
||||||
|
await _edit(context, () => entry.editTags(newTags));
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _removeMetadata(BuildContext context) async {
|
Future<void> _removeMetadata(BuildContext context) async {
|
||||||
final types = await selectMetadataToRemove(context, {entry});
|
final types = await selectMetadataToRemove(context, {entry});
|
||||||
if (types == null) return;
|
if (types == null) return;
|
||||||
|
|
|
@ -13,19 +13,20 @@ import 'package:flutter/scheduler.dart';
|
||||||
|
|
||||||
class InfoAppBar extends StatelessWidget {
|
class InfoAppBar extends StatelessWidget {
|
||||||
final AvesEntry entry;
|
final AvesEntry entry;
|
||||||
|
final EntryInfoActionDelegate actionDelegate;
|
||||||
final ValueNotifier<Map<String, MetadataDirectory>> metadataNotifier;
|
final ValueNotifier<Map<String, MetadataDirectory>> metadataNotifier;
|
||||||
final VoidCallback onBackPressed;
|
final VoidCallback onBackPressed;
|
||||||
|
|
||||||
const InfoAppBar({
|
const InfoAppBar({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.entry,
|
required this.entry,
|
||||||
|
required this.actionDelegate,
|
||||||
required this.metadataNotifier,
|
required this.metadataNotifier,
|
||||||
required this.onBackPressed,
|
required this.onBackPressed,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final actionDelegate = EntryInfoActionDelegate(entry);
|
|
||||||
final menuActions = EntryInfoActions.all.where(actionDelegate.isVisible);
|
final menuActions = EntryInfoActions.all.where(actionDelegate.isVisible);
|
||||||
|
|
||||||
return SliverAppBar(
|
return SliverAppBar(
|
||||||
|
|
|
@ -1,3 +1,7 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:aves/model/actions/entry_info_actions.dart';
|
||||||
|
import 'package:aves/model/actions/events.dart';
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/filters/filters.dart';
|
import 'package:aves/model/filters/filters.dart';
|
||||||
import 'package:aves/model/source/collection_lens.dart';
|
import 'package:aves/model/source/collection_lens.dart';
|
||||||
|
@ -6,6 +10,7 @@ import 'package:aves/widgets/common/basic/insets.dart';
|
||||||
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||||
import 'package:aves/widgets/viewer/embedded/embedded_data_opener.dart';
|
import 'package:aves/widgets/viewer/embedded/embedded_data_opener.dart';
|
||||||
import 'package:aves/widgets/viewer/info/basic_section.dart';
|
import 'package:aves/widgets/viewer/info/basic_section.dart';
|
||||||
|
import 'package:aves/widgets/viewer/info/entry_info_action_delegate.dart';
|
||||||
import 'package:aves/widgets/viewer/info/info_app_bar.dart';
|
import 'package:aves/widgets/viewer/info/info_app_bar.dart';
|
||||||
import 'package:aves/widgets/viewer/info/location_section.dart';
|
import 'package:aves/widgets/viewer/info/location_section.dart';
|
||||||
import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart';
|
import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart';
|
||||||
|
@ -142,19 +147,57 @@ class _InfoPageContent extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _InfoPageContentState extends State<_InfoPageContent> {
|
class _InfoPageContentState extends State<_InfoPageContent> {
|
||||||
static const horizontalPadding = EdgeInsets.symmetric(horizontal: 8);
|
final List<StreamSubscription> _subscriptions = [];
|
||||||
|
late EntryInfoActionDelegate _actionDelegate;
|
||||||
final ValueNotifier<Map<String, MetadataDirectory>> _metadataNotifier = ValueNotifier({});
|
final ValueNotifier<Map<String, MetadataDirectory>> _metadataNotifier = ValueNotifier({});
|
||||||
|
final ValueNotifier<bool> _isEditingTagNotifier = ValueNotifier(false);
|
||||||
|
|
||||||
|
static const horizontalPadding = EdgeInsets.symmetric(horizontal: 8);
|
||||||
|
|
||||||
CollectionLens? get collection => widget.collection;
|
CollectionLens? get collection => widget.collection;
|
||||||
|
|
||||||
AvesEntry get entry => widget.entry;
|
AvesEntry get entry => widget.entry;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_registerWidget(widget);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant _InfoPageContent oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
|
||||||
|
if (oldWidget.entry != widget.entry) {
|
||||||
|
_unregisterWidget(oldWidget);
|
||||||
|
_registerWidget(widget);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_unregisterWidget(widget);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _registerWidget(_InfoPageContent widget) {
|
||||||
|
_actionDelegate = EntryInfoActionDelegate(widget.entry);
|
||||||
|
_subscriptions.add(_actionDelegate.eventStream.listen(_onActionDelegateEvent));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _unregisterWidget(_InfoPageContent widget) {
|
||||||
|
_subscriptions
|
||||||
|
..forEach((sub) => sub.cancel())
|
||||||
|
..clear();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final basicSection = BasicSection(
|
final basicSection = BasicSection(
|
||||||
entry: entry,
|
entry: entry,
|
||||||
collection: collection,
|
collection: collection,
|
||||||
|
actionDelegate: _actionDelegate,
|
||||||
|
isEditingTagNotifier: _isEditingTagNotifier,
|
||||||
onFilter: _goToCollection,
|
onFilter: _goToCollection,
|
||||||
);
|
);
|
||||||
final locationAtTop = widget.split && entry.hasGps;
|
final locationAtTop = widget.split && entry.hasGps;
|
||||||
|
@ -194,6 +237,7 @@ class _InfoPageContentState extends State<_InfoPageContent> {
|
||||||
slivers: [
|
slivers: [
|
||||||
InfoAppBar(
|
InfoAppBar(
|
||||||
entry: entry,
|
entry: entry,
|
||||||
|
actionDelegate: _actionDelegate,
|
||||||
metadataNotifier: _metadataNotifier,
|
metadataNotifier: _metadataNotifier,
|
||||||
onBackPressed: widget.goToViewer,
|
onBackPressed: widget.goToViewer,
|
||||||
),
|
),
|
||||||
|
@ -210,6 +254,18 @@ class _InfoPageContentState extends State<_InfoPageContent> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onActionDelegateEvent(ActionEvent<EntryInfoAction> event) {
|
||||||
|
if (event.action == EntryInfoAction.editTags) {
|
||||||
|
Future.delayed(Durations.dialogTransitionAnimation).then((_) {
|
||||||
|
if (event is ActionStartedEvent) {
|
||||||
|
_isEditingTagNotifier.value = true;
|
||||||
|
} else if (event is ActionEndedEvent) {
|
||||||
|
_isEditingTagNotifier.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _goToCollection(CollectionFilter filter) {
|
void _goToCollection(CollectionFilter filter) {
|
||||||
if (collection == null) return;
|
if (collection == null) return;
|
||||||
FilterSelectedNotification(filter).dispatch(context);
|
FilterSelectedNotification(filter).dispatch(context);
|
||||||
|
|
|
@ -13,7 +13,7 @@ class FakeMetadataDb extends Fake implements MetadataDb {
|
||||||
Future<void> init() => SynchronousFuture(null);
|
Future<void> init() => SynchronousFuture(null);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> removeIds(Set<int> contentIds, {required bool metadataOnly}) => SynchronousFuture(null);
|
Future<void> removeIds(Set<int> contentIds, {Set<EntryDataType>? dataTypes}) => SynchronousFuture(null);
|
||||||
|
|
||||||
// entries
|
// entries
|
||||||
|
|
||||||
|
|
|
@ -125,10 +125,10 @@ void main() {
|
||||||
longitude: australiaLatLng.longitude,
|
longitude: australiaLatLng.longitude,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(image1.xmpSubjects, []);
|
expect(image1.tags, <String>{});
|
||||||
|
|
||||||
final source = await _initSource();
|
final source = await _initSource();
|
||||||
expect(image1.xmpSubjects, [aTag]);
|
expect(image1.tags, {aTag});
|
||||||
expect(image1.addressDetails, australiaAddress.copyWith(contentId: image1.contentId));
|
expect(image1.addressDetails, australiaAddress.copyWith(contentId: image1.contentId));
|
||||||
|
|
||||||
expect(source.visibleEntries.length, 0);
|
expect(source.visibleEntries.length, 0);
|
||||||
|
|
|
@ -1 +1,17 @@
|
||||||
{}
|
{
|
||||||
|
"ko": [
|
||||||
|
"resetButtonTooltip",
|
||||||
|
"entryInfoActionEditTags",
|
||||||
|
"tagEditorPageTitle",
|
||||||
|
"tagEditorPageNewTagFieldLabel",
|
||||||
|
"tagEditorPageAddTagTooltip"
|
||||||
|
],
|
||||||
|
|
||||||
|
"ru": [
|
||||||
|
"resetButtonTooltip",
|
||||||
|
"entryInfoActionEditTags",
|
||||||
|
"tagEditorPageTitle",
|
||||||
|
"tagEditorPageNewTagFieldLabel",
|
||||||
|
"tagEditorPageAddTagTooltip"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue