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 {
|
||||
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<FieldMap> {
|
||||
override fun onSuccess(res: FieldMap) {
|
||||
resultFields.putAll(res)
|
||||
result.success(resultFields)
|
||||
}
|
||||
|
||||
|
|
|
@ -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<FieldMap> {
|
||||
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<FieldMap> {
|
||||
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<FieldMap> {
|
||||
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<FieldMap> {
|
||||
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<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) {
|
||||
Glide.get(activity).clearDiskCache()
|
||||
result.success(null)
|
||||
|
|
|
@ -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<FieldMap> {
|
||||
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<FieldMap> {
|
||||
override fun onSuccess(res: FieldMap) = success(res)
|
||||
override fun onFailure(throwable: Throwable) = error("move-failure", "failed to move entries", throwable)
|
||||
})
|
||||
endOfStream()
|
||||
|
|
|
@ -19,6 +19,8 @@ import kotlin.math.roundToLong
|
|||
object ExifInterfaceHelper {
|
||||
private val LOG_TAG = LogUtils.createTag<ExifInterfaceHelper>()
|
||||
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
|
||||
|
||||
|
|
|
@ -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<FieldMap>) {
|
||||
// source MIME type may be incorrect, so we get a second opinion if possible
|
||||
var extractorMimeType: String? = null
|
||||
try {
|
||||
|
|
|
@ -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<FieldMap>) {
|
||||
if (sourceMimeType == null) {
|
||||
callback.onFailure(Exception("MIME type is null for uri=$uri"))
|
||||
return
|
||||
|
|
|
@ -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<FieldMap>) {
|
||||
callback.onFailure(UnsupportedOperationException())
|
||||
}
|
||||
|
||||
|
@ -47,7 +48,7 @@ abstract class ImageProvider {
|
|||
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())
|
||||
}
|
||||
|
||||
|
@ -56,7 +57,7 @@ abstract class ImageProvider {
|
|||
imageExportMimeType: String,
|
||||
destinationDir: String,
|
||||
entries: List<AvesEntry>,
|
||||
callback: ImageOpCallback,
|
||||
callback: ImageOpCallback<FieldMap>,
|
||||
) {
|
||||
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<FieldMap>,
|
||||
) {
|
||||
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<FieldMap>) {
|
||||
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 <T> editExif(
|
||||
context: Context,
|
||||
path: String,
|
||||
uri: Uri,
|
||||
mimeType: String,
|
||||
sizeBytes: Long,
|
||||
callback: ImageOpCallback<T>,
|
||||
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<String, Any?>()
|
||||
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<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)`
|
||||
// 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<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 {
|
||||
fun onSuccess(fields: FieldMap)
|
||||
interface ImageOpCallback<T> {
|
||||
fun onSuccess(res: T)
|
||||
fun onFailure(throwable: Throwable)
|
||||
}
|
||||
|
||||
|
|
|
@ -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<FieldMap>) {
|
||||
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<AvesEntry>,
|
||||
callback: ImageOpCallback,
|
||||
callback: ImageOpCallback<FieldMap>,
|
||||
) {
|
||||
val destinationDirDocFile = createDirectoryIfAbsent(activity, destinationDir)
|
||||
if (destinationDirDocFile == null) {
|
||||
|
|
|
@ -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": {},
|
||||
|
||||
|
|
|
@ -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": "동영상",
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
enum SettingsAction {
|
||||
export,
|
||||
import,
|
||||
enum EntryInfoAction {
|
||||
editDate,
|
||||
}
|
||||
|
|
|
@ -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<void> catalog({bool background = false, bool persist = true}) async {
|
||||
if (isCatalogued) return;
|
||||
Future<void> 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
|
||||
|
|
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: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<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/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';
|
||||
|
|
|
@ -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<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 {
|
||||
if (newName == entry.filenameWithoutExtension) return true;
|
||||
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/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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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};
|
||||
|
||||
// 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> _knownOpaqueImages = {heic, heif, jpeg};
|
||||
|
|
|
@ -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<Map<String, dynamic>> rotate(AvesEntry entry, {required bool clockwise});
|
||||
|
||||
Future<Map<String, dynamic>> flip(AvesEntry entry);
|
||||
|
||||
Future<bool> editDate(AvesEntry entry, DateModifier modifier);
|
||||
}
|
||||
|
||||
class PlatformImageFileService implements ImageFileService {
|
||||
|
@ -408,4 +412,33 @@ class PlatformImageFileService implements ImageFileService {
|
|||
}
|
||||
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/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';
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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<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 {
|
||||
|
|
|
@ -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<RenameEntryDialog> {
|
||||
final TextEditingController _nameController = TextEditingController();
|
||||
final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false);
|
||||
class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
|
||||
DateEditAction _action = DateEditAction.set;
|
||||
late Set<MetadataField> _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<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(
|
||||
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<bool>(
|
||||
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<void> _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<T> extends StatefulWidget {
|
||||
final ValueNotifier<T> valueNotifier;
|
||||
final List<T> 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<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/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';
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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<Map<String, MetadataDirectory>> metadataNotifier;
|
||||
final VoidCallback onBackPressed;
|
||||
|
@ -38,6 +48,23 @@ class InfoAppBar extends StatelessWidget {
|
|||
onPressed: () => _goToSearch(context),
|
||||
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,
|
||||
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 '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';
|
||||
|
|
|
@ -167,6 +167,7 @@ class _WelcomePageState extends State<WelcomePage> {
|
|||
child: Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
scrollbarTheme: const ScrollbarThemeData(
|
||||
isAlwaysShown: true,
|
||||
radius: Radius.circular(16),
|
||||
crossAxisMargin: 6,
|
||||
mainAxisMargin: 16,
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
Loading…
Reference in a new issue