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

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

View file

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

View file

@ -20,6 +20,8 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
"rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) }
"flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) }
"editDate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::editDate) }
"setIptc" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::setIptc) }
"setXmp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::setXmp) }
"removeTypes" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::removeTypes) }
else -> result.notImplemented()
}
@ -97,6 +99,64 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
})
}
private fun setIptc(call: MethodCall, result: MethodChannel.Result) {
val iptc = call.argument<List<FieldMap>>("iptc")
val entryMap = call.argument<FieldMap>("entry")
val postEditScan = call.argument<Boolean>("postEditScan")
if (entryMap == null || postEditScan == null) {
result.error("setIptc-args", "failed because of missing arguments", null)
return
}
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) }
val path = entryMap["path"] as String?
val mimeType = entryMap["mimeType"] as String?
if (uri == null || path == null || mimeType == null) {
result.error("setIptc-args", "failed because entry fields are missing", null)
return
}
val provider = getProvider(uri)
if (provider == null) {
result.error("setIptc-provider", "failed to find provider for uri=$uri", null)
return
}
provider.setIptc(activity, path, uri, mimeType, postEditScan, iptc = iptc, callback = object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("setIptc-failure", "failed to set IPTC for mimeType=$mimeType uri=$uri", throwable.message)
})
}
private fun setXmp(call: MethodCall, result: MethodChannel.Result) {
val xmp = call.argument<String>("xmp")
val extendedXmp = call.argument<String>("extendedXmp")
val entryMap = call.argument<FieldMap>("entry")
if (entryMap == null) {
result.error("setXmp-args", "failed because of missing arguments", null)
return
}
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) }
val path = entryMap["path"] as String?
val mimeType = entryMap["mimeType"] as String?
if (uri == null || path == null || mimeType == null) {
result.error("setXmp-args", "failed because entry fields are missing", null)
return
}
val provider = getProvider(uri)
if (provider == null) {
result.error("setXmp-provider", "failed to find provider for uri=$uri", null)
return
}
provider.setXmp(activity, path, uri, mimeType, coreXmp = xmp, extendedXmp = extendedXmp, callback = object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("setXmp-failure", "failed to set XMP for mimeType=$mimeType uri=$uri", throwable.message)
})
}
private fun removeTypes(call: MethodCall, result: MethodChannel.Result) {
val types = call.argument<List<String>>("types")
val entryMap = call.argument<FieldMap>("entry")

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

@ -1,6 +1,7 @@
import 'dart:async';
import 'package:aves/model/entry.dart';
import 'package:aves/model/entry_xmp_iptc.dart';
import 'package:aves/model/metadata/date_modifier.dart';
import 'package:aves/model/metadata/enums.dart';
import 'package:aves/services/common/services.dart';
@ -13,6 +14,10 @@ abstract class MetadataEditService {
Future<Map<String, dynamic>> editDate(AvesEntry entry, DateModifier modifier);
Future<Map<String, dynamic>> setIptc(AvesEntry entry, List<Map<String, dynamic>>? iptc, {required bool postEditScan});
Future<Map<String, dynamic>> setXmp(AvesEntry entry, AvesXmp? xmp);
Future<Map<String, dynamic>> removeTypes(AvesEntry entry, Set<MetadataType> types);
}
@ -85,6 +90,40 @@ class PlatformMetadataEditService implements MetadataEditService {
return {};
}
@override
Future<Map<String, dynamic>> setIptc(AvesEntry entry, List<Map<String, dynamic>>? iptc, {required bool postEditScan}) async {
try {
final result = await platform.invokeMethod('setIptc', <String, dynamic>{
'entry': _toPlatformEntryMap(entry),
'iptc': iptc,
'postEditScan': postEditScan,
});
if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e, stack) {
if (!entry.isMissingAtPath) {
await reportService.recordError(e, stack);
}
}
return {};
}
@override
Future<Map<String, dynamic>> setXmp(AvesEntry entry, AvesXmp? xmp) async {
try {
final result = await platform.invokeMethod('setXmp', <String, dynamic>{
'entry': _toPlatformEntryMap(entry),
'xmp': xmp?.xmpString,
'extendedXmp': xmp?.extendedXmpString,
});
if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e, stack) {
if (!entry.isMissingAtPath) {
await reportService.recordError(e, stack);
}
}
return {};
}
@override
Future<Map<String, dynamic>> removeTypes(AvesEntry entry, Set<MetadataType> types) async {
try {
@ -116,6 +155,8 @@ class PlatformMetadataEditService implements MetadataEditService {
String _toPlatformMetadataType(MetadataType type) {
switch (type) {
case MetadataType.comment:
return 'comment';
case MetadataType.exif:
return 'exif';
case MetadataType.iccProfile:
@ -126,8 +167,6 @@ class PlatformMetadataEditService implements MetadataEditService {
return 'jfif';
case MetadataType.jpegAdobe:
return 'jpeg_adobe';
case MetadataType.jpegComment:
return 'jpeg_comment';
case MetadataType.jpegDucky:
return 'jpeg_ducky';
case MetadataType.photoshopIrb:

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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