info: edit exif date
This commit is contained in:
parent
84999053eb
commit
1c4db4d8e7
36 changed files with 934 additions and 247 deletions
|
@ -160,9 +160,9 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
try {
|
try {
|
||||||
val embedBytes: ByteArray = if (!dataPropPath.contains('/')) {
|
val embedBytes: ByteArray = if (!dataPropPath.contains('/')) {
|
||||||
val propNs = XMP.namespaceForPropPath(dataPropPath)
|
val propNs = XMP.namespaceForPropPath(dataPropPath)
|
||||||
xmpDirs.map { it.xmpMeta.getPropertyBase64(propNs, dataPropPath) }.filterNotNull().first()
|
xmpDirs.mapNotNull { it.xmpMeta.getPropertyBase64(propNs, dataPropPath) }.first()
|
||||||
} else {
|
} else {
|
||||||
xmpDirs.map { it.xmpMeta.getSafeStructField(dataPropPath) }.filterNotNull().first().let {
|
xmpDirs.mapNotNull { it.xmpMeta.getSafeStructField(dataPropPath) }.first().let {
|
||||||
XMPUtils.decodeBase64(it.value)
|
XMPUtils.decodeBase64(it.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -211,9 +211,9 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
)
|
)
|
||||||
if (isImage(mimeType) || isVideo(mimeType)) {
|
if (isImage(mimeType) || isVideo(mimeType)) {
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
ContentImageProvider().fetchSingle(context, uri, mimeType, object : ImageProvider.ImageOpCallback {
|
ContentImageProvider().fetchSingle(context, uri, mimeType, object : ImageProvider.ImageOpCallback<FieldMap> {
|
||||||
override fun onSuccess(fields: FieldMap) {
|
override fun onSuccess(res: FieldMap) {
|
||||||
resultFields.putAll(fields)
|
resultFields.putAll(res)
|
||||||
result.success(resultFields)
|
result.success(resultFields)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -38,6 +38,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
|
||||||
"rename" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::rename) }
|
"rename" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::rename) }
|
||||||
"rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) }
|
"rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) }
|
||||||
"flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) }
|
"flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) }
|
||||||
|
"editDate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::editDate) }
|
||||||
"clearSizedThumbnailDiskCache" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::clearSizedThumbnailDiskCache) }
|
"clearSizedThumbnailDiskCache" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::clearSizedThumbnailDiskCache) }
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
|
@ -57,8 +58,8 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
provider.fetchSingle(activity, uri, mimeType, object : ImageOpCallback {
|
provider.fetchSingle(activity, uri, mimeType, object : ImageOpCallback<FieldMap> {
|
||||||
override fun onSuccess(fields: FieldMap) = result.success(fields)
|
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)
|
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)
|
destinationDir = ensureTrailingSeparator(destinationDir)
|
||||||
provider.captureFrame(activity, desiredName, exifFields, bytes, destinationDir, object : ImageOpCallback {
|
provider.captureFrame(activity, desiredName, exifFields, bytes, destinationDir, object : ImageOpCallback<FieldMap> {
|
||||||
override fun onSuccess(fields: FieldMap) = result.success(fields)
|
override fun onSuccess(res: FieldMap) = result.success(res)
|
||||||
override fun onFailure(throwable: Throwable) = result.error("captureFrame-failure", "failed to capture frame", throwable.message)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
provider.rename(activity, path, uri, mimeType, newName, object : ImageOpCallback {
|
provider.rename(activity, path, uri, mimeType, newName, object : ImageOpCallback<FieldMap> {
|
||||||
override fun onSuccess(fields: FieldMap) = result.success(fields)
|
override fun onSuccess(res: FieldMap) = result.success(res)
|
||||||
override fun onFailure(throwable: Throwable) = result.error("rename-failure", "failed to rename", throwable.message)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
provider.changeOrientation(activity, path, uri, mimeType, sizeBytes, op, object : ImageOpCallback {
|
provider.changeOrientation(activity, path, uri, mimeType, sizeBytes, op, object : ImageOpCallback<FieldMap> {
|
||||||
override fun onSuccess(fields: FieldMap) = result.success(fields)
|
override fun onSuccess(res: FieldMap) = result.success(res)
|
||||||
override fun onFailure(throwable: Throwable) = result.error("changeOrientation-failure", "failed to change orientation", throwable.message)
|
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<Number>("dateMillis")?.toLong()
|
||||||
|
val shiftMinutes = call.argument<Number>("shiftMinutes")?.toLong()
|
||||||
|
val fields = call.argument<List<String>>("fields")
|
||||||
|
val entryMap = call.argument<FieldMap>("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<Boolean> {
|
||||||
|
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) {
|
private fun clearSizedThumbnailDiskCache(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
|
||||||
Glide.get(activity).clearDiskCache()
|
Glide.get(activity).clearDiskCache()
|
||||||
result.success(null)
|
result.success(null)
|
||||||
|
|
|
@ -138,8 +138,8 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
|
||||||
|
|
||||||
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
|
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
|
||||||
val entries = entryMapList.map(::AvesEntry)
|
val entries = entryMapList.map(::AvesEntry)
|
||||||
provider.exportMultiple(activity, mimeType, destinationDir, entries, object : ImageOpCallback {
|
provider.exportMultiple(activity, mimeType, destinationDir, entries, object : ImageOpCallback<FieldMap> {
|
||||||
override fun onSuccess(fields: FieldMap) = success(fields)
|
override fun onSuccess(res: FieldMap) = success(res)
|
||||||
override fun onFailure(throwable: Throwable) = error("export-failure", "failed to export entries", throwable)
|
override fun onFailure(throwable: Throwable) = error("export-failure", "failed to export entries", throwable)
|
||||||
})
|
})
|
||||||
endOfStream()
|
endOfStream()
|
||||||
|
@ -168,8 +168,8 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
|
||||||
|
|
||||||
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
|
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
|
||||||
val entries = entryMapList.map(::AvesEntry)
|
val entries = entryMapList.map(::AvesEntry)
|
||||||
provider.moveMultiple(activity, copy, destinationDir, entries, object : ImageOpCallback {
|
provider.moveMultiple(activity, copy, destinationDir, entries, object : ImageOpCallback<FieldMap> {
|
||||||
override fun onSuccess(fields: FieldMap) = success(fields)
|
override fun onSuccess(res: FieldMap) = success(res)
|
||||||
override fun onFailure(throwable: Throwable) = error("move-failure", "failed to move entries", throwable)
|
override fun onFailure(throwable: Throwable) = error("move-failure", "failed to move entries", throwable)
|
||||||
})
|
})
|
||||||
endOfStream()
|
endOfStream()
|
||||||
|
|
|
@ -19,6 +19,8 @@ import kotlin.math.roundToLong
|
||||||
object ExifInterfaceHelper {
|
object ExifInterfaceHelper {
|
||||||
private val LOG_TAG = LogUtils.createTag<ExifInterfaceHelper>()
|
private val LOG_TAG = LogUtils.createTag<ExifInterfaceHelper>()
|
||||||
val DATETIME_FORMAT = SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.ROOT)
|
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
|
private const val precisionErrorTolerance = 1e-10
|
||||||
|
|
||||||
|
|
|
@ -9,13 +9,14 @@ import com.drew.imaging.ImageMetadataReader
|
||||||
import com.drew.metadata.file.FileTypeDirectory
|
import com.drew.metadata.file.FileTypeDirectory
|
||||||
import deckers.thibault.aves.metadata.Metadata
|
import deckers.thibault.aves.metadata.Metadata
|
||||||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString
|
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString
|
||||||
|
import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.model.SourceEntry
|
import deckers.thibault.aves.model.SourceEntry
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import deckers.thibault.aves.utils.MimeTypes
|
import deckers.thibault.aves.utils.MimeTypes
|
||||||
import deckers.thibault.aves.utils.StorageUtils
|
import deckers.thibault.aves.utils.StorageUtils
|
||||||
|
|
||||||
internal class ContentImageProvider : ImageProvider() {
|
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<FieldMap>) {
|
||||||
// source MIME type may be incorrect, so we get a second opinion if possible
|
// source MIME type may be incorrect, so we get a second opinion if possible
|
||||||
var extractorMimeType: String? = null
|
var extractorMimeType: String? = null
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -2,11 +2,12 @@ package deckers.thibault.aves.model.provider
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.model.SourceEntry
|
import deckers.thibault.aves.model.SourceEntry
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
internal class FileImageProvider : ImageProvider() {
|
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<FieldMap>) {
|
||||||
if (sourceMimeType == null) {
|
if (sourceMimeType == null) {
|
||||||
callback.onFailure(Exception("MIME type is null for uri=$uri"))
|
callback.onFailure(Exception("MIME type is null for uri=$uri"))
|
||||||
return
|
return
|
||||||
|
|
|
@ -18,6 +18,7 @@ import com.commonsware.cwac.document.DocumentFileCompat
|
||||||
import deckers.thibault.aves.decoder.MultiTrackImage
|
import deckers.thibault.aves.decoder.MultiTrackImage
|
||||||
import deckers.thibault.aves.decoder.TiffImage
|
import deckers.thibault.aves.decoder.TiffImage
|
||||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper
|
import deckers.thibault.aves.metadata.ExifInterfaceHelper
|
||||||
|
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
|
||||||
import deckers.thibault.aves.metadata.MultiPage
|
import deckers.thibault.aves.metadata.MultiPage
|
||||||
import deckers.thibault.aves.model.AvesEntry
|
import deckers.thibault.aves.model.AvesEntry
|
||||||
import deckers.thibault.aves.model.ExifOrientationOp
|
import deckers.thibault.aves.model.ExifOrientationOp
|
||||||
|
@ -39,7 +40,7 @@ import kotlin.coroutines.resumeWithException
|
||||||
import kotlin.coroutines.suspendCoroutine
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
|
||||||
abstract class ImageProvider {
|
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<FieldMap>) {
|
||||||
callback.onFailure(UnsupportedOperationException())
|
callback.onFailure(UnsupportedOperationException())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -47,7 +48,7 @@ abstract class ImageProvider {
|
||||||
throw UnsupportedOperationException()
|
throw UnsupportedOperationException()
|
||||||
}
|
}
|
||||||
|
|
||||||
open suspend fun moveMultiple(activity: Activity, copy: Boolean, destinationDir: String, entries: List<AvesEntry>, callback: ImageOpCallback) {
|
open suspend fun moveMultiple(activity: Activity, copy: Boolean, destinationDir: String, entries: List<AvesEntry>, callback: ImageOpCallback<FieldMap>) {
|
||||||
callback.onFailure(UnsupportedOperationException())
|
callback.onFailure(UnsupportedOperationException())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -56,7 +57,7 @@ abstract class ImageProvider {
|
||||||
imageExportMimeType: String,
|
imageExportMimeType: String,
|
||||||
destinationDir: String,
|
destinationDir: String,
|
||||||
entries: List<AvesEntry>,
|
entries: List<AvesEntry>,
|
||||||
callback: ImageOpCallback,
|
callback: ImageOpCallback<FieldMap>,
|
||||||
) {
|
) {
|
||||||
if (!supportedExportMimeTypes.contains(imageExportMimeType)) {
|
if (!supportedExportMimeTypes.contains(imageExportMimeType)) {
|
||||||
throw Exception("unsupported export MIME type=$imageExportMimeType")
|
throw Exception("unsupported export MIME type=$imageExportMimeType")
|
||||||
|
@ -204,7 +205,7 @@ abstract class ImageProvider {
|
||||||
exifFields: FieldMap,
|
exifFields: FieldMap,
|
||||||
bytes: ByteArray,
|
bytes: ByteArray,
|
||||||
destinationDir: String,
|
destinationDir: String,
|
||||||
callback: ImageOpCallback,
|
callback: ImageOpCallback<FieldMap>,
|
||||||
) {
|
) {
|
||||||
val destinationDirDocFile = createDirectoryIfAbsent(context, destinationDir)
|
val destinationDirDocFile = createDirectoryIfAbsent(context, destinationDir)
|
||||||
if (destinationDirDocFile == null) {
|
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<FieldMap>) {
|
||||||
val oldFile = File(oldPath)
|
val oldFile = File(oldPath)
|
||||||
val newFile = File(oldFile.parent, newFilename)
|
val newFile = File(oldFile.parent, newFilename)
|
||||||
if (oldFile == newFile) {
|
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 <T> editExif(
|
||||||
|
context: Context,
|
||||||
|
path: String,
|
||||||
|
uri: Uri,
|
||||||
|
mimeType: String,
|
||||||
|
sizeBytes: Long,
|
||||||
|
callback: ImageOpCallback<T>,
|
||||||
|
editExif: (exif: ExifInterface) -> Unit,
|
||||||
|
): Boolean {
|
||||||
if (!canEditExif(mimeType)) {
|
if (!canEditExif(mimeType)) {
|
||||||
callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType"))
|
callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType"))
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
val originalDocumentFile = getDocumentFile(context, path, uri)
|
val originalDocumentFile = getDocumentFile(context, path, uri)
|
||||||
if (originalDocumentFile == null) {
|
if (originalDocumentFile == null) {
|
||||||
callback.onFailure(Exception("failed to get document file for path=$path, uri=$uri"))
|
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()
|
val videoSizeBytes = MultiPage.getMotionPhotoOffset(context, uri, mimeType, sizeBytes)?.toInt()
|
||||||
|
@ -372,13 +390,33 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
callback.onFailure(e)
|
callback.onFailure(e)
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val newFields = HashMap<String, Any?>()
|
|
||||||
try {
|
try {
|
||||||
val exif = ExifInterface(editableFile)
|
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<FieldMap>) {
|
||||||
|
val newFields = HashMap<String, Any?>()
|
||||||
|
|
||||||
|
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)`
|
// 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
|
// in that case we explicitly set it to `normal` first
|
||||||
// because ExifInterface fails to rotate an image with undefined orientation
|
// because ExifInterface fails to rotate an image with undefined orientation
|
||||||
|
@ -393,20 +431,10 @@ abstract class ImageProvider {
|
||||||
ExifOrientationOp.FLIP -> exif.flipHorizontally()
|
ExifOrientationOp.FLIP -> exif.flipHorizontally()
|
||||||
}
|
}
|
||||||
exif.saveAttributes()
|
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["rotationDegrees"] = exif.rotationDegrees
|
||||||
newFields["isFlipped"] = exif.isFlipped
|
newFields["isFlipped"] = exif.isFlipped
|
||||||
} catch (e: IOException) {
|
|
||||||
callback.onFailure(e)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
if (!success) return
|
||||||
|
|
||||||
MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, _ ->
|
MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, _ ->
|
||||||
val projection = arrayOf(MediaStore.MediaColumns.DATE_MODIFIED)
|
val projection = arrayOf(MediaStore.MediaColumns.DATE_MODIFIED)
|
||||||
|
@ -424,12 +452,97 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// support for writing EXIF
|
fun editDate(
|
||||||
// as of androidx.exifinterface:exifinterface:1.3.0
|
context: Context,
|
||||||
private fun canEditExif(mimeType: String): Boolean {
|
path: String,
|
||||||
return when (mimeType) {
|
uri: Uri,
|
||||||
MimeTypes.JPEG, MimeTypes.PNG, MimeTypes.WEBP -> true
|
mimeType: String,
|
||||||
else -> false
|
sizeBytes: Long,
|
||||||
|
dateMillis: Long?,
|
||||||
|
shiftMinutes: Long?,
|
||||||
|
fields: List<String>,
|
||||||
|
callback: ImageOpCallback<Boolean>,
|
||||||
|
) {
|
||||||
|
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 {
|
interface ImageOpCallback<T> {
|
||||||
fun onSuccess(fields: FieldMap)
|
fun onSuccess(res: T)
|
||||||
fun onFailure(throwable: Throwable)
|
fun onFailure(throwable: Throwable)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -38,7 +38,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
fetchFrom(context, isModified, handleNewEntry, VIDEO_CONTENT_URI, VIDEO_PROJECTION)
|
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<FieldMap>) {
|
||||||
val id = uri.tryParseId()
|
val id = uri.tryParseId()
|
||||||
val onSuccess = fun(entry: FieldMap) {
|
val onSuccess = fun(entry: FieldMap) {
|
||||||
entry["uri"] = uri.toString()
|
entry["uri"] = uri.toString()
|
||||||
|
@ -255,7 +255,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
copy: Boolean,
|
copy: Boolean,
|
||||||
destinationDir: String,
|
destinationDir: String,
|
||||||
entries: List<AvesEntry>,
|
entries: List<AvesEntry>,
|
||||||
callback: ImageOpCallback,
|
callback: ImageOpCallback<FieldMap>,
|
||||||
) {
|
) {
|
||||||
val destinationDirDocFile = createDirectoryIfAbsent(activity, destinationDir)
|
val destinationDirDocFile = createDirectoryIfAbsent(activity, destinationDir)
|
||||||
if (destinationDirDocFile == null) {
|
if (destinationDirDocFile == null) {
|
||||||
|
|
|
@ -18,12 +18,15 @@
|
||||||
"@applyButtonLabel": {},
|
"@applyButtonLabel": {},
|
||||||
"deleteButtonLabel": "DELETE",
|
"deleteButtonLabel": "DELETE",
|
||||||
"@deleteButtonLabel": {},
|
"@deleteButtonLabel": {},
|
||||||
|
"nextButtonLabel": "NEXT",
|
||||||
|
"@nextButtonLabel": {},
|
||||||
"showButtonLabel": "SHOW",
|
"showButtonLabel": "SHOW",
|
||||||
"@showButtonLabel": {},
|
"@showButtonLabel": {},
|
||||||
"hideButtonLabel": "HIDE",
|
"hideButtonLabel": "HIDE",
|
||||||
"@hideButtonLabel": {},
|
"@hideButtonLabel": {},
|
||||||
"continueButtonLabel": "CONTINUE",
|
"continueButtonLabel": "CONTINUE",
|
||||||
"@continueButtonLabel": {},
|
"@continueButtonLabel": {},
|
||||||
|
|
||||||
"changeTooltip": "Change",
|
"changeTooltip": "Change",
|
||||||
"@changeTooltip": {},
|
"@changeTooltip": {},
|
||||||
"clearTooltip": "Clear",
|
"clearTooltip": "Clear",
|
||||||
|
@ -126,6 +129,9 @@
|
||||||
"videoActionSettings": "Settings",
|
"videoActionSettings": "Settings",
|
||||||
"@videoActionSettings": {},
|
"@videoActionSettings": {},
|
||||||
|
|
||||||
|
"entryInfoActionEditDate": "Edit date & time",
|
||||||
|
"@entryInfoActionEditDate": {},
|
||||||
|
|
||||||
"filterFavouriteLabel": "Favourite",
|
"filterFavouriteLabel": "Favourite",
|
||||||
"@filterFavouriteLabel": {},
|
"@filterFavouriteLabel": {},
|
||||||
"filterLocationEmptyLabel": "Unlocated",
|
"filterLocationEmptyLabel": "Unlocated",
|
||||||
|
@ -304,6 +310,21 @@
|
||||||
"renameEntryDialogLabel": "New name",
|
"renameEntryDialogLabel": "New name",
|
||||||
"@renameEntryDialogLabel": {},
|
"@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": "Playback speed",
|
||||||
"@videoSpeedDialogLabel": {},
|
"@videoSpeedDialogLabel": {},
|
||||||
|
|
||||||
|
|
|
@ -7,9 +7,11 @@
|
||||||
|
|
||||||
"applyButtonLabel": "확인",
|
"applyButtonLabel": "확인",
|
||||||
"deleteButtonLabel": "삭제",
|
"deleteButtonLabel": "삭제",
|
||||||
|
"nextButtonLabel": "다음",
|
||||||
"showButtonLabel": "보기",
|
"showButtonLabel": "보기",
|
||||||
"hideButtonLabel": "숨기기",
|
"hideButtonLabel": "숨기기",
|
||||||
"continueButtonLabel": "다음",
|
"continueButtonLabel": "다음",
|
||||||
|
|
||||||
"changeTooltip": "변경",
|
"changeTooltip": "변경",
|
||||||
"clearTooltip": "초기화",
|
"clearTooltip": "초기화",
|
||||||
"previousTooltip": "이전",
|
"previousTooltip": "이전",
|
||||||
|
@ -64,6 +66,8 @@
|
||||||
"videoActionSetSpeed": "재생 배속",
|
"videoActionSetSpeed": "재생 배속",
|
||||||
"videoActionSettings": "설정",
|
"videoActionSettings": "설정",
|
||||||
|
|
||||||
|
"entryInfoActionEditDate": "날짜와 시간 수정",
|
||||||
|
|
||||||
"filterFavouriteLabel": "즐겨찾기",
|
"filterFavouriteLabel": "즐겨찾기",
|
||||||
"filterLocationEmptyLabel": "장소 없음",
|
"filterLocationEmptyLabel": "장소 없음",
|
||||||
"filterTagEmptyLabel": "태그 없음",
|
"filterTagEmptyLabel": "태그 없음",
|
||||||
|
@ -137,6 +141,14 @@
|
||||||
|
|
||||||
"renameEntryDialogLabel": "이름",
|
"renameEntryDialogLabel": "이름",
|
||||||
|
|
||||||
|
"editEntryDateDialogTitle": "날짜 및 시간",
|
||||||
|
"editEntryDateDialogSet": "설정",
|
||||||
|
"editEntryDateDialogShift": "앞뒤로",
|
||||||
|
"editEntryDateDialogClear": "삭제",
|
||||||
|
"editEntryDateDialogFieldSelection": "필드 선택",
|
||||||
|
"editEntryDateDialogHours": "시간",
|
||||||
|
"editEntryDateDialogMinutes": "분",
|
||||||
|
|
||||||
"videoSpeedDialogLabel": "재생 배속",
|
"videoSpeedDialogLabel": "재생 배속",
|
||||||
|
|
||||||
"videoStreamSelectionDialogVideo": "동영상",
|
"videoStreamSelectionDialogVideo": "동영상",
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
enum SettingsAction {
|
enum EntryInfoAction {
|
||||||
export,
|
editDate,
|
||||||
import,
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,8 @@ import 'dart:async';
|
||||||
import 'package:aves/geo/countries.dart';
|
import 'package:aves/geo/countries.dart';
|
||||||
import 'package:aves/model/entry_cache.dart';
|
import 'package:aves/model/entry_cache.dart';
|
||||||
import 'package:aves/model/favourites.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/multipage.dart';
|
||||||
import 'package:aves/model/settings/settings.dart';
|
import 'package:aves/model/settings/settings.dart';
|
||||||
import 'package:aves/model/video/metadata.dart';
|
import 'package:aves/model/video/metadata.dart';
|
||||||
|
@ -413,8 +414,8 @@ class AvesEntry {
|
||||||
addressDetails = null;
|
addressDetails = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> catalog({bool background = false, bool persist = true}) async {
|
Future<void> catalog({bool background = false, bool persist = true, bool force = false}) async {
|
||||||
if (isCatalogued) return;
|
if (isCatalogued && !force) return;
|
||||||
if (isSvg) {
|
if (isSvg) {
|
||||||
// vector image sizing is not essential, so we should not spend time for it during loading
|
// vector image sizing is not essential, so we should not spend time for it during loading
|
||||||
// but it is useful anyway (for aspect ratios etc.) so we size them during cataloguing
|
// but it is useful anyway (for aspect ratios etc.) so we size them during cataloguing
|
||||||
|
|
51
lib/model/metadata/address.dart
Normal file
51
lib/model/metadata/address.dart
Normal file
|
@ -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<String, dynamic> 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}';
|
||||||
|
}
|
|
@ -1,7 +1,5 @@
|
||||||
import 'package:aves/services/geocoding_service.dart';
|
import 'package:aves/services/geocoding_service.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
|
|
||||||
class CatalogMetadata {
|
class CatalogMetadata {
|
||||||
final int? contentId, dateMillis;
|
final int? contentId, dateMillis;
|
||||||
|
@ -107,82 +105,3 @@ class CatalogMetadata {
|
||||||
@override
|
@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}';
|
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<String, dynamic> 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}';
|
|
||||||
}
|
|
20
lib/model/metadata/date_modifier.dart
Normal file
20
lib/model/metadata/date_modifier.dart
Normal file
|
@ -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<MetadataField> fields;
|
||||||
|
final DateTime? dateTime;
|
||||||
|
final int? shiftMinutes;
|
||||||
|
|
||||||
|
const DateModifier(this.action, this.fields, {this.dateTime, this.shiftMinutes});
|
||||||
|
}
|
12
lib/model/metadata/enums.dart
Normal file
12
lib/model/metadata/enums.dart
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
enum MetadataField {
|
||||||
|
exifDate,
|
||||||
|
exifDateOriginal,
|
||||||
|
exifDateDigitized,
|
||||||
|
exifGpsDate,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DateEditAction {
|
||||||
|
set,
|
||||||
|
shift,
|
||||||
|
clear,
|
||||||
|
}
|
32
lib/model/metadata/overlay.dart
Normal file
32
lib/model/metadata/overlay.dart
Normal file
|
@ -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}';
|
||||||
|
}
|
|
@ -4,7 +4,8 @@ import 'package:aves/model/covers.dart';
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/favourites.dart';
|
import 'package:aves/model/favourites.dart';
|
||||||
import 'package:aves/model/filters/filters.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/model/metadata_db_upgrade.dart';
|
||||||
import 'package:aves/services/services.dart';
|
import 'package:aves/services/services.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
|
|
|
@ -7,6 +7,7 @@ import 'package:aves/model/filters/album.dart';
|
||||||
import 'package:aves/model/filters/filters.dart';
|
import 'package:aves/model/filters/filters.dart';
|
||||||
import 'package:aves/model/filters/location.dart';
|
import 'package:aves/model/filters/location.dart';
|
||||||
import 'package:aves/model/filters/tag.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/settings/settings.dart';
|
||||||
import 'package:aves/model/source/album.dart';
|
import 'package:aves/model/source/album.dart';
|
||||||
import 'package:aves/model/source/enums.dart';
|
import 'package:aves/model/source/enums.dart';
|
||||||
|
@ -157,6 +158,14 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> 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<bool> renameEntry(AvesEntry entry, String newName, {required bool persist}) async {
|
Future<bool> renameEntry(AvesEntry entry, String newName, {required bool persist}) async {
|
||||||
if (newName == entry.filenameWithoutExtension) return true;
|
if (newName == entry.filenameWithoutExtension) return true;
|
||||||
final newFields = await imageFileService.rename(entry, '$newName${entry.extension}');
|
final newFields = await imageFileService.rename(entry, '$newName${entry.extension}');
|
||||||
|
|
|
@ -3,7 +3,7 @@ import 'dart:math';
|
||||||
import 'package:aves/geo/countries.dart';
|
import 'package:aves/geo/countries.dart';
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/filters/location.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/collection_source.dart';
|
||||||
import 'package:aves/model/source/enums.dart';
|
import 'package:aves/model/source/enums.dart';
|
||||||
import 'package:aves/services/services.dart';
|
import 'package:aves/services/services.dart';
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/filters/tag.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/collection_source.dart';
|
||||||
import 'package:aves/model/source/enums.dart';
|
import 'package:aves/model/source/enums.dart';
|
||||||
import 'package:aves/services/services.dart';
|
import 'package:aves/services/services.dart';
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:aves/model/entry.dart';
|
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/channel_layouts.dart';
|
||||||
import 'package:aves/model/video/codecs.dart';
|
import 'package:aves/model/video/codecs.dart';
|
||||||
import 'package:aves/model/video/keys.dart';
|
import 'package:aves/model/video/keys.dart';
|
||||||
|
|
|
@ -57,7 +57,7 @@ class MimeTypes {
|
||||||
|
|
||||||
static const Set<String> rawImages = {arw, cr2, crw, dcr, dng, erf, k25, kdc, mrw, nef, nrw, orf, pef, raf, raw, rw2, sr2, srf, srw, x3f};
|
static const Set<String> 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<String> undecodableImages = {art, crw, djvu, psdVnd, psdX};
|
static const Set<String> undecodableImages = {art, crw, djvu, psdVnd, psdX};
|
||||||
|
|
||||||
static const Set<String> _knownOpaqueImages = {heic, heif, jpeg};
|
static const Set<String> _knownOpaqueImages = {heic, heif, jpeg};
|
||||||
|
|
|
@ -4,6 +4,8 @@ import 'dart:typed_data';
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:aves/model/entry.dart';
|
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/ref/mime_types.dart';
|
||||||
import 'package:aves/services/image_op_events.dart';
|
import 'package:aves/services/image_op_events.dart';
|
||||||
import 'package:aves/services/output_buffer.dart';
|
import 'package:aves/services/output_buffer.dart';
|
||||||
|
@ -94,6 +96,8 @@ abstract class ImageFileService {
|
||||||
Future<Map<String, dynamic>> rotate(AvesEntry entry, {required bool clockwise});
|
Future<Map<String, dynamic>> rotate(AvesEntry entry, {required bool clockwise});
|
||||||
|
|
||||||
Future<Map<String, dynamic>> flip(AvesEntry entry);
|
Future<Map<String, dynamic>> flip(AvesEntry entry);
|
||||||
|
|
||||||
|
Future<bool> editDate(AvesEntry entry, DateModifier modifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
class PlatformImageFileService implements ImageFileService {
|
class PlatformImageFileService implements ImageFileService {
|
||||||
|
@ -408,4 +412,33 @@ class PlatformImageFileService implements ImageFileService {
|
||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> editDate(AvesEntry entry, DateModifier modifier) async {
|
||||||
|
try {
|
||||||
|
final result = await platform.invokeMethod('editDate', <String, dynamic>{
|
||||||
|
'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';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:aves/model/entry.dart';
|
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/multipage.dart';
|
||||||
import 'package:aves/model/panorama.dart';
|
import 'package:aves/model/panorama.dart';
|
||||||
import 'package:aves/services/service_policy.dart';
|
import 'package:aves/services/service_policy.dart';
|
||||||
|
|
|
@ -40,6 +40,7 @@ class AIcons {
|
||||||
static const IconData copy = Icons.file_copy_outlined;
|
static const IconData copy = Icons.file_copy_outlined;
|
||||||
static const IconData debug = Icons.whatshot_outlined;
|
static const IconData debug = Icons.whatshot_outlined;
|
||||||
static const IconData delete = Icons.delete_outlined;
|
static const IconData delete = Icons.delete_outlined;
|
||||||
|
static const IconData edit = Icons.edit_outlined;
|
||||||
static const IconData export = MdiIcons.fileExportOutline;
|
static const IconData export = MdiIcons.fileExportOutline;
|
||||||
static const IconData flip = Icons.flip_outlined;
|
static const IconData flip = Icons.flip_outlined;
|
||||||
static const IconData favourite = Icons.favorite_border;
|
static const IconData favourite = Icons.favorite_border;
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import 'package:aves/model/covers.dart';
|
import 'package:aves/model/covers.dart';
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/favourites.dart';
|
import 'package:aves/model/favourites.dart';
|
||||||
import 'package:aves/model/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/services/services.dart';
|
||||||
import 'package:aves/utils/file_utils.dart';
|
import 'package:aves/utils/file_utils.dart';
|
||||||
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
|
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
|
||||||
|
|
|
@ -31,28 +31,7 @@ class AvesDialog extends AlertDialog {
|
||||||
// scroll both the title and the content together,
|
// scroll both the title and the content together,
|
||||||
// and overflow feedback ignores the dialog shape,
|
// and overflow feedback ignores the dialog shape,
|
||||||
// so we restrict scrolling to the content instead
|
// so we restrict scrolling to the content instead
|
||||||
content: scrollableContent != null
|
content: _buildContent(context, scrollController, scrollableContent, content),
|
||||||
? 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,
|
|
||||||
contentPadding: scrollableContent != null ? EdgeInsets.zero : const EdgeInsets.fromLTRB(24, 20, 24, 0),
|
contentPadding: scrollableContent != null ? EdgeInsets.zero : const EdgeInsets.fromLTRB(24, 20, 24, 0),
|
||||||
actions: actions,
|
actions: actions,
|
||||||
actionsPadding: const EdgeInsets.symmetric(horizontal: 8),
|
actionsPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
@ -61,6 +40,57 @@ class AvesDialog extends AlertDialog {
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(24)),
|
borderRadius: const BorderRadius.all(Radius.circular(24)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
static Widget _buildContent(
|
||||||
|
BuildContext context,
|
||||||
|
ScrollController? scrollController,
|
||||||
|
List<Widget>? 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 {
|
class DialogTitle extends StatelessWidget {
|
||||||
|
|
|
@ -1,86 +1,420 @@
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:aves/model/entry.dart';
|
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:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'aves_dialog.dart';
|
import 'aves_dialog.dart';
|
||||||
|
|
||||||
class RenameEntryDialog extends StatefulWidget {
|
class EditEntryDateDialog extends StatefulWidget {
|
||||||
final AvesEntry entry;
|
final AvesEntry entry;
|
||||||
|
|
||||||
const RenameEntryDialog({
|
const EditEntryDateDialog({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.entry,
|
required this.entry,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_RenameEntryDialogState createState() => _RenameEntryDialogState();
|
_EditEntryDateDialogState createState() => _EditEntryDateDialogState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _RenameEntryDialogState extends State<RenameEntryDialog> {
|
class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
|
||||||
final TextEditingController _nameController = TextEditingController();
|
DateEditAction _action = DateEditAction.set;
|
||||||
final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false);
|
late Set<MetadataField> _fields;
|
||||||
|
late DateTime _dateTime;
|
||||||
|
int _shiftMinutes = 60;
|
||||||
|
bool _showOptions = false;
|
||||||
|
|
||||||
AvesEntry get entry => widget.entry;
|
AvesEntry get entry => widget.entry;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_nameController.text = entry.filenameWithoutExtension ?? entry.sourceTitle ?? '';
|
_fields = {
|
||||||
_validate();
|
MetadataField.exifDate,
|
||||||
}
|
MetadataField.exifDateDigitized,
|
||||||
|
MetadataField.exifDateOriginal,
|
||||||
@override
|
};
|
||||||
void dispose() {
|
_dateTime = entry.bestDate ?? DateTime.now();
|
||||||
_nameController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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<DateEditAction>(
|
||||||
|
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<DateEditAction>(
|
||||||
|
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<DateEditAction>(
|
||||||
|
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<void> _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<int>(
|
||||||
|
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<TimeShiftDialog> {
|
||||||
|
late ValueNotifier<int> _hour, _minute;
|
||||||
|
late ValueNotifier<String> _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(
|
return AvesDialog(
|
||||||
context: context,
|
context: context,
|
||||||
content: TextField(
|
scrollableContent: [
|
||||||
controller: _nameController,
|
Center(
|
||||||
decoration: InputDecoration(
|
child: Padding(
|
||||||
labelText: context.l10n.renameEntryDialogLabel,
|
padding: const EdgeInsets.only(top: 8),
|
||||||
suffixText: entry.extension,
|
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: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
|
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
|
||||||
),
|
),
|
||||||
ValueListenableBuilder<bool>(
|
TextButton(
|
||||||
valueListenable: _isValidNotifier,
|
onPressed: () => Navigator.pop(context, (_hour.value * 60 + _minute.value) * (_sign.value == '+' ? 1 : -1)),
|
||||||
builder: (context, isValid, child) {
|
child: Text(MaterialLocalizations.of(context).okButtonLabel),
|
||||||
return TextButton(
|
),
|
||||||
onPressed: isValid ? () => _submit(context) : null,
|
|
||||||
child: Text(context.l10n.applyButtonLabel),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
)
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
String _buildEntryPath(String name) {
|
|
||||||
if (name.isEmpty) return '';
|
class _Wheel<T> extends StatefulWidget {
|
||||||
return pContext.join(entry.directory!, name + entry.extension!);
|
final ValueNotifier<T> valueNotifier;
|
||||||
}
|
final List<T> values;
|
||||||
|
final TextStyle textStyle;
|
||||||
Future<void> _validate() async {
|
final TextAlign textAlign;
|
||||||
final newName = _nameController.text;
|
|
||||||
final path = _buildEntryPath(newName);
|
const _Wheel({
|
||||||
final exists = newName.isNotEmpty && await FileSystemEntity.type(path) != FileSystemEntityType.notFound;
|
Key? key,
|
||||||
_isValidNotifier.value = newName.isNotEmpty && !exists;
|
required this.valueNotifier,
|
||||||
}
|
required this.values,
|
||||||
|
required this.textStyle,
|
||||||
void _submit(BuildContext context) => Navigator.pop(context, _nameController.text);
|
required this.textAlign,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
_WheelState createState() => _WheelState<T>();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WheelState<T> extends State<_Wheel<T>> {
|
||||||
|
late final ScrollController _controller;
|
||||||
|
|
||||||
|
static const itemSize = Size(40, 40);
|
||||||
|
|
||||||
|
ValueNotifier<T> get valueNotifier => widget.valueNotifier;
|
||||||
|
|
||||||
|
List<T> 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(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:aves/model/entry.dart';
|
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/services/services.dart';
|
||||||
import 'package:aves/widgets/viewer/info/common.dart';
|
import 'package:aves/widgets/viewer/info/common.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
|
|
|
@ -40,36 +40,41 @@ class BasicSection extends StatelessWidget {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
final infoUnknown = l10n.viewerInfoUnknown;
|
final infoUnknown = l10n.viewerInfoUnknown;
|
||||||
final date = entry.bestDate;
|
|
||||||
final locale = l10n.localeName;
|
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
|
return AnimatedBuilder(
|
||||||
// inserting ZWSP (\u200B) between characters does help, but it messes with width and height computation (another Flutter issue)
|
animation: entry.metadataChangeNotifier,
|
||||||
final title = entry.bestTitle ?? infoUnknown;
|
builder: (context, child) {
|
||||||
final uri = entry.uri;
|
// TODO TLAD line break on all characters for the following fields when this is fixed: https://github.com/flutter/flutter/issues/61081
|
||||||
final path = entry.path;
|
// 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(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
InfoRowGroup(
|
InfoRowGroup(
|
||||||
info: {
|
info: {
|
||||||
l10n.viewerInfoLabelTitle: title,
|
l10n.viewerInfoLabelTitle: title,
|
||||||
l10n.viewerInfoLabelDate: dateText,
|
l10n.viewerInfoLabelDate: dateText,
|
||||||
if (entry.isVideo) ..._buildVideoRows(context),
|
if (entry.isVideo) ..._buildVideoRows(context),
|
||||||
if (!entry.isSvg && entry.isSized) l10n.viewerInfoLabelResolution: rasterResolutionText,
|
if (showResolution) l10n.viewerInfoLabelResolution: rasterResolutionText,
|
||||||
l10n.viewerInfoLabelSize: entry.sizeBytes != null ? formatFilesize(entry.sizeBytes!) : infoUnknown,
|
l10n.viewerInfoLabelSize: sizeText,
|
||||||
l10n.viewerInfoLabelUri: uri,
|
l10n.viewerInfoLabelUri: entry.uri,
|
||||||
if (path != null) l10n.viewerInfoLabelPath: path,
|
if (path != null) l10n.viewerInfoLabelPath: path,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
OwnerProp(
|
OwnerProp(
|
||||||
entry: entry,
|
entry: entry,
|
||||||
),
|
),
|
||||||
_buildChips(context),
|
_buildChips(context),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildChips(BuildContext context) {
|
Widget _buildChips(BuildContext context) {
|
||||||
|
|
|
@ -1,12 +1,22 @@
|
||||||
|
import 'package:aves/model/actions/entry_info_actions.dart';
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
|
import 'package:aves/model/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/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/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/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/info_search.dart';
|
||||||
import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart';
|
import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart';
|
||||||
import 'package:flutter/material.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 AvesEntry entry;
|
||||||
final ValueNotifier<Map<String, MetadataDirectory>> metadataNotifier;
|
final ValueNotifier<Map<String, MetadataDirectory>> metadataNotifier;
|
||||||
final VoidCallback onBackPressed;
|
final VoidCallback onBackPressed;
|
||||||
|
@ -38,6 +48,23 @@ class InfoAppBar extends StatelessWidget {
|
||||||
onPressed: () => _goToSearch(context),
|
onPressed: () => _goToSearch(context),
|
||||||
tooltip: MaterialLocalizations.of(context).searchFieldLabel,
|
tooltip: MaterialLocalizations.of(context).searchFieldLabel,
|
||||||
),
|
),
|
||||||
|
MenuIconTheme(
|
||||||
|
child: PopupMenuButton<EntryInfoAction>(
|
||||||
|
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,
|
titleSpacing: 0,
|
||||||
floating: true,
|
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<void> _showDateEditDialog(BuildContext context) async {
|
||||||
|
final modifier = await showDialog<DateModifier>(
|
||||||
|
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<CollectionSource>().editEntryDate(entry, modifier);
|
||||||
|
if (success) {
|
||||||
|
showFeedback(context, context.l10n.genericSuccessFeedback);
|
||||||
|
} else {
|
||||||
|
showFeedback(context, context.l10n.genericFailureFeedback);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:aves/model/entry.dart';
|
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/multipage.dart';
|
||||||
import 'package:aves/model/settings/coordinate_format.dart';
|
import 'package:aves/model/settings/coordinate_format.dart';
|
||||||
import 'package:aves/model/settings/settings.dart';
|
import 'package:aves/model/settings/settings.dart';
|
||||||
|
|
|
@ -167,6 +167,7 @@ class _WelcomePageState extends State<WelcomePage> {
|
||||||
child: Theme(
|
child: Theme(
|
||||||
data: Theme.of(context).copyWith(
|
data: Theme.of(context).copyWith(
|
||||||
scrollbarTheme: const ScrollbarThemeData(
|
scrollbarTheme: const ScrollbarThemeData(
|
||||||
|
isAlwaysShown: true,
|
||||||
radius: Radius.circular(16),
|
radius: Radius.circular(16),
|
||||||
crossAxisMargin: 6,
|
crossAxisMargin: 6,
|
||||||
mainAxisMargin: 16,
|
mainAxisMargin: 16,
|
||||||
|
|
|
@ -2,7 +2,8 @@ import 'package:aves/model/covers.dart';
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/favourites.dart';
|
import 'package:aves/model/favourites.dart';
|
||||||
import 'package:aves/model/filters/filters.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:aves/model/metadata_db.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import 'package:aves/model/entry.dart';
|
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:aves/services/metadata_service.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
Loading…
Reference in a new issue