info: edit exif date

This commit is contained in:
Thibault Deckers 2021-08-26 19:53:56 +09:00
parent 84999053eb
commit 1c4db4d8e7
36 changed files with 934 additions and 247 deletions

View file

@ -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)
}

View file

@ -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)

View file

@ -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()

View file

@ -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

View file

@ -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 {

View file

@ -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

View file

@ -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)
}

View file

@ -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) {

View file

@ -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": {},

View file

@ -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": "동영상",

View file

@ -1,4 +1,3 @@
enum SettingsAction {
export,
import,
enum EntryInfoAction {
editDate,
}

View file

@ -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

View 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}';
}

View file

@ -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}';
}

View 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});
}

View file

@ -0,0 +1,12 @@
enum MetadataField {
exifDate,
exifDateOriginal,
exifDateDigitized,
exifGpsDate,
}
enum DateEditAction {
set,
shift,
clear,
}

View 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}';
}

View file

@ -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';

View file

@ -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}');

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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};

View file

@ -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';
}
}
}

View file

@ -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';

View file

@ -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;

View file

@ -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';

View file

@ -31,8 +31,29 @@ 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(
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),
shape: RoundedRectangleBorder(
side: Divider.createBorderSide(context, width: borderWidth),
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
@ -45,24 +66,33 @@ class AvesDialog extends AlertDialog {
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 ?? ScrollController(),
controller: scrollController,
shrinkWrap: true,
children: scrollableContent,
),
),
)
: content,
contentPadding: scrollableContent != null ? EdgeInsets.zero : const EdgeInsets.fromLTRB(24, 20, 24, 0),
actions: actions,
actionsPadding: const EdgeInsets.symmetric(horizontal: 8),
shape: RoundedRectangleBorder(
side: Divider.createBorderSide(context, width: borderWidth),
borderRadius: const BorderRadius.all(Radius.circular(24)),
),
),
);
}
return const SizedBox();
}
}
class DialogTitle extends StatelessWidget {
final String title;

View file

@ -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) {
return AvesDialog(
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,
content: TextField(
controller: _nameController,
decoration: InputDecoration(
labelText: context.l10n.renameEntryDialogLabel,
suffixText: entry.extension,
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),
),
autofocus: true,
onChanged: (_) => _validate(),
onSubmitted: (_) => _submit(context),
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),
),
ValueListenableBuilder<bool>(
valueListenable: _isValidNotifier,
builder: (context, isValid, child) {
return TextButton(
onPressed: isValid ? () => _submit(context) : null,
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,
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,
),
),
),
],
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
),
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;
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>();
}
void _submit(BuildContext context) => Navigator.pop(context, _nameController.text);
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(),
),
),
),
);
}
}

View file

@ -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';

View file

@ -40,14 +40,18 @@ 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;
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 uri = entry.uri;
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(
@ -58,9 +62,9 @@ class BasicSection extends StatelessWidget {
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 (showResolution) l10n.viewerInfoLabelResolution: rasterResolutionText,
l10n.viewerInfoLabelSize: sizeText,
l10n.viewerInfoLabelUri: entry.uri,
if (path != null) l10n.viewerInfoLabelPath: path,
},
),
@ -70,6 +74,7 @@ class BasicSection extends StatelessWidget {
_buildChips(context),
],
);
});
}
Widget _buildChips(BuildContext context) {

View file

@ -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);
}
}
}

View file

@ -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';

View file

@ -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,

View file

@ -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';

View file

@ -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';