diff --git a/android/app/build.gradle b/android/app/build.gradle index bccdc8a54..69f477b13 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -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' diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataEditHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataEditHandler.kt index 6ff4ef4b4..9040083d5 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataEditHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataEditHandler.kt @@ -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>("iptc") + val entryMap = call.argument("entry") + val postEditScan = call.argument("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("xmp") + val extendedXmp = call.argument("extendedXmp") + val entryMap = call.argument("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>("types") val entryMap = call.argument("entry") diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt index 3fbbb707f..daf8e908e 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt @@ -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("mimeType") + val uri = call.argument("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("mimeType") + val uri = call.argument("uri")?.let { Uri.parse(it) } + val sizeBytes = call.argument("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("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. ... + omitXmpMetaElement = false // e.g. ... + } + // catalog metadata private const val KEY_MIME_TYPE = "mimeType" private const val KEY_DATE_MILLIS = "dateMillis" diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt index 31d6adf4c..8e86a124e 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt @@ -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" diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/PixyMetaHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/PixyMetaHelper.kt index fb4df2a92..eee140f28 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/PixyMetaHelper.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/PixyMetaHelper.kt @@ -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? { + val iptc = Metadata.readMetadata(input)[MetadataType.IPTC] as IPTC? ?: return null + + val iptcDataList = ArrayList() + 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?, + ) { + 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() + 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 diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt index e70aa3437..e74547232 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt @@ -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?, + ): 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? = null, + ) { + val newFields = HashMap() + + 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() + + 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, diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt index 6416286b2..5e686fa9f 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt @@ -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) { diff --git a/android/build.gradle b/android/build.gradle index a66954e85..ceb368710 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -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() diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index fd0091b18..b2470baba 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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", diff --git a/lib/model/actions/entry_info_actions.dart b/lib/model/actions/entry_info_actions.dart index f0c6c7805..901133187 100644 --- a/lib/model/actions/entry_info_actions.dart +++ b/lib/model/actions/entry_info_actions.dart @@ -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 diff --git a/lib/model/actions/entry_set_actions.dart b/lib/model/actions/entry_set_actions.dart index f85e69d6f..0db4efa6e 100644 --- a/lib/model/actions/entry_set_actions.dart +++ b/lib/model/actions/entry_set_actions.dart @@ -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; } diff --git a/lib/model/actions/events.dart b/lib/model/actions/events.dart new file mode 100644 index 000000000..248e9dd88 --- /dev/null +++ b/lib/model/actions/events.dart @@ -0,0 +1,18 @@ +import 'package:flutter/foundation.dart'; + +@immutable +class ActionEvent { + final T action; + + const ActionEvent(this.action); +} + +@immutable +class ActionStartedEvent extends ActionEvent { + const ActionStartedEvent(T action) : super(action); +} + +@immutable +class ActionEndedEvent extends ActionEvent { + const ActionEndedEvent(T action) : super(action); +} diff --git a/lib/model/entry.dart b/lib/model/entry.dart index 6357ae9a7..ad8b5438d 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -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? _xmpSubjects; + Set? _tags; - List get xmpSubjects { - _xmpSubjects ??= _catalogMetadata?.xmpSubjects?.split(';').where((tag) => tag.isNotEmpty).toList() ?? []; - return _xmpSubjects!; + Set 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 catalog({required bool background, required bool persist, required bool force}) async { + Future 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 refresh({required bool background, required bool persist, required bool force, required Locale geocoderLocale}) async { - _catalogMetadata = null; - _addressDetails = null; + Future refresh({ + required bool background, + required bool persist, + required Set 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 rotate({required bool clockwise, required bool persist}) async { + Future> 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 flip({required bool persist}) async { + Future> 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 editDate(DateModifier modifier) async { + Future> 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 removeMetadata(Set types) async { + Future> removeMetadata(Set types) async { final newFields = await metadataEditService.removeTypes(this, types); - return newFields.isNotEmpty; + return newFields.isEmpty + ? {} + : { + EntryDataType.basic, + EntryDataType.catalog, + EntryDataType.address, + }; } Future delete() { diff --git a/lib/model/entry_images.dart b/lib/model/entry_images.dart index 16a11be8b..3a281c733 100644 --- a/lib/model/entry_images.dart +++ b/lib/model/entry_images.dart @@ -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}) { diff --git a/lib/model/entry_xmp_iptc.dart b/lib/model/entry_xmp_iptc.dart new file mode 100644 index 000000000..f5a1a80f6 --- /dev/null +++ b/lib/model/entry_xmp_iptc.dart @@ -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> editTags(Set 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 _setIptcKeywords(List> iptc, Set 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 namespaces) => namespaces.forEach((uri, prefix) => node.setAttribute('$xmlnsPrefix:$prefix', uri)); + + void _setStringBag(XmlNode node, String name, Set 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 get props => [xmpString, extendedXmpString]; + + const AvesXmp({ + required this.xmpString, + this.extendedXmpString, + }); + + static AvesXmp? fromList(List xmpStrings) { + switch (xmpStrings.length) { + case 0: + return null; + case 1: + return AvesXmp(xmpString: xmpStrings.single); + default: + final byExtending = groupBy(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); + } + } +} diff --git a/lib/model/filters/tag.dart b/lib/model/filters/tag.dart index 1f5c1e0dc..f525ccdb1 100644 --- a/lib/model/filters/tag.dart +++ b/lib/model/filters/tag.dart @@ -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); } } diff --git a/lib/model/metadata/enums.dart b/lib/model/metadata/enums.dart index 351d441ec..145ce2ea7 100644 --- a/lib/model/metadata/enums.dart +++ b/lib/model/metadata/enums.dart @@ -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: diff --git a/lib/model/metadata_db.dart b/lib/model/metadata_db.dart index df3750b5b..bb0a63201 100644 --- a/lib/model/metadata_db.dart +++ b/lib/model/metadata_db.dart @@ -20,7 +20,7 @@ abstract class MetadataDb { Future reset(); - Future removeIds(Set contentIds, {required bool metadataOnly}); + Future removeIds(Set contentIds, {Set? dataTypes}); // entries @@ -187,20 +187,28 @@ class SqfliteMetadataDb implements MetadataDb { } @override - Future removeIds(Set contentIds, {required bool metadataOnly}) async { + Future removeIds(Set contentIds, {Set? 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]; - batch.delete(entryTable, where: where, whereArgs: whereArgs); - batch.delete(dateTakenTable, where: where, whereArgs: whereArgs); - batch.delete(metadataTable, where: where, whereArgs: whereArgs); - batch.delete(addressTable, where: where, whereArgs: whereArgs); - if (!metadataOnly) { + 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 (_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); diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index 2a74801c3..86f574b1f 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -284,8 +284,8 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM Future> refreshUris(Set changedUris, {AnalysisController? analysisController}); - Future refreshEntry(AvesEntry entry) async { - await entry.refresh(background: false, persist: true, force: true, geocoderLocale: settings.appliedLocale); + Future refreshEntry(AvesEntry entry, Set dataTypes) async { + await entry.refresh(background: false, persist: true, dataTypes: dataTypes, geocoderLocale: settings.appliedLocale); updateDerivedFilters({entry}); eventBus.fire(EntryRefreshedEvent({entry})); } diff --git a/lib/model/source/media_store_source.dart b/lib/model/source/media_store_source.dart index d2e246c35..0a88b12d2 100644 --- a/lib/model/source/media_store_source.dart +++ b/lib/model/source/media_store_source.dart @@ -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'); diff --git a/lib/model/source/tag.dart b/lib/model/source/tag.dart index 3617b0212..121371d63 100644 --- a/lib/model/source/tag.dart +++ b/lib/model/source/tag.dart @@ -38,7 +38,7 @@ mixin TagMixin on SourceBase { var stopCheckCount = 0; final newMetadata = {}; 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)); diff --git a/lib/ref/iptc.dart b/lib/ref/iptc.dart new file mode 100644 index 000000000..908f18914 --- /dev/null +++ b/lib/ref/iptc.dart @@ -0,0 +1,6 @@ +class IPTC { + static const int applicationRecord = 2; + + // ApplicationRecord tags + static const int keywordsTag = 25; +} \ No newline at end of file diff --git a/lib/services/metadata/metadata_edit_service.dart b/lib/services/metadata/metadata_edit_service.dart index cde075fdc..0bb1cfaa4 100644 --- a/lib/services/metadata/metadata_edit_service.dart +++ b/lib/services/metadata/metadata_edit_service.dart @@ -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> editDate(AvesEntry entry, DateModifier modifier); + Future> setIptc(AvesEntry entry, List>? iptc, {required bool postEditScan}); + + Future> setXmp(AvesEntry entry, AvesXmp? xmp); + Future> removeTypes(AvesEntry entry, Set types); } @@ -85,6 +90,40 @@ class PlatformMetadataEditService implements MetadataEditService { return {}; } + @override + Future> setIptc(AvesEntry entry, List>? iptc, {required bool postEditScan}) async { + try { + final result = await platform.invokeMethod('setIptc', { + 'entry': _toPlatformEntryMap(entry), + 'iptc': iptc, + 'postEditScan': postEditScan, + }); + if (result != null) return (result as Map).cast(); + } on PlatformException catch (e, stack) { + if (!entry.isMissingAtPath) { + await reportService.recordError(e, stack); + } + } + return {}; + } + + @override + Future> setXmp(AvesEntry entry, AvesXmp? xmp) async { + try { + final result = await platform.invokeMethod('setXmp', { + 'entry': _toPlatformEntryMap(entry), + 'xmp': xmp?.xmpString, + 'extendedXmp': xmp?.extendedXmpString, + }); + if (result != null) return (result as Map).cast(); + } on PlatformException catch (e, stack) { + if (!entry.isMissingAtPath) { + await reportService.recordError(e, stack); + } + } + return {}; + } + @override Future> removeTypes(AvesEntry entry, Set 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: diff --git a/lib/services/metadata/metadata_fetch_service.dart b/lib/services/metadata/metadata_fetch_service.dart index 942c7aa5b..4ed8210d9 100644 --- a/lib/services/metadata/metadata_fetch_service.dart +++ b/lib/services/metadata/metadata_fetch_service.dart @@ -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 getPanoramaInfo(AvesEntry entry); + Future>?> getIptc(AvesEntry entry); + + Future getXmp(AvesEntry entry); + Future hasContentResolverProp(String prop); Future getContentResolverProp(AvesEntry entry, String prop); @@ -151,6 +156,39 @@ class PlatformMetadataFetchService implements MetadataFetchService { return null; } + @override + Future>?> getIptc(AvesEntry entry) async { + try { + final result = await platform.invokeMethod('getIptc', { + 'mimeType': entry.mimeType, + 'uri': entry.uri, + }); + if (result != null) return (result as List).cast().map((fields) => fields.cast()).toList(); + } on PlatformException catch (e, stack) { + if (!entry.isMissingAtPath) { + await reportService.recordError(e, stack); + } + } + return null; + } + + @override + Future getXmp(AvesEntry entry) async { + try { + final result = await platform.invokeMethod('getXmp', { + 'mimeType': entry.mimeType, + 'uri': entry.uri, + 'sizeBytes': entry.sizeBytes, + }); + if (result != null) return AvesXmp.fromList((result as List).cast()); + } on PlatformException catch (e, stack) { + if (!entry.isMissingAtPath) { + await reportService.recordError(e, stack); + } + } + return null; + } + final Map _contentResolverProps = {}; @override diff --git a/lib/theme/durations.dart b/lib/theme/durations.dart index ecba948fa..b071be57c 100644 --- a/lib/theme/durations.dart +++ b/lib/theme/durations.dart @@ -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); diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index 421714a50..daed92698 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -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; diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index 5135feec9..d55dcdbbf 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -269,6 +269,7 @@ class _CollectionAppBarState extends State 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 with SingleTickerPr case EntrySetAction.rotateCW: case EntrySetAction.flip: case EntrySetAction.editDate: + case EntrySetAction.editTags: case EntrySetAction.removeMetadata: _actionDelegate.onActionSelected(context, action); break; diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index d8d5029af..3a8d7b6e2 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -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 selection, Set todoItems, - Future Function(AvesEntry entry) op, + Future> 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( 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>(); 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 _editTags(BuildContext context) async { + final selection = context.read>(); + 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 _removeMetadata(BuildContext context) async { final selection = context.read>(); final selectedItems = _getExpandedSelectedItems(selection); diff --git a/lib/widgets/common/action_mixins/entry_editor.dart b/lib/widgets/common/action_mixins/entry_editor.dart index 3212a9827..2ef66c82f 100644 --- a/lib/widgets/common/action_mixins/entry_editor.dart +++ b/lib/widgets/common/action_mixins/entry_editor.dart @@ -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>?> selectTags(BuildContext context, Set 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?> selectMetadataToRemove(BuildContext context, Set entries) async { if (entries.isEmpty) return null; diff --git a/lib/widgets/common/identity/aves_filter_chip.dart b/lib/widgets/common/identity/aves_filter_chip.dart index b774dc4c6..a5b4c5e1c 100644 --- a/lib/widgets/common/identity/aves_filter_chip.dart +++ b/lib/widgets/common/identity/aves_filter_chip.dart @@ -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 { 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; diff --git a/lib/widgets/dialogs/edit_entry_date_dialog.dart b/lib/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart similarity index 99% rename from lib/widgets/dialogs/edit_entry_date_dialog.dart rename to lib/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart index 0d6056f79..970f0ac89 100644 --- a/lib/widgets/dialogs/edit_entry_date_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart @@ -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; diff --git a/lib/widgets/dialogs/entry_editors/edit_entry_tags_dialog.dart b/lib/widgets/dialogs/entry_editors/edit_entry_tags_dialog.dart new file mode 100644 index 000000000..9d44106c1 --- /dev/null +++ b/lib/widgets/dialogs/entry_editors/edit_entry_tags_dialog.dart @@ -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> tagsByEntry; + + const TagEditorPage({ + Key? key, + required this.tagsByEntry, + }) : super(key: key); + + @override + _TagEditorPageState createState() => _TagEditorPageState(); +} + +class _TagEditorPageState extends State { + final TextEditingController _newTagTextController = TextEditingController(); + final FocusNode _newTagTextFocusNode = FocusNode(); + final ValueNotifier _expandedSectionNotifier = ValueNotifier(null); + late final List _topTags; + + static final List _recentTags = []; + + static const Color untaggedColor = Colors.blueGrey; + static const int tagHistoryCount = 10; + + Map> 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 entryCountByTag = {}; + tagsByEntry.entries.forEach((kv) { + kv.value.forEach((tag) => entryCountByTag[tag] = (entryCountByTag[tag] ?? 0) + 1); + }); + List> 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( + valueListenable: _expandedSectionNotifier, + builder: (context, expandedSection, child) { + return ValueListenableBuilder( + 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( + 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 entryCountByTag = {}; + final visibleEntries = context.read()?.visibleEntries; + visibleEntries?.forEach((entry) { + entry.tags.forEach((tag) => entryCountByTag[tag] = (entryCountByTag[tag] ?? 0) + 1); + }); + List> sortedTopTags = _sortEntryCountByTag(entryCountByTag); + _topTags = sortedTopTags.map((kv) => kv.key).toList(); + } + + List> _sortEntryCountByTag(Map 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 filters; + final ValueNotifier 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), + ), + ); + } +} diff --git a/lib/widgets/dialogs/remove_entry_metadata_dialog.dart b/lib/widgets/dialogs/entry_editors/remove_entry_metadata_dialog.dart similarity index 99% rename from lib/widgets/dialogs/remove_entry_metadata_dialog.dart rename to lib/widgets/dialogs/entry_editors/remove_entry_metadata_dialog.dart index 2ecc94bef..54c16d7ec 100644 --- a/lib/widgets/dialogs/remove_entry_metadata_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/remove_entry_metadata_dialog.dart @@ -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; diff --git a/lib/widgets/dialogs/rename_entry_dialog.dart b/lib/widgets/dialogs/entry_editors/rename_entry_dialog.dart similarity index 98% rename from lib/widgets/dialogs/rename_entry_dialog.dart rename to lib/widgets/dialogs/entry_editors/rename_entry_dialog.dart index 26ba06ca4..7458f5043 100644 --- a/lib/widgets/dialogs/rename_entry_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/rename_entry_dialog.dart @@ -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; diff --git a/lib/widgets/dialogs/cover_selection_dialog.dart b/lib/widgets/dialogs/filter_editors/cover_selection_dialog.dart similarity index 100% rename from lib/widgets/dialogs/cover_selection_dialog.dart rename to lib/widgets/dialogs/filter_editors/cover_selection_dialog.dart diff --git a/lib/widgets/dialogs/create_album_dialog.dart b/lib/widgets/dialogs/filter_editors/create_album_dialog.dart similarity index 99% rename from lib/widgets/dialogs/create_album_dialog.dart rename to lib/widgets/dialogs/filter_editors/create_album_dialog.dart index 2e5965d24..738642c50 100644 --- a/lib/widgets/dialogs/create_album_dialog.dart +++ b/lib/widgets/dialogs/filter_editors/create_album_dialog.dart @@ -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); diff --git a/lib/widgets/dialogs/rename_album_dialog.dart b/lib/widgets/dialogs/filter_editors/rename_album_dialog.dart similarity index 100% rename from lib/widgets/dialogs/rename_album_dialog.dart rename to lib/widgets/dialogs/filter_editors/rename_album_dialog.dart diff --git a/lib/widgets/filter_grids/album_pick.dart b/lib/widgets/filter_grids/album_pick.dart index 1a4634d94..290714c9d 100644 --- a/lib/widgets/filter_grids/album_pick.dart +++ b/lib/widgets/filter_grids/album_pick.dart @@ -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'; diff --git a/lib/widgets/filter_grids/common/action_delegates/album_set.dart b/lib/widgets/filter_grids/common/action_delegates/album_set.dart index 606c928a8..0ee31819f 100644 --- a/lib/widgets/filter_grids/common/action_delegates/album_set.dart +++ b/lib/widgets/filter_grids/common/action_delegates/album_set.dart @@ -18,8 +18,8 @@ import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; -import 'package:aves/widgets/dialogs/create_album_dialog.dart'; -import 'package:aves/widgets/dialogs/rename_album_dialog.dart'; +import 'package:aves/widgets/dialogs/filter_editors/create_album_dialog.dart'; +import 'package:aves/widgets/dialogs/filter_editors/rename_album_dialog.dart'; import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; diff --git a/lib/widgets/filter_grids/common/action_delegates/chip_set.dart b/lib/widgets/filter_grids/common/action_delegates/chip_set.dart index bb02c7bf1..4a2167d31 100644 --- a/lib/widgets/filter_grids/common/action_delegates/chip_set.dart +++ b/lib/widgets/filter_grids/common/action_delegates/chip_set.dart @@ -15,7 +15,7 @@ import 'package:aves/widgets/common/action_mixins/size_aware.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; -import 'package:aves/widgets/dialogs/cover_selection_dialog.dart'; +import 'package:aves/widgets/dialogs/filter_editors/cover_selection_dialog.dart'; import 'package:aves/widgets/map/map_page.dart'; import 'package:aves/widgets/search/search_delegate.dart'; import 'package:aves/widgets/stats/stats_page.dart'; diff --git a/lib/widgets/home_page.dart b/lib/widgets/home_page.dart index 4569ce373..bd533fb9b 100644 --- a/lib/widgets/home_page.dart +++ b/lib/widgets/home_page.dart @@ -136,7 +136,7 @@ class _HomePageState extends State { 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; } diff --git a/lib/widgets/search/expandable_filter_row.dart b/lib/widgets/search/expandable_filter_row.dart index 10995787a..0356a6461 100644 --- a/lib/widgets/search/expandable_filter_row.dart +++ b/lib/widgets/search/expandable_filter_row.dart @@ -9,16 +9,20 @@ class ExpandableFilterRow extends StatelessWidget { final String? title; final Iterable filters; final ValueNotifier 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, ); } } diff --git a/lib/widgets/search/search_delegate.dart b/lib/widgets/search/search_delegate.dart index 938b7ddde..6dfe0c0c8 100644 --- a/lib/widgets/search/search_delegate.dart +++ b/lib/widgets/search/search_delegate.dart @@ -27,10 +27,10 @@ import 'package:provider/provider.dart'; class CollectionSearchDelegate { final CollectionSource source; final CollectionLens? parentCollection; - final ValueNotifier expandedSectionNotifier = ValueNotifier(null); + final ValueNotifier _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( - valueListenable: expandedSectionNotifier, + valueListenable: _expandedSectionNotifier, builder: (context, expandedSection, child) { final queryFilter = _buildQueryFilter(false); return Selector>( @@ -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, ); } diff --git a/lib/widgets/stats/stats_page.dart b/lib/widgets/stats/stats_page.dart index f80232d90..60ac3c422 100644 --- a/lib/widgets/stats/stats_page.dart +++ b/lib/widgets/stats/stats_page.dart @@ -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; }); }); diff --git a/lib/widgets/viewer/debug/debug_page.dart b/lib/widgets/viewer/debug/debug_page.dart index 7089a8581..3bc71ec6e 100644 --- a/lib/widgets/viewer/debug/debug_page.dart +++ b/lib/widgets/viewer/debug/debug_page.dart @@ -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(), diff --git a/lib/widgets/viewer/entry_action_delegate.dart b/lib/widgets/viewer/entry_action_delegate.dart index 4ff192481..3602d466e 100644 --- a/lib/widgets/viewer/entry_action_delegate.dart +++ b/lib/widgets/viewer/entry_action_delegate.dart @@ -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 _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 _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 _rotateScreen(BuildContext context) async { diff --git a/lib/widgets/viewer/entry_vertical_pager.dart b/lib/widgets/viewer/entry_vertical_pager.dart index 7725da999..2c722dbe6 100644 --- a/lib/widgets/viewer/entry_vertical_pager.dart +++ b/lib/widgets/viewer/entry_vertical_pager.dart @@ -169,7 +169,7 @@ class _ViewerVerticalPageViewState extends State { // 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); diff --git a/lib/widgets/viewer/info/basic_section.dart b/lib/widgets/viewer/info/basic_section.dart index b8d062669..5d7aeb60b 100644 --- a/lib/widgets/viewer/info/basic_section.dart +++ b/lib/widgets/viewer/info/basic_section.dart @@ -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 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,19 +108,60 @@ class BasicSection extends StatelessWidget { ...filters, if (entry.isFavourite) FavouriteFilter.instance, ]..sort(); - if (effectiveFilters.isEmpty) return const SizedBox.shrink(); - return Padding( - padding: const EdgeInsets.symmetric(horizontal: AvesFilterChip.outlineWidth / 2) + const EdgeInsets.only(top: 8), - child: Wrap( - spacing: 8, - runSpacing: 8, - children: effectiveFilters - .map((filter) => AvesFilterChip( - filter: filter, - onTap: onFilter, - )) - .toList(), - ), + + final children = [ + ...effectiveFilters.map((filter) => AvesFilterChip( + filter: filter, + onTap: onFilter, + )), + if (actionDelegate.canApply(EntryInfoAction.editTags)) _buildEditTagButton(context), + ]; + + return children.isEmpty + ? const SizedBox() + : Padding( + padding: const EdgeInsets.symmetric(horizontal: AvesFilterChip.outlineWidth / 2) + const EdgeInsets.only(top: 8), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: children, + ), + ); + }, + ); + } + + Widget _buildEditTagButton(BuildContext context) { + const action = EntryInfoAction.editTags; + return ValueListenableBuilder( + 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, + ), + ), + ), + ], ); }, ); diff --git a/lib/widgets/viewer/info/entry_info_action_delegate.dart b/lib/widgets/viewer/info/entry_info_action_delegate.dart index da527c6d5..5460b24dc 100644 --- a/lib/widgets/viewer/info/entry_info_action_delegate.dart +++ b/lib/widgets/viewer/info/entry_info_action_delegate.dart @@ -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> _eventStreamController = StreamController>.broadcast(); + + Stream> 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,26 +71,37 @@ class EntryInfoActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAw OpenEmbeddedDataNotification.motionPhotoVideo().dispatch(context); break; } + _eventStreamController.add(ActionEndedEvent(action)); } bool _isMainMode(BuildContext context) => context.read>().value == AppMode.main; - Future _edit(BuildContext context, Future Function() apply) async { + Future _edit(BuildContext context, Future> 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(); source?.pauseMonitoring(); - final success = await apply(); - if (success) { - if (_isMainMode(context) && source != null) { - await source.refreshEntry(entry); + + final dataTypes = await apply(); + final success = dataTypes.isNotEmpty; + try { + if (success) { + if (isMainMode && source != null) { + await source.refreshEntry(entry, dataTypes); + } else { + await entry.refresh(background: false, persist: false, dataTypes: dataTypes, geocoderLocale: settings.appliedLocale); + } + showFeedback(context, l10n.genericSuccessFeedback); } else { - await entry.refresh(background: false, persist: false, force: true, geocoderLocale: settings.appliedLocale); + showFeedback(context, l10n.genericFailureFeedback); } - 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 _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 _removeMetadata(BuildContext context) async { final types = await selectMetadataToRemove(context, {entry}); if (types == null) return; diff --git a/lib/widgets/viewer/info/info_app_bar.dart b/lib/widgets/viewer/info/info_app_bar.dart index 94b78e925..d44e8059d 100644 --- a/lib/widgets/viewer/info/info_app_bar.dart +++ b/lib/widgets/viewer/info/info_app_bar.dart @@ -13,19 +13,20 @@ import 'package:flutter/scheduler.dart'; class InfoAppBar extends StatelessWidget { final AvesEntry entry; + final EntryInfoActionDelegate actionDelegate; final ValueNotifier> 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( diff --git a/lib/widgets/viewer/info/info_page.dart b/lib/widgets/viewer/info/info_page.dart index 1eadb7439..78810f832 100644 --- a/lib/widgets/viewer/info/info_page.dart +++ b/lib/widgets/viewer/info/info_page.dart @@ -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 _subscriptions = []; + late EntryInfoActionDelegate _actionDelegate; final ValueNotifier> _metadataNotifier = ValueNotifier({}); + final ValueNotifier _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 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); diff --git a/test/fake/metadata_db.dart b/test/fake/metadata_db.dart index d34d41d9e..40042d0f8 100644 --- a/test/fake/metadata_db.dart +++ b/test/fake/metadata_db.dart @@ -13,7 +13,7 @@ class FakeMetadataDb extends Fake implements MetadataDb { Future init() => SynchronousFuture(null); @override - Future removeIds(Set contentIds, {required bool metadataOnly}) => SynchronousFuture(null); + Future removeIds(Set contentIds, {Set? dataTypes}) => SynchronousFuture(null); // entries diff --git a/test/model/collection_source_test.dart b/test/model/collection_source_test.dart index 916df6701..914c82ead 100644 --- a/test/model/collection_source_test.dart +++ b/test/model/collection_source_test.dart @@ -125,10 +125,10 @@ void main() { longitude: australiaLatLng.longitude, ), ); - expect(image1.xmpSubjects, []); + expect(image1.tags, {}); 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); diff --git a/untranslated.json b/untranslated.json index 9e26dfeeb..ec2e86e7c 100644 --- a/untranslated.json +++ b/untranslated.json @@ -1 +1,17 @@ -{} \ No newline at end of file +{ + "ko": [ + "resetButtonTooltip", + "entryInfoActionEditTags", + "tagEditorPageTitle", + "tagEditorPageNewTagFieldLabel", + "tagEditorPageAddTagTooltip" + ], + + "ru": [ + "resetButtonTooltip", + "entryInfoActionEditTags", + "tagEditorPageTitle", + "tagEditorPageNewTagFieldLabel", + "tagEditorPageAddTagTooltip" + ] +}