From 1c4db4d8e7a5f1dbf0d6b6337e3a5aa6fdc68925 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Thu, 26 Aug 2021 19:53:56 +0900 Subject: [PATCH] info: edit exif date --- .../aves/channel/calls/EmbeddedDataHandler.kt | 10 +- .../aves/channel/calls/ImageFileHandler.kt | 48 +- .../channel/streams/ImageOpStreamHandler.kt | 8 +- .../aves/metadata/ExifInterfaceHelper.kt | 2 + .../model/provider/ContentImageProvider.kt | 3 +- .../aves/model/provider/FileImageProvider.kt | 3 +- .../aves/model/provider/ImageProvider.kt | 171 +++++-- .../model/provider/MediaStoreImageProvider.kt | 4 +- lib/l10n/app_en.arb | 21 + lib/l10n/app_ko.arb | 12 + lib/model/actions/entry_info_actions.dart | 5 +- lib/model/entry.dart | 7 +- lib/model/metadata/address.dart | 51 +++ .../{metadata.dart => metadata/catalog.dart} | 81 ---- lib/model/metadata/date_modifier.dart | 20 + lib/model/metadata/enums.dart | 12 + lib/model/metadata/overlay.dart | 32 ++ lib/model/metadata_db.dart | 3 +- lib/model/source/collection_source.dart | 9 + lib/model/source/location.dart | 2 +- lib/model/source/tag.dart | 2 +- lib/model/video/metadata.dart | 2 +- lib/ref/mime_types.dart | 2 +- lib/services/image_file_service.dart | 33 ++ lib/services/metadata_service.dart | 3 +- lib/theme/icons.dart | 1 + lib/widgets/debug/database.dart | 3 +- lib/widgets/dialogs/aves_dialog.dart | 74 ++- .../dialogs/edit_entry_date_dialog.dart | 432 ++++++++++++++++-- lib/widgets/viewer/debug/db.dart | 3 +- lib/widgets/viewer/info/basic_section.dart | 59 +-- lib/widgets/viewer/info/info_app_bar.dart | 55 ++- lib/widgets/viewer/overlay/bottom/common.dart | 2 +- lib/widgets/welcome_page.dart | 1 + test/fake/metadata_db.dart | 3 +- test/fake/metadata_service.dart | 2 +- 36 files changed, 934 insertions(+), 247 deletions(-) create mode 100644 lib/model/metadata/address.dart rename lib/model/{metadata.dart => metadata/catalog.dart} (63%) create mode 100644 lib/model/metadata/date_modifier.dart create mode 100644 lib/model/metadata/enums.dart create mode 100644 lib/model/metadata/overlay.dart diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt index 9bc84c83f..eb158b257 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt @@ -160,9 +160,9 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler { try { val embedBytes: ByteArray = if (!dataPropPath.contains('/')) { val propNs = XMP.namespaceForPropPath(dataPropPath) - xmpDirs.map { it.xmpMeta.getPropertyBase64(propNs, dataPropPath) }.filterNotNull().first() + xmpDirs.mapNotNull { it.xmpMeta.getPropertyBase64(propNs, dataPropPath) }.first() } else { - xmpDirs.map { it.xmpMeta.getSafeStructField(dataPropPath) }.filterNotNull().first().let { + xmpDirs.mapNotNull { it.xmpMeta.getSafeStructField(dataPropPath) }.first().let { XMPUtils.decodeBase64(it.value) } } @@ -211,9 +211,9 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler { ) if (isImage(mimeType) || isVideo(mimeType)) { GlobalScope.launch(Dispatchers.IO) { - ContentImageProvider().fetchSingle(context, uri, mimeType, object : ImageProvider.ImageOpCallback { - override fun onSuccess(fields: FieldMap) { - resultFields.putAll(fields) + ContentImageProvider().fetchSingle(context, uri, mimeType, object : ImageProvider.ImageOpCallback { + override fun onSuccess(res: FieldMap) { + resultFields.putAll(res) result.success(resultFields) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt index 8b35b4d9a..18228cf81 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt @@ -38,6 +38,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler { "rename" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::rename) } "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) } "clearSizedThumbnailDiskCache" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::clearSizedThumbnailDiskCache) } else -> result.notImplemented() } @@ -57,8 +58,8 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler { return } - provider.fetchSingle(activity, uri, mimeType, object : ImageOpCallback { - override fun onSuccess(fields: FieldMap) = result.success(fields) + provider.fetchSingle(activity, uri, mimeType, object : ImageOpCallback { + override fun onSuccess(res: FieldMap) = result.success(res) override fun onFailure(throwable: Throwable) = result.error("getEntry-failure", "failed to get entry for uri=$uri", throwable.message) }) } @@ -159,8 +160,8 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler { } destinationDir = ensureTrailingSeparator(destinationDir) - provider.captureFrame(activity, desiredName, exifFields, bytes, destinationDir, object : ImageOpCallback { - override fun onSuccess(fields: FieldMap) = result.success(fields) + provider.captureFrame(activity, desiredName, exifFields, bytes, destinationDir, object : ImageOpCallback { + override fun onSuccess(res: FieldMap) = result.success(res) override fun onFailure(throwable: Throwable) = result.error("captureFrame-failure", "failed to capture frame", throwable.message) }) } @@ -187,8 +188,8 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler { return } - provider.rename(activity, path, uri, mimeType, newName, object : ImageOpCallback { - override fun onSuccess(fields: FieldMap) = result.success(fields) + provider.rename(activity, path, uri, mimeType, newName, object : ImageOpCallback { + override fun onSuccess(res: FieldMap) = result.success(res) override fun onFailure(throwable: Throwable) = result.error("rename-failure", "failed to rename", throwable.message) }) } @@ -230,12 +231,43 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler { return } - provider.changeOrientation(activity, path, uri, mimeType, sizeBytes, op, object : ImageOpCallback { - override fun onSuccess(fields: FieldMap) = result.success(fields) + provider.changeOrientation(activity, path, uri, mimeType, sizeBytes, op, object : ImageOpCallback { + override fun onSuccess(res: FieldMap) = result.success(res) override fun onFailure(throwable: Throwable) = result.error("changeOrientation-failure", "failed to change orientation", throwable.message) }) } + private fun editDate(call: MethodCall, result: MethodChannel.Result) { + val dateMillis = call.argument("dateMillis")?.toLong() + val shiftMinutes = call.argument("shiftMinutes")?.toLong() + val fields = call.argument>("fields") + val entryMap = call.argument("entry") + if (entryMap == null || fields == null) { + result.error("editDate-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? + val sizeBytes = (entryMap["sizeBytes"] as Number?)?.toLong() + if (uri == null || path == null || mimeType == null || sizeBytes == null) { + result.error("editDate-args", "failed because entry fields are missing", null) + return + } + + val provider = getProvider(uri) + if (provider == null) { + result.error("editDate-provider", "failed to find provider for uri=$uri", null) + return + } + + provider.editDate(activity, path, uri, mimeType, sizeBytes, dateMillis, shiftMinutes, fields, object : ImageOpCallback { + override fun onSuccess(res: Boolean) = result.success(res) + override fun onFailure(throwable: Throwable) = result.error("editDate-failure", "failed to edit date", throwable.message) + }) + } + private fun clearSizedThumbnailDiskCache(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { Glide.get(activity).clearDiskCache() result.success(null) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt index 7170a3105..9a6ad38b8 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt @@ -138,8 +138,8 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir) val entries = entryMapList.map(::AvesEntry) - provider.exportMultiple(activity, mimeType, destinationDir, entries, object : ImageOpCallback { - override fun onSuccess(fields: FieldMap) = success(fields) + provider.exportMultiple(activity, mimeType, destinationDir, entries, object : ImageOpCallback { + override fun onSuccess(res: FieldMap) = success(res) override fun onFailure(throwable: Throwable) = error("export-failure", "failed to export entries", throwable) }) endOfStream() @@ -168,8 +168,8 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir) val entries = entryMapList.map(::AvesEntry) - provider.moveMultiple(activity, copy, destinationDir, entries, object : ImageOpCallback { - override fun onSuccess(fields: FieldMap) = success(fields) + provider.moveMultiple(activity, copy, destinationDir, entries, object : ImageOpCallback { + override fun onSuccess(res: FieldMap) = success(res) override fun onFailure(throwable: Throwable) = error("move-failure", "failed to move entries", throwable) }) endOfStream() diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifInterfaceHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifInterfaceHelper.kt index 07a0c45dc..3817c7b38 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifInterfaceHelper.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifInterfaceHelper.kt @@ -19,6 +19,8 @@ import kotlin.math.roundToLong object ExifInterfaceHelper { private val LOG_TAG = LogUtils.createTag() val DATETIME_FORMAT = SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.ROOT) + val GPS_DATE_FORMAT = SimpleDateFormat("yyyy:MM:dd", Locale.ROOT) + val GPS_TIME_FORMAT = SimpleDateFormat("HH:mm:ss", Locale.ROOT) private const val precisionErrorTolerance = 1e-10 diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ContentImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ContentImageProvider.kt index 1f1829db5..0cfc8ebdf 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ContentImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ContentImageProvider.kt @@ -9,13 +9,14 @@ import com.drew.imaging.ImageMetadataReader import com.drew.metadata.file.FileTypeDirectory import deckers.thibault.aves.metadata.Metadata import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString +import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.SourceEntry import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.StorageUtils internal class ContentImageProvider : ImageProvider() { - override fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) { + override fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) { // source MIME type may be incorrect, so we get a second opinion if possible var extractorMimeType: String? = null try { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/FileImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/FileImageProvider.kt index eb0eb3138..8fe774ef4 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/FileImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/FileImageProvider.kt @@ -2,11 +2,12 @@ package deckers.thibault.aves.model.provider import android.content.Context import android.net.Uri +import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.SourceEntry import java.io.File internal class FileImageProvider : ImageProvider() { - override fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) { + override fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) { if (sourceMimeType == null) { callback.onFailure(Exception("MIME type is null for uri=$uri")) return 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 5af94b640..0adc2351b 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 @@ -18,6 +18,7 @@ import com.commonsware.cwac.document.DocumentFileCompat import deckers.thibault.aves.decoder.MultiTrackImage import deckers.thibault.aves.decoder.TiffImage import deckers.thibault.aves.metadata.ExifInterfaceHelper +import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis import deckers.thibault.aves.metadata.MultiPage import deckers.thibault.aves.model.AvesEntry import deckers.thibault.aves.model.ExifOrientationOp @@ -39,7 +40,7 @@ import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine abstract class ImageProvider { - open fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) { + open fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) { callback.onFailure(UnsupportedOperationException()) } @@ -47,7 +48,7 @@ abstract class ImageProvider { throw UnsupportedOperationException() } - open suspend fun moveMultiple(activity: Activity, copy: Boolean, destinationDir: String, entries: List, callback: ImageOpCallback) { + open suspend fun moveMultiple(activity: Activity, copy: Boolean, destinationDir: String, entries: List, callback: ImageOpCallback) { callback.onFailure(UnsupportedOperationException()) } @@ -56,7 +57,7 @@ abstract class ImageProvider { imageExportMimeType: String, destinationDir: String, entries: List, - callback: ImageOpCallback, + callback: ImageOpCallback, ) { if (!supportedExportMimeTypes.contains(imageExportMimeType)) { throw Exception("unsupported export MIME type=$imageExportMimeType") @@ -204,7 +205,7 @@ abstract class ImageProvider { exifFields: FieldMap, bytes: ByteArray, destinationDir: String, - callback: ImageOpCallback, + callback: ImageOpCallback, ) { val destinationDirDocFile = createDirectoryIfAbsent(context, destinationDir) if (destinationDirDocFile == null) { @@ -299,7 +300,7 @@ abstract class ImageProvider { } } - suspend fun rename(context: Context, oldPath: String, oldMediaUri: Uri, mimeType: String, newFilename: String, callback: ImageOpCallback) { + suspend fun rename(context: Context, oldPath: String, oldMediaUri: Uri, mimeType: String, newFilename: String, callback: ImageOpCallback) { val oldFile = File(oldPath) val newFile = File(oldFile.parent, newFilename) if (oldFile == newFile) { @@ -329,16 +330,33 @@ abstract class ImageProvider { } } - fun changeOrientation(context: Context, path: String, uri: Uri, mimeType: String, sizeBytes: Long, op: ExifOrientationOp, callback: ImageOpCallback) { + // support for writing EXIF + // as of androidx.exifinterface:exifinterface:1.3.0 + private fun canEditExif(mimeType: String): Boolean { + return when (mimeType) { + MimeTypes.JPEG, MimeTypes.PNG, MimeTypes.WEBP -> true + else -> false + } + } + + private fun editExif( + context: Context, + path: String, + uri: Uri, + mimeType: String, + sizeBytes: Long, + callback: ImageOpCallback, + editExif: (exif: ExifInterface) -> Unit, + ): Boolean { if (!canEditExif(mimeType)) { callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType")) - return + return false } val originalDocumentFile = getDocumentFile(context, path, uri) if (originalDocumentFile == null) { callback.onFailure(Exception("failed to get document file for path=$path, uri=$uri")) - return + return false } val videoSizeBytes = MultiPage.getMotionPhotoOffset(context, uri, mimeType, sizeBytes)?.toInt() @@ -372,13 +390,33 @@ abstract class ImageProvider { } } catch (e: Exception) { callback.onFailure(e) - return + return false } } - val newFields = HashMap() try { val exif = ExifInterface(editableFile) + + editExif(exif) + + if (videoBytes != null) { + // append motion photo video, if any + editableFile.appendBytes(videoBytes!!) + } + // copy the edited temporary file back to the original + DocumentFileCompat.fromFile(editableFile).copyTo(originalDocumentFile) + } catch (e: IOException) { + callback.onFailure(e) + return false + } + + return true + } + + fun changeOrientation(context: Context, path: String, uri: Uri, mimeType: String, sizeBytes: Long, op: ExifOrientationOp, callback: ImageOpCallback) { + val newFields = HashMap() + + val success = editExif(context, path, uri, mimeType, sizeBytes, callback) { exif -> // when the orientation is not defined, it returns `undefined (0)` instead of the orientation default value `normal (1)` // in that case we explicitly set it to `normal` first // because ExifInterface fails to rotate an image with undefined orientation @@ -393,20 +431,10 @@ abstract class ImageProvider { ExifOrientationOp.FLIP -> exif.flipHorizontally() } exif.saveAttributes() - - if (videoBytes != null) { - // append motion photo video, if any - editableFile.appendBytes(videoBytes!!) - } - // copy the edited temporary file back to the original - DocumentFileCompat.fromFile(editableFile).copyTo(originalDocumentFile) - newFields["rotationDegrees"] = exif.rotationDegrees newFields["isFlipped"] = exif.isFlipped - } catch (e: IOException) { - callback.onFailure(e) - return } + if (!success) return MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, _ -> val projection = arrayOf(MediaStore.MediaColumns.DATE_MODIFIED) @@ -424,12 +452,97 @@ abstract class ImageProvider { } } - // support for writing EXIF - // as of androidx.exifinterface:exifinterface:1.3.0 - private fun canEditExif(mimeType: String): Boolean { - return when (mimeType) { - MimeTypes.JPEG, MimeTypes.PNG, MimeTypes.WEBP -> true - else -> false + fun editDate( + context: Context, + path: String, + uri: Uri, + mimeType: String, + sizeBytes: Long, + dateMillis: Long?, + shiftMinutes: Long?, + fields: List, + callback: ImageOpCallback, + ) { + if (dateMillis != null && dateMillis < 0) { + callback.onFailure(Exception("dateMillis=$dateMillis cannot be negative")) + return + } + + val success = editExif(context, path, uri, mimeType, sizeBytes, callback) { exif -> + when { + dateMillis != null -> { + // set + val date = Date(dateMillis) + val dateString = ExifInterfaceHelper.DATETIME_FORMAT.format(date) + val subSec = dateMillis % 1000 + val subSecString = if (subSec > 0) subSec.toString().padStart(3, '0') else null + + if (fields.contains(ExifInterface.TAG_DATETIME)) { + exif.setAttribute(ExifInterface.TAG_DATETIME, dateString) + exif.setAttribute(ExifInterface.TAG_SUBSEC_TIME, subSecString) + } + if (fields.contains(ExifInterface.TAG_DATETIME_ORIGINAL)) { + exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, dateString) + exif.setAttribute(ExifInterface.TAG_SUBSEC_TIME_ORIGINAL, subSecString) + } + if (fields.contains(ExifInterface.TAG_DATETIME_DIGITIZED)) { + exif.setAttribute(ExifInterface.TAG_DATETIME_DIGITIZED, dateString) + exif.setAttribute(ExifInterface.TAG_SUBSEC_TIME_DIGITIZED, subSecString) + } + if (fields.contains(ExifInterface.TAG_GPS_DATESTAMP)) { + exif.setAttribute(ExifInterface.TAG_GPS_DATESTAMP, ExifInterfaceHelper.GPS_DATE_FORMAT.format(date)) + exif.setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, ExifInterfaceHelper.GPS_TIME_FORMAT.format(date)) + } + } + shiftMinutes != null -> { + // shift + val shiftMillis = shiftMinutes * 60000 + listOf( + ExifInterface.TAG_DATETIME, + ExifInterface.TAG_DATETIME_ORIGINAL, + ExifInterface.TAG_DATETIME_DIGITIZED, + ).forEach { field -> + if (fields.contains(field)) { + exif.getSafeDateMillis(field) { date -> + exif.setAttribute(field, ExifInterfaceHelper.DATETIME_FORMAT.format(date + shiftMillis)) + } + } + } + if (fields.contains(ExifInterface.TAG_GPS_DATESTAMP)) { + exif.gpsDateTime?.let { date -> + val shifted = date + shiftMillis - TimeZone.getDefault().rawOffset + exif.setAttribute(ExifInterface.TAG_GPS_DATESTAMP, ExifInterfaceHelper.GPS_DATE_FORMAT.format(shifted)) + exif.setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, ExifInterfaceHelper.GPS_TIME_FORMAT.format(shifted)) + } + } + } + else -> { + // clear + if (fields.contains(ExifInterface.TAG_DATETIME)) { + exif.setAttribute(ExifInterface.TAG_DATETIME, null) + exif.setAttribute(ExifInterface.TAG_SUBSEC_TIME, null) + exif.setAttribute(ExifInterface.TAG_OFFSET_TIME, null) + } + if (fields.contains(ExifInterface.TAG_DATETIME_ORIGINAL)) { + exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, null) + exif.setAttribute(ExifInterface.TAG_SUBSEC_TIME_ORIGINAL, null) + exif.setAttribute(ExifInterface.TAG_OFFSET_TIME_ORIGINAL, null) + } + if (fields.contains(ExifInterface.TAG_DATETIME_DIGITIZED)) { + exif.setAttribute(ExifInterface.TAG_DATETIME_DIGITIZED, null) + exif.setAttribute(ExifInterface.TAG_SUBSEC_TIME_DIGITIZED, null) + exif.setAttribute(ExifInterface.TAG_OFFSET_TIME_DIGITIZED, null) + } + if (fields.contains(ExifInterface.TAG_GPS_DATESTAMP)) { + exif.setAttribute(ExifInterface.TAG_GPS_DATESTAMP, null) + exif.setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, null) + } + } + } + exif.saveAttributes() + } + if (success) { + callback.onSuccess(true) } } @@ -492,8 +605,8 @@ abstract class ImageProvider { } } - interface ImageOpCallback { - fun onSuccess(fields: FieldMap) + interface ImageOpCallback { + fun onSuccess(res: T) fun onFailure(throwable: Throwable) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt index 4c04b8907..c8ab6342a 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt @@ -38,7 +38,7 @@ class MediaStoreImageProvider : ImageProvider() { fetchFrom(context, isModified, handleNewEntry, VIDEO_CONTENT_URI, VIDEO_PROJECTION) } - override fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) { + override fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) { val id = uri.tryParseId() val onSuccess = fun(entry: FieldMap) { entry["uri"] = uri.toString() @@ -255,7 +255,7 @@ class MediaStoreImageProvider : ImageProvider() { copy: Boolean, destinationDir: String, entries: List, - callback: ImageOpCallback, + callback: ImageOpCallback, ) { val destinationDirDocFile = createDirectoryIfAbsent(activity, destinationDir) if (destinationDirDocFile == null) { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 1cd437d31..b1435cd7f 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -18,12 +18,15 @@ "@applyButtonLabel": {}, "deleteButtonLabel": "DELETE", "@deleteButtonLabel": {}, + "nextButtonLabel": "NEXT", + "@nextButtonLabel": {}, "showButtonLabel": "SHOW", "@showButtonLabel": {}, "hideButtonLabel": "HIDE", "@hideButtonLabel": {}, "continueButtonLabel": "CONTINUE", "@continueButtonLabel": {}, + "changeTooltip": "Change", "@changeTooltip": {}, "clearTooltip": "Clear", @@ -126,6 +129,9 @@ "videoActionSettings": "Settings", "@videoActionSettings": {}, + "entryInfoActionEditDate": "Edit date & time", + "@entryInfoActionEditDate": {}, + "filterFavouriteLabel": "Favourite", "@filterFavouriteLabel": {}, "filterLocationEmptyLabel": "Unlocated", @@ -304,6 +310,21 @@ "renameEntryDialogLabel": "New name", "@renameEntryDialogLabel": {}, + "editEntryDateDialogTitle": "Date & Time", + "@editEntryDateDialogTitle": {}, + "editEntryDateDialogSet": "Set", + "@editEntryDateDialogSet": {}, + "editEntryDateDialogShift": "Shift", + "@editEntryDateDialogShift": {}, + "editEntryDateDialogClear": "Clear", + "@editEntryDateDialogClear": {}, + "editEntryDateDialogFieldSelection": "Field selection", + "@editEntryDateDialogFieldSelection": {}, + "editEntryDateDialogHours": "Hours", + "@editEntryDateDialogHours": {}, + "editEntryDateDialogMinutes": "Minutes", + "@editEntryDateDialogMinutes": {}, + "videoSpeedDialogLabel": "Playback speed", "@videoSpeedDialogLabel": {}, diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 0002cb28a..1509ea85d 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -7,9 +7,11 @@ "applyButtonLabel": "확인", "deleteButtonLabel": "삭제", + "nextButtonLabel": "다음", "showButtonLabel": "보기", "hideButtonLabel": "숨기기", "continueButtonLabel": "다음", + "changeTooltip": "변경", "clearTooltip": "초기화", "previousTooltip": "이전", @@ -64,6 +66,8 @@ "videoActionSetSpeed": "재생 배속", "videoActionSettings": "설정", + "entryInfoActionEditDate": "날짜와 시간 수정", + "filterFavouriteLabel": "즐겨찾기", "filterLocationEmptyLabel": "장소 없음", "filterTagEmptyLabel": "태그 없음", @@ -137,6 +141,14 @@ "renameEntryDialogLabel": "이름", + "editEntryDateDialogTitle": "날짜 및 시간", + "editEntryDateDialogSet": "설정", + "editEntryDateDialogShift": "앞뒤로", + "editEntryDateDialogClear": "삭제", + "editEntryDateDialogFieldSelection": "필드 선택", + "editEntryDateDialogHours": "시간", + "editEntryDateDialogMinutes": "분", + "videoSpeedDialogLabel": "재생 배속", "videoStreamSelectionDialogVideo": "동영상", diff --git a/lib/model/actions/entry_info_actions.dart b/lib/model/actions/entry_info_actions.dart index 77e5a557a..4b9808b35 100644 --- a/lib/model/actions/entry_info_actions.dart +++ b/lib/model/actions/entry_info_actions.dart @@ -1,4 +1,3 @@ -enum SettingsAction { - export, - import, +enum EntryInfoAction { + editDate, } diff --git a/lib/model/entry.dart b/lib/model/entry.dart index e593bc719..9b9d62e9d 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -3,7 +3,8 @@ import 'dart:async'; import 'package:aves/geo/countries.dart'; import 'package:aves/model/entry_cache.dart'; import 'package:aves/model/favourites.dart'; -import 'package:aves/model/metadata.dart'; +import 'package:aves/model/metadata/address.dart'; +import 'package:aves/model/metadata/catalog.dart'; import 'package:aves/model/multipage.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/video/metadata.dart'; @@ -413,8 +414,8 @@ class AvesEntry { addressDetails = null; } - Future catalog({bool background = false, bool persist = true}) async { - if (isCatalogued) return; + Future catalog({bool background = false, bool persist = true, bool force = false}) async { + if (isCatalogued && !force) return; if (isSvg) { // vector image sizing is not essential, so we should not spend time for it during loading // but it is useful anyway (for aspect ratios etc.) so we size them during cataloguing diff --git a/lib/model/metadata/address.dart b/lib/model/metadata/address.dart new file mode 100644 index 000000000..86962a736 --- /dev/null +++ b/lib/model/metadata/address.dart @@ -0,0 +1,51 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +@immutable +class AddressDetails { + final int? contentId; + final String? countryCode, countryName, adminArea, locality; + + String? get place => locality != null && locality!.isNotEmpty ? locality : adminArea; + + const AddressDetails({ + this.contentId, + this.countryCode, + this.countryName, + this.adminArea, + this.locality, + }); + + AddressDetails copyWith({ + int? contentId, + }) { + return AddressDetails( + contentId: contentId ?? this.contentId, + countryCode: countryCode, + countryName: countryName, + adminArea: adminArea, + locality: locality, + ); + } + + factory AddressDetails.fromMap(Map map) { + return AddressDetails( + contentId: map['contentId'] as int?, + countryCode: map['countryCode'] as String?, + countryName: map['countryName'] as String?, + adminArea: map['adminArea'] as String?, + locality: map['locality'] as String?, + ); + } + + Map toMap() => { + 'contentId': contentId, + 'countryCode': countryCode, + 'countryName': countryName, + 'adminArea': adminArea, + 'locality': locality, + }; + + @override + String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, countryCode=$countryCode, countryName=$countryName, adminArea=$adminArea, locality=$locality}'; +} diff --git a/lib/model/metadata.dart b/lib/model/metadata/catalog.dart similarity index 63% rename from lib/model/metadata.dart rename to lib/model/metadata/catalog.dart index 73c57b90e..1c532673e 100644 --- a/lib/model/metadata.dart +++ b/lib/model/metadata/catalog.dart @@ -1,7 +1,5 @@ import 'package:aves/services/geocoding_service.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; -import 'package:intl/intl.dart'; class CatalogMetadata { final int? contentId, dateMillis; @@ -107,82 +105,3 @@ class CatalogMetadata { @override String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, is360=$is360, isMultiPage=$isMultiPage, rotationDegrees=$rotationDegrees, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}'; } - -class OverlayMetadata { - final String? aperture, exposureTime, focalLength, iso; - - static final apertureFormat = NumberFormat('0.0', 'en_US'); - static final focalLengthFormat = NumberFormat('0.#', 'en_US'); - - OverlayMetadata({ - double? aperture, - this.exposureTime, - double? focalLength, - int? iso, - }) : aperture = aperture != null ? 'ƒ/${apertureFormat.format(aperture)}' : null, - focalLength = focalLength != null ? '${focalLengthFormat.format(focalLength)} mm' : null, - iso = iso != null ? 'ISO$iso' : null; - - factory OverlayMetadata.fromMap(Map map) { - return OverlayMetadata( - aperture: map['aperture'] as double?, - exposureTime: map['exposureTime'] as String?, - focalLength: map['focalLength'] as double?, - iso: map['iso'] as int?, - ); - } - - bool get isEmpty => aperture == null && exposureTime == null && focalLength == null && iso == null; - - @override - String toString() => '$runtimeType#${shortHash(this)}{aperture=$aperture, exposureTime=$exposureTime, focalLength=$focalLength, iso=$iso}'; -} - -@immutable -class AddressDetails { - final int? contentId; - final String? countryCode, countryName, adminArea, locality; - - String? get place => locality != null && locality!.isNotEmpty ? locality : adminArea; - - const AddressDetails({ - this.contentId, - this.countryCode, - this.countryName, - this.adminArea, - this.locality, - }); - - AddressDetails copyWith({ - int? contentId, - }) { - return AddressDetails( - contentId: contentId ?? this.contentId, - countryCode: countryCode, - countryName: countryName, - adminArea: adminArea, - locality: locality, - ); - } - - factory AddressDetails.fromMap(Map map) { - return AddressDetails( - contentId: map['contentId'] as int?, - countryCode: map['countryCode'] as String?, - countryName: map['countryName'] as String?, - adminArea: map['adminArea'] as String?, - locality: map['locality'] as String?, - ); - } - - Map toMap() => { - 'contentId': contentId, - 'countryCode': countryCode, - 'countryName': countryName, - 'adminArea': adminArea, - 'locality': locality, - }; - - @override - String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, countryCode=$countryCode, countryName=$countryName, adminArea=$adminArea, locality=$locality}'; -} diff --git a/lib/model/metadata/date_modifier.dart b/lib/model/metadata/date_modifier.dart new file mode 100644 index 000000000..9c7e364f7 --- /dev/null +++ b/lib/model/metadata/date_modifier.dart @@ -0,0 +1,20 @@ +import 'package:aves/model/metadata/enums.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +@immutable +class DateModifier { + static const allDateFields = [ + MetadataField.exifDate, + MetadataField.exifDateOriginal, + MetadataField.exifDateDigitized, + MetadataField.exifGpsDate, + ]; + + final DateEditAction action; + final Set fields; + final DateTime? dateTime; + final int? shiftMinutes; + + const DateModifier(this.action, this.fields, {this.dateTime, this.shiftMinutes}); +} diff --git a/lib/model/metadata/enums.dart b/lib/model/metadata/enums.dart new file mode 100644 index 000000000..131cb1c0c --- /dev/null +++ b/lib/model/metadata/enums.dart @@ -0,0 +1,12 @@ +enum MetadataField { + exifDate, + exifDateOriginal, + exifDateDigitized, + exifGpsDate, +} + +enum DateEditAction { + set, + shift, + clear, +} diff --git a/lib/model/metadata/overlay.dart b/lib/model/metadata/overlay.dart new file mode 100644 index 000000000..bebcb6e1c --- /dev/null +++ b/lib/model/metadata/overlay.dart @@ -0,0 +1,32 @@ +import 'package:flutter/foundation.dart'; +import 'package:intl/intl.dart'; + +class OverlayMetadata { + final String? aperture, exposureTime, focalLength, iso; + + static final apertureFormat = NumberFormat('0.0', 'en_US'); + static final focalLengthFormat = NumberFormat('0.#', 'en_US'); + + OverlayMetadata({ + double? aperture, + this.exposureTime, + double? focalLength, + int? iso, + }) : aperture = aperture != null ? 'ƒ/${apertureFormat.format(aperture)}' : null, + focalLength = focalLength != null ? '${focalLengthFormat.format(focalLength)} mm' : null, + iso = iso != null ? 'ISO$iso' : null; + + factory OverlayMetadata.fromMap(Map map) { + return OverlayMetadata( + aperture: map['aperture'] as double?, + exposureTime: map['exposureTime'] as String?, + focalLength: map['focalLength'] as double?, + iso: map['iso'] as int?, + ); + } + + bool get isEmpty => aperture == null && exposureTime == null && focalLength == null && iso == null; + + @override + String toString() => '$runtimeType#${shortHash(this)}{aperture=$aperture, exposureTime=$exposureTime, focalLength=$focalLength, iso=$iso}'; +} diff --git a/lib/model/metadata_db.dart b/lib/model/metadata_db.dart index a46558835..defabddf4 100644 --- a/lib/model/metadata_db.dart +++ b/lib/model/metadata_db.dart @@ -4,7 +4,8 @@ import 'package:aves/model/covers.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/favourites.dart'; import 'package:aves/model/filters/filters.dart'; -import 'package:aves/model/metadata.dart'; +import 'package:aves/model/metadata/address.dart'; +import 'package:aves/model/metadata/catalog.dart'; import 'package:aves/model/metadata_db_upgrade.dart'; import 'package:aves/services/services.dart'; import 'package:collection/collection.dart'; diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index d99d9e652..fb6ff46f3 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -7,6 +7,7 @@ import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/tag.dart'; +import 'package:aves/model/metadata/date_modifier.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/album.dart'; import 'package:aves/model/source/enums.dart'; @@ -157,6 +158,14 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM } } + Future editEntryDate(AvesEntry entry, DateModifier modifier) async { + final success = await imageFileService.editDate(entry, modifier); + if (!success) return false; + + await entry.catalog(background: false, force: true); + return true; + } + Future renameEntry(AvesEntry entry, String newName, {required bool persist}) async { if (newName == entry.filenameWithoutExtension) return true; final newFields = await imageFileService.rename(entry, '$newName${entry.extension}'); diff --git a/lib/model/source/location.dart b/lib/model/source/location.dart index 05ec1b788..a45564588 100644 --- a/lib/model/source/location.dart +++ b/lib/model/source/location.dart @@ -3,7 +3,7 @@ import 'dart:math'; import 'package:aves/geo/countries.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/location.dart'; -import 'package:aves/model/metadata.dart'; +import 'package:aves/model/metadata/address.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; import 'package:aves/services/services.dart'; diff --git a/lib/model/source/tag.dart b/lib/model/source/tag.dart index 505179730..a08575ef0 100644 --- a/lib/model/source/tag.dart +++ b/lib/model/source/tag.dart @@ -1,6 +1,6 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/tag.dart'; -import 'package:aves/model/metadata.dart'; +import 'package:aves/model/metadata/catalog.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; import 'package:aves/services/services.dart'; diff --git a/lib/model/video/metadata.dart b/lib/model/video/metadata.dart index bdb23dbb3..2a792dc7e 100644 --- a/lib/model/video/metadata.dart +++ b/lib/model/video/metadata.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'package:aves/model/entry.dart'; -import 'package:aves/model/metadata.dart'; +import 'package:aves/model/metadata/catalog.dart'; import 'package:aves/model/video/channel_layouts.dart'; import 'package:aves/model/video/codecs.dart'; import 'package:aves/model/video/keys.dart'; diff --git a/lib/ref/mime_types.dart b/lib/ref/mime_types.dart index f3a6e5601..62d576670 100644 --- a/lib/ref/mime_types.dart +++ b/lib/ref/mime_types.dart @@ -57,7 +57,7 @@ class MimeTypes { static const Set rawImages = {arw, cr2, crw, dcr, dng, erf, k25, kdc, mrw, nef, nrw, orf, pef, raf, raw, rw2, sr2, srf, srw, x3f}; - // TODO TLAD make it dynamic if it depends on OS/lib versions + // TODO TLAD [codec] make it dynamic if it depends on OS/lib versions static const Set undecodableImages = {art, crw, djvu, psdVnd, psdX}; static const Set _knownOpaqueImages = {heic, heif, jpeg}; diff --git a/lib/services/image_file_service.dart b/lib/services/image_file_service.dart index c7ebb2c79..8c6853b9c 100644 --- a/lib/services/image_file_service.dart +++ b/lib/services/image_file_service.dart @@ -4,6 +4,8 @@ import 'dart:typed_data'; import 'dart:ui'; import 'package:aves/model/entry.dart'; +import 'package:aves/model/metadata/date_modifier.dart'; +import 'package:aves/model/metadata/enums.dart'; import 'package:aves/ref/mime_types.dart'; import 'package:aves/services/image_op_events.dart'; import 'package:aves/services/output_buffer.dart'; @@ -94,6 +96,8 @@ abstract class ImageFileService { Future> rotate(AvesEntry entry, {required bool clockwise}); Future> flip(AvesEntry entry); + + Future editDate(AvesEntry entry, DateModifier modifier); } class PlatformImageFileService implements ImageFileService { @@ -408,4 +412,33 @@ class PlatformImageFileService implements ImageFileService { } return {}; } + + @override + Future editDate(AvesEntry entry, DateModifier modifier) async { + try { + final result = await platform.invokeMethod('editDate', { + 'entry': _toPlatformEntryMap(entry), + 'dateMillis': modifier.dateTime?.millisecondsSinceEpoch, + 'shiftMinutes': modifier.shiftMinutes, + 'fields': modifier.fields.map(_toExifInterfaceTag).toList(), + }); + if (result != null) return result as bool; + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); + } + return false; + } + + String _toExifInterfaceTag(MetadataField field) { + switch (field) { + case MetadataField.exifDate: + return 'DateTime'; + case MetadataField.exifDateOriginal: + return 'DateTimeOriginal'; + case MetadataField.exifDateDigitized: + return 'DateTimeDigitized'; + case MetadataField.exifGpsDate: + return 'GPSDateStamp'; + } + } } diff --git a/lib/services/metadata_service.dart b/lib/services/metadata_service.dart index d6ab7c509..80c023077 100644 --- a/lib/services/metadata_service.dart +++ b/lib/services/metadata_service.dart @@ -1,5 +1,6 @@ import 'package:aves/model/entry.dart'; -import 'package:aves/model/metadata.dart'; +import 'package:aves/model/metadata/catalog.dart'; +import 'package:aves/model/metadata/overlay.dart'; import 'package:aves/model/multipage.dart'; import 'package:aves/model/panorama.dart'; import 'package:aves/services/service_policy.dart'; diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index 05f4c5d43..a23273655 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -40,6 +40,7 @@ class AIcons { static const IconData copy = Icons.file_copy_outlined; static const IconData debug = Icons.whatshot_outlined; static const IconData delete = Icons.delete_outlined; + static const IconData edit = Icons.edit_outlined; static const IconData export = MdiIcons.fileExportOutline; static const IconData flip = Icons.flip_outlined; static const IconData favourite = Icons.favorite_border; diff --git a/lib/widgets/debug/database.dart b/lib/widgets/debug/database.dart index a3f97fb7e..87811ec72 100644 --- a/lib/widgets/debug/database.dart +++ b/lib/widgets/debug/database.dart @@ -1,7 +1,8 @@ import 'package:aves/model/covers.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/favourites.dart'; -import 'package:aves/model/metadata.dart'; +import 'package:aves/model/metadata/address.dart'; +import 'package:aves/model/metadata/catalog.dart'; import 'package:aves/services/services.dart'; import 'package:aves/utils/file_utils.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; diff --git a/lib/widgets/dialogs/aves_dialog.dart b/lib/widgets/dialogs/aves_dialog.dart index 57abc2199..6be5d79a4 100644 --- a/lib/widgets/dialogs/aves_dialog.dart +++ b/lib/widgets/dialogs/aves_dialog.dart @@ -31,28 +31,7 @@ class AvesDialog extends AlertDialog { // scroll both the title and the content together, // and overflow feedback ignores the dialog shape, // so we restrict scrolling to the content instead - content: scrollableContent != null - ? Container( - // padding to avoid transparent border overlapping - padding: const EdgeInsets.symmetric(horizontal: borderWidth), - // workaround because the dialog tries - // to size itself to the content intrinsic size, - // but the `ListView` viewport does not have one - width: 1, - child: DecoratedBox( - decoration: BoxDecoration( - border: Border( - bottom: Divider.createBorderSide(context, width: borderWidth), - ), - ), - child: ListView( - controller: scrollController ?? ScrollController(), - shrinkWrap: true, - children: scrollableContent, - ), - ), - ) - : content, + content: _buildContent(context, scrollController, scrollableContent, content), contentPadding: scrollableContent != null ? EdgeInsets.zero : const EdgeInsets.fromLTRB(24, 20, 24, 0), actions: actions, actionsPadding: const EdgeInsets.symmetric(horizontal: 8), @@ -61,6 +40,57 @@ class AvesDialog extends AlertDialog { borderRadius: const BorderRadius.all(Radius.circular(24)), ), ); + + static Widget _buildContent( + BuildContext context, + ScrollController? scrollController, + List? scrollableContent, + Widget? content, + ) { + if (content != null) { + return content; + } + + if (scrollableContent != null) { + scrollController ??= ScrollController(); + return Container( + // padding to avoid transparent border overlapping + padding: const EdgeInsets.symmetric(horizontal: borderWidth), + // workaround because the dialog tries + // to size itself to the content intrinsic size, + // but the `ListView` viewport does not have one + width: 1, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border( + bottom: Divider.createBorderSide(context, width: borderWidth), + ), + ), + child: Theme( + data: Theme.of(context).copyWith( + scrollbarTheme: const ScrollbarThemeData( + isAlwaysShown: true, + radius: Radius.circular(16), + crossAxisMargin: 4, + mainAxisMargin: 4, + interactive: true, + ), + ), + child: Scrollbar( + controller: scrollController, + child: ListView( + controller: scrollController, + shrinkWrap: true, + children: scrollableContent, + ), + ), + ), + ), + ); + } + + return const SizedBox(); + } } class DialogTitle extends StatelessWidget { diff --git a/lib/widgets/dialogs/edit_entry_date_dialog.dart b/lib/widgets/dialogs/edit_entry_date_dialog.dart index 80b859ef8..957293fef 100644 --- a/lib/widgets/dialogs/edit_entry_date_dialog.dart +++ b/lib/widgets/dialogs/edit_entry_date_dialog.dart @@ -1,86 +1,420 @@ -import 'dart:io'; - import 'package:aves/model/entry.dart'; -import 'package:aves/services/services.dart'; +import 'package:aves/model/metadata/date_modifier.dart'; +import 'package:aves/model/metadata/enums.dart'; +import 'package:aves/theme/format.dart'; +import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/material.dart'; import 'aves_dialog.dart'; -class RenameEntryDialog extends StatefulWidget { +class EditEntryDateDialog extends StatefulWidget { final AvesEntry entry; - const RenameEntryDialog({ + const EditEntryDateDialog({ Key? key, required this.entry, }) : super(key: key); @override - _RenameEntryDialogState createState() => _RenameEntryDialogState(); + _EditEntryDateDialogState createState() => _EditEntryDateDialogState(); } -class _RenameEntryDialogState extends State { - final TextEditingController _nameController = TextEditingController(); - final ValueNotifier _isValidNotifier = ValueNotifier(false); +class _EditEntryDateDialogState extends State { + DateEditAction _action = DateEditAction.set; + late Set _fields; + late DateTime _dateTime; + int _shiftMinutes = 60; + bool _showOptions = false; AvesEntry get entry => widget.entry; @override void initState() { super.initState(); - _nameController.text = entry.filenameWithoutExtension ?? entry.sourceTitle ?? ''; - _validate(); - } - - @override - void dispose() { - _nameController.dispose(); - super.dispose(); + _fields = { + MetadataField.exifDate, + MetadataField.exifDateDigitized, + MetadataField.exifDateOriginal, + }; + _dateTime = entry.bestDate ?? DateTime.now(); } @override Widget build(BuildContext context) { + final l10n = context.l10n; + void _updateAction(DateEditAction? action) { + if (action == null) return; + setState(() => _action = action); + } + + Widget _tileText(String text) => Text( + text, + softWrap: false, + overflow: TextOverflow.fade, + maxLines: 1, + ); + + final setTile = Row( + children: [ + Expanded( + child: RadioListTile( + value: DateEditAction.set, + groupValue: _action, + onChanged: _updateAction, + title: _tileText(l10n.editEntryDateDialogSet), + subtitle: Text(formatDateTime(_dateTime, l10n.localeName)), + ), + ), + Padding( + padding: const EdgeInsetsDirectional.only(end: 12), + child: IconButton( + icon: const Icon(AIcons.edit), + onPressed: _action == DateEditAction.set ? _editDate : null, + tooltip: context.l10n.changeTooltip, + ), + ), + ], + ); + final shiftTile = Row( + children: [ + Expanded( + child: RadioListTile( + value: DateEditAction.shift, + groupValue: _action, + onChanged: _updateAction, + title: _tileText(l10n.editEntryDateDialogShift), + subtitle: Text(_formatShiftDuration()), + ), + ), + Padding( + padding: const EdgeInsetsDirectional.only(end: 12), + child: IconButton( + icon: const Icon(AIcons.edit), + onPressed: _action == DateEditAction.shift ? _editShift : null, + tooltip: context.l10n.changeTooltip, + ), + ), + ], + ); + final clearTile = RadioListTile( + value: DateEditAction.clear, + groupValue: _action, + onChanged: _updateAction, + title: _tileText(l10n.editEntryDateDialogClear), + ); + + final theme = Theme.of(context); + return Theme( + data: theme.copyWith( + textTheme: theme.textTheme.copyWith( + // dense style font for tile subtitles, without modifying title font + bodyText2: const TextStyle(fontSize: 12), + ), + ), + child: AvesDialog( + context: context, + title: context.l10n.editEntryDateDialogTitle, + scrollableContent: [ + setTile, + shiftTile, + clearTile, + Padding( + padding: const EdgeInsets.only(bottom: 1), + child: ExpansionPanelList( + expansionCallback: (index, isExpanded) { + setState(() => _showOptions = !isExpanded); + }, + expandedHeaderPadding: EdgeInsets.zero, + elevation: 0, + children: [ + ExpansionPanel( + headerBuilder: (context, isExpanded) => ListTile( + title: Text(l10n.editEntryDateDialogFieldSelection), + ), + body: Column( + children: DateModifier.allDateFields + .map((field) => SwitchListTile( + value: _fields.contains(field), + onChanged: (selected) => setState(() => selected ? _fields.add(field) : _fields.remove(field)), + title: Text(_fieldTitle(field)), + )) + .toList(), + ), + isExpanded: _showOptions, + canTapOnHeader: true, + backgroundColor: Theme.of(context).dialogBackgroundColor, + ), + ], + ), + ), + ], + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), + ), + TextButton( + onPressed: () => _submit(context), + child: Text(context.l10n.applyButtonLabel), + ), + ], + ), + ); + } + + String _formatShiftDuration() { + final abs = _shiftMinutes.abs(); + final h = abs ~/ 60; + final m = abs % 60; + return '${_shiftMinutes.isNegative ? '-' : '+'}$h:${m.toString().padLeft(2, '0')}'; + } + + String _fieldTitle(MetadataField field) { + switch (field) { + case MetadataField.exifDate: + return 'Exif date'; + case MetadataField.exifDateOriginal: + return 'Exif original date'; + case MetadataField.exifDateDigitized: + return 'Exif digitized date'; + case MetadataField.exifGpsDate: + return 'Exif GPS date'; + } + } + + Future _editDate() async { + final _date = await showDatePicker( + context: context, + initialDate: _dateTime, + firstDate: DateTime(0), + lastDate: DateTime.now(), + confirmText: context.l10n.nextButtonLabel, + ); + if (_date == null) return; + + final _time = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(_dateTime), + ); + if (_time == null) return; + + setState(() => _dateTime = DateTime( + _date.year, + _date.month, + _date.day, + _time.hour, + _time.minute, + )); + } + + void _editShift() async { + final picked = await showDialog( + context: context, + builder: (context) => TimeShiftDialog( + initialShiftMinutes: _shiftMinutes, + ), + ); + if (picked == null) return; + + setState(() => _shiftMinutes = picked); + } + + void _submit(BuildContext context) { + late DateModifier modifier; + switch (_action) { + case DateEditAction.set: + modifier = DateModifier(_action, _fields, dateTime: _dateTime); + break; + case DateEditAction.shift: + modifier = DateModifier(_action, _fields, shiftMinutes: _shiftMinutes); + break; + case DateEditAction.clear: + modifier = DateModifier(_action, _fields); + break; + } + Navigator.pop(context, modifier); + } +} + +class TimeShiftDialog extends StatefulWidget { + final int initialShiftMinutes; + + const TimeShiftDialog({ + Key? key, + required this.initialShiftMinutes, + }) : super(key: key); + + @override + _TimeShiftDialogState createState() => _TimeShiftDialogState(); +} + +class _TimeShiftDialogState extends State { + late ValueNotifier _hour, _minute; + late ValueNotifier _sign; + + @override + void initState() { + super.initState(); + final initial = widget.initialShiftMinutes; + final abs = initial.abs(); + _hour = ValueNotifier(abs ~/ 60); + _minute = ValueNotifier(abs % 60); + _sign = ValueNotifier(initial.isNegative ? '-' : '+'); + } + + @override + Widget build(BuildContext context) { + const textStyle = TextStyle(fontSize: 34); return AvesDialog( context: context, - content: TextField( - controller: _nameController, - decoration: InputDecoration( - labelText: context.l10n.renameEntryDialogLabel, - suffixText: entry.extension, + scrollableContent: [ + Center( + child: Padding( + padding: const EdgeInsets.only(top: 8), + child: Table( + children: [ + TableRow( + children: [ + const SizedBox(), + Center(child: Text(context.l10n.editEntryDateDialogHours)), + const SizedBox(), + Center(child: Text(context.l10n.editEntryDateDialogMinutes)), + ], + ), + TableRow( + children: [ + _Wheel( + valueNotifier: _sign, + values: const ['+', '-'], + textStyle: textStyle, + textAlign: TextAlign.center, + ), + Align( + alignment: Alignment.centerRight, + child: _Wheel( + valueNotifier: _hour, + values: List.generate(24, (i) => i), + textStyle: textStyle, + textAlign: TextAlign.end, + ), + ), + const Padding( + padding: EdgeInsets.only(bottom: 2), + child: Text( + ':', + style: textStyle, + ), + ), + Align( + alignment: Alignment.centerLeft, + child: _Wheel( + valueNotifier: _minute, + values: List.generate(60, (i) => i), + textStyle: textStyle, + textAlign: TextAlign.end, + ), + ), + ], + ) + ], + defaultColumnWidth: const IntrinsicColumnWidth(), + defaultVerticalAlignment: TableCellVerticalAlignment.middle, + ), + ), ), - autofocus: true, - onChanged: (_) => _validate(), - onSubmitted: (_) => _submit(context), - ), + ], actions: [ TextButton( onPressed: () => Navigator.pop(context), child: Text(MaterialLocalizations.of(context).cancelButtonLabel), ), - ValueListenableBuilder( - valueListenable: _isValidNotifier, - builder: (context, isValid, child) { - return TextButton( - onPressed: isValid ? () => _submit(context) : null, - child: Text(context.l10n.applyButtonLabel), - ); - }, - ) + TextButton( + onPressed: () => Navigator.pop(context, (_hour.value * 60 + _minute.value) * (_sign.value == '+' ? 1 : -1)), + child: Text(MaterialLocalizations.of(context).okButtonLabel), + ), ], ); } - - String _buildEntryPath(String name) { - if (name.isEmpty) return ''; - return pContext.join(entry.directory!, name + entry.extension!); - } - - Future _validate() async { - final newName = _nameController.text; - final path = _buildEntryPath(newName); - final exists = newName.isNotEmpty && await FileSystemEntity.type(path) != FileSystemEntityType.notFound; - _isValidNotifier.value = newName.isNotEmpty && !exists; - } - - void _submit(BuildContext context) => Navigator.pop(context, _nameController.text); +} + +class _Wheel extends StatefulWidget { + final ValueNotifier valueNotifier; + final List values; + final TextStyle textStyle; + final TextAlign textAlign; + + const _Wheel({ + Key? key, + required this.valueNotifier, + required this.values, + required this.textStyle, + required this.textAlign, + }) : super(key: key); + + @override + _WheelState createState() => _WheelState(); +} + +class _WheelState extends State<_Wheel> { + late final ScrollController _controller; + + static const itemSize = Size(40, 40); + + ValueNotifier get valueNotifier => widget.valueNotifier; + + List get values => widget.values; + + @override + void initState() { + super.initState(); + var indexOf = values.indexOf(valueNotifier.value); + _controller = FixedExtentScrollController( + initialItem: indexOf, + ); + } + + @override + Widget build(BuildContext context) { + final background = Theme.of(context).dialogBackgroundColor; + final foreground = DefaultTextStyle.of(context).style.color!; + + return Padding( + padding: const EdgeInsets.all(8), + child: SizedBox( + width: itemSize.width, + height: itemSize.height * 3, + child: ShaderMask( + shaderCallback: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + background, + foreground, + foreground, + background, + ], + ).createShader, + child: ListWheelScrollView( + controller: _controller, + physics: const FixedExtentScrollPhysics(parent: BouncingScrollPhysics()), + diameterRatio: 1.2, + itemExtent: itemSize.height, + squeeze: 1.3, + onSelectedItemChanged: (i) => valueNotifier.value = values[i], + children: values + .map((i) => SizedBox.fromSize( + size: itemSize, + child: Text( + '$i', + textAlign: widget.textAlign, + style: widget.textStyle, + ), + )) + .toList(), + ), + ), + ), + ); + } } diff --git a/lib/widgets/viewer/debug/db.dart b/lib/widgets/viewer/debug/db.dart index af5f8bc3b..eef41b005 100644 --- a/lib/widgets/viewer/debug/db.dart +++ b/lib/widgets/viewer/debug/db.dart @@ -1,5 +1,6 @@ import 'package:aves/model/entry.dart'; -import 'package:aves/model/metadata.dart'; +import 'package:aves/model/metadata/address.dart'; +import 'package:aves/model/metadata/catalog.dart'; import 'package:aves/services/services.dart'; import 'package:aves/widgets/viewer/info/common.dart'; import 'package:collection/collection.dart'; diff --git a/lib/widgets/viewer/info/basic_section.dart b/lib/widgets/viewer/info/basic_section.dart index 2b7d10767..4a6f42e00 100644 --- a/lib/widgets/viewer/info/basic_section.dart +++ b/lib/widgets/viewer/info/basic_section.dart @@ -40,36 +40,41 @@ class BasicSection extends StatelessWidget { Widget build(BuildContext context) { final l10n = context.l10n; final infoUnknown = l10n.viewerInfoUnknown; - final date = entry.bestDate; final locale = l10n.localeName; - final dateText = date != null ? formatDateTime(date, locale) : infoUnknown; - // TODO TLAD line break on all characters for the following fields when this is fixed: https://github.com/flutter/flutter/issues/61081 - // inserting ZWSP (\u200B) between characters does help, but it messes with width and height computation (another Flutter issue) - final title = entry.bestTitle ?? infoUnknown; - final uri = entry.uri; - final path = entry.path; + return AnimatedBuilder( + animation: entry.metadataChangeNotifier, + builder: (context, child) { + // TODO TLAD line break on all characters for the following fields when this is fixed: https://github.com/flutter/flutter/issues/61081 + // inserting ZWSP (\u200B) between characters does help, but it messes with width and height computation (another Flutter issue) + final title = entry.bestTitle ?? infoUnknown; + final date = entry.bestDate; + final dateText = date != null ? formatDateTime(date, locale) : infoUnknown; + final showResolution = !entry.isSvg && entry.isSized; + final sizeText = entry.sizeBytes != null ? formatFilesize(entry.sizeBytes!) : infoUnknown; + final path = entry.path; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - InfoRowGroup( - info: { - l10n.viewerInfoLabelTitle: title, - l10n.viewerInfoLabelDate: dateText, - if (entry.isVideo) ..._buildVideoRows(context), - if (!entry.isSvg && entry.isSized) l10n.viewerInfoLabelResolution: rasterResolutionText, - l10n.viewerInfoLabelSize: entry.sizeBytes != null ? formatFilesize(entry.sizeBytes!) : infoUnknown, - l10n.viewerInfoLabelUri: uri, - if (path != null) l10n.viewerInfoLabelPath: path, - }, - ), - OwnerProp( - entry: entry, - ), - _buildChips(context), - ], - ); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InfoRowGroup( + info: { + l10n.viewerInfoLabelTitle: title, + l10n.viewerInfoLabelDate: dateText, + if (entry.isVideo) ..._buildVideoRows(context), + if (showResolution) l10n.viewerInfoLabelResolution: rasterResolutionText, + l10n.viewerInfoLabelSize: sizeText, + l10n.viewerInfoLabelUri: entry.uri, + if (path != null) l10n.viewerInfoLabelPath: path, + }, + ), + OwnerProp( + entry: entry, + ), + _buildChips(context), + ], + ); + }); } Widget _buildChips(BuildContext context) { diff --git a/lib/widgets/viewer/info/info_app_bar.dart b/lib/widgets/viewer/info/info_app_bar.dart index 4860026f7..175f86937 100644 --- a/lib/widgets/viewer/info/info_app_bar.dart +++ b/lib/widgets/viewer/info/info_app_bar.dart @@ -1,12 +1,22 @@ +import 'package:aves/model/actions/entry_info_actions.dart'; import 'package:aves/model/entry.dart'; +import 'package:aves/model/metadata/date_modifier.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/action_mixins/feedback.dart'; +import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; import 'package:aves/widgets/common/app_bar_title.dart'; +import 'package:aves/widgets/common/basic/menu.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/dialogs/edit_entry_date_dialog.dart'; import 'package:aves/widgets/viewer/info/info_search.dart'; import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:provider/provider.dart'; -class InfoAppBar extends StatelessWidget { +class InfoAppBar extends StatelessWidget with FeedbackMixin, PermissionAwareMixin { final AvesEntry entry; final ValueNotifier> metadataNotifier; final VoidCallback onBackPressed; @@ -38,6 +48,23 @@ class InfoAppBar extends StatelessWidget { onPressed: () => _goToSearch(context), tooltip: MaterialLocalizations.of(context).searchFieldLabel, ), + MenuIconTheme( + child: PopupMenuButton( + itemBuilder: (context) { + return [ + PopupMenuItem( + value: EntryInfoAction.editDate, + enabled: entry.canEditExif, + child: MenuRow(text: context.l10n.entryInfoActionEditDate, icon: const Icon(AIcons.date)), + ), + ]; + }, + onSelected: (action) { + // wait for the popup menu to hide before proceeding with the action + Future.delayed(Durations.popupMenuAnimation * timeDilation, () => _onActionSelected(context, action)); + }, + ), + ), ], titleSpacing: 0, floating: true, @@ -54,4 +81,30 @@ class InfoAppBar extends StatelessWidget { ), ); } + + void _onActionSelected(BuildContext context, EntryInfoAction action) async { + switch (action) { + case EntryInfoAction.editDate: + await _showDateEditDialog(context); + break; + } + } + + Future _showDateEditDialog(BuildContext context) async { + final modifier = await showDialog( + context: context, + builder: (context) => EditEntryDateDialog(entry: entry), + ); + if (modifier == null) return; + + if (!await checkStoragePermission(context, {entry})) return; + + // TODO TLAD [meta edit] handle viewer mode + final success = await context.read().editEntryDate(entry, modifier); + if (success) { + showFeedback(context, context.l10n.genericSuccessFeedback); + } else { + showFeedback(context, context.l10n.genericFailureFeedback); + } + } } diff --git a/lib/widgets/viewer/overlay/bottom/common.dart b/lib/widgets/viewer/overlay/bottom/common.dart index 6b1552424..b9ed3beda 100644 --- a/lib/widgets/viewer/overlay/bottom/common.dart +++ b/lib/widgets/viewer/overlay/bottom/common.dart @@ -1,7 +1,7 @@ import 'dart:math'; import 'package:aves/model/entry.dart'; -import 'package:aves/model/metadata.dart'; +import 'package:aves/model/metadata/overlay.dart'; import 'package:aves/model/multipage.dart'; import 'package:aves/model/settings/coordinate_format.dart'; import 'package:aves/model/settings/settings.dart'; diff --git a/lib/widgets/welcome_page.dart b/lib/widgets/welcome_page.dart index fb71abbcd..1f4f45a6c 100644 --- a/lib/widgets/welcome_page.dart +++ b/lib/widgets/welcome_page.dart @@ -167,6 +167,7 @@ class _WelcomePageState extends State { child: Theme( data: Theme.of(context).copyWith( scrollbarTheme: const ScrollbarThemeData( + isAlwaysShown: true, radius: Radius.circular(16), crossAxisMargin: 6, mainAxisMargin: 16, diff --git a/test/fake/metadata_db.dart b/test/fake/metadata_db.dart index 3584897d5..2506f8813 100644 --- a/test/fake/metadata_db.dart +++ b/test/fake/metadata_db.dart @@ -2,7 +2,8 @@ import 'package:aves/model/covers.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/favourites.dart'; import 'package:aves/model/filters/filters.dart'; -import 'package:aves/model/metadata.dart'; +import 'package:aves/model/metadata/address.dart'; +import 'package:aves/model/metadata/catalog.dart'; import 'package:aves/model/metadata_db.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; diff --git a/test/fake/metadata_service.dart b/test/fake/metadata_service.dart index 684e41d9d..fb3eaf13d 100644 --- a/test/fake/metadata_service.dart +++ b/test/fake/metadata_service.dart @@ -1,5 +1,5 @@ import 'package:aves/model/entry.dart'; -import 'package:aves/model/metadata.dart'; +import 'package:aves/model/metadata/catalog.dart'; import 'package:aves/services/metadata_service.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart';