Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2021-09-02 17:27:51 +09:00
commit 34135b9893
82 changed files with 1625 additions and 472 deletions

View file

@ -3,6 +3,19 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
## [v1.5.0] - 2021-09-02
### Added
- Info: edit Exif dates (setting, shifting, deleting)
- Collection: custom quick actions for item selection
- Collection: video date detection for more formats
### Changed
- faster collection loading when launching the app
### Fixed
- app launching on some devices
- corrupting motion photo exif editing (e.g. rotation)
## [v1.4.9] - 2021-08-20
### Added
- Map & Stats from selection

View file

@ -32,7 +32,7 @@ if (keystorePropertiesFile.exists()) {
keystoreProperties.load(reader)
}
} else {
// for release using credentials in environment variables set up by Github Actions
// for release using credentials in environment variables set up by GitHub Actions
// warning: in property file, single quotes should be escaped with a backslash
// but they should not be escaped when stored in env variables
keystoreProperties['storeFile'] = System.getenv('AVES_STORE_FILE')
@ -120,7 +120,10 @@ dependencies {
implementation 'com.caverock:androidsvg-aar:1.4'
implementation 'com.commonsware.cwac:document:0.4.1'
implementation 'com.drewnoakes:metadata-extractor:2.16.0'
// https://jitpack.io/com/github/deckerst/Android-TiffBitmapFactory/**********/build.log
implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a' // forked, built by JitPack
// https://jitpack.io/com/github/deckerst/pixymeta-android/**********/build.log
implementation 'com.github.deckerst:pixymeta-android:f90140ed2b' // forked, built by JitPack
implementation 'com.github.bumptech.glide:glide:4.12.0'
kapt 'androidx.annotation:annotation:1.2.0'

View file

@ -17,11 +17,13 @@ import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.metadata.ExifInterfaceHelper
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper
import deckers.thibault.aves.metadata.Metadata
import deckers.thibault.aves.metadata.PixyMetaHelper
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes.isImage
import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface
import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor
import deckers.thibault.aves.utils.MimeTypes.isSupportedByPixyMeta
import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.StorageUtils
import deckers.thibault.aves.utils.UriUtils.tryParseId
@ -52,11 +54,33 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
"getExifInterfaceMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getExifInterfaceMetadata) }
"getMediaMetadataRetrieverMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMediaMetadataRetrieverMetadata) }
"getMetadataExtractorSummary" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMetadataExtractorSummary) }
"getPixyMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPixyMetadata) }
"getTiffStructure" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getTiffStructure) }
else -> result.notImplemented()
}
}
private fun getPixyMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (mimeType == null || uri == null) {
result.error("getPixyMetadata-args", "failed because of missing arguments", null)
return
}
val metadataMap = HashMap<String, String>()
if (isSupportedByPixyMeta(mimeType)) {
try {
StorageUtils.openInputStream(context, uri)?.use { input ->
metadataMap.putAll(PixyMetaHelper.describe(input))
}
} catch (e: Exception) {
result.error("getPixyMetadata-exception", e.message, e.stackTraceToString())
}
}
result.success(metadataMap)
}
private fun getContextDirs(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
val dirs = hashMapOf(
"cacheDir" to context.cacheDir,

View file

@ -15,11 +15,10 @@ class DeviceHandler : MethodCallHandler {
}
private fun getPerformanceClass(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
// TODO TLAD uncomment when the future is here
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// result.success(Build.VERSION.MEDIA_PERFORMANCE_CLASS)
// return
// }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
result.success(Build.VERSION.MEDIA_PERFORMANCE_CLASS)
return
}
result.success(Build.VERSION.SDK_INT)
}

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)
}
}
@ -217,7 +217,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
result.success(resultFields)
}
override fun onFailure(throwable: Throwable) = result.error("copyEmbeddedBytes-failure", "failed to get entry for uri=$uri mime=$mimeType", throwable.message)
override fun onFailure(throwable: Throwable) = result.error("copyEmbeddedBytes-failure", "failed to get entry for uri=$uri mime=$mimeType", "${throwable.message}\n${throwable.stackTraceToString()}")
})
}
} else {

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()
}
@ -59,7 +60,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
provider.fetchSingle(activity, uri, mimeType, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("getEntry-failure", "failed to get entry for uri=$uri", throwable.message)
override fun onFailure(throwable: Throwable) = result.error("getEntry-failure", "failed to get entry for uri=$uri", "${throwable.message}\n${throwable.stackTraceToString()}")
})
}
@ -161,7 +162,7 @@ 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)
override fun onFailure(throwable: Throwable) = result.error("captureFrame-failure", "failed to capture frame", throwable.message)
override fun onFailure(throwable: Throwable) = result.error("captureFrame-failure", "failed to capture frame", "${throwable.message}\n${throwable.stackTraceToString()}")
})
}
@ -189,7 +190,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
provider.rename(activity, path, uri, mimeType, newName, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("rename-failure", "failed to rename", throwable.message)
override fun onFailure(throwable: Throwable) = result.error("rename-failure", "failed to rename", "${throwable.message}\n${throwable.stackTraceToString()}")
})
}
@ -218,8 +219,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
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) {
if (uri == null || path == null || mimeType == null) {
result.error("changeOrientation-args", "failed because entry fields are missing", null)
return
}
@ -230,9 +230,39 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
return
}
provider.changeOrientation(activity, path, uri, mimeType, sizeBytes, op, object : ImageOpCallback {
provider.changeOrientation(activity, path, uri, mimeType, op, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("changeOrientation-failure", "failed to change orientation", throwable.message)
override fun onFailure(throwable: Throwable) = result.error("changeOrientation-failure", "failed to change orientation", "${throwable.message}\n${throwable.stackTraceToString()}")
})
}
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?
if (uri == null || path == null || mimeType == 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, dateMillis, shiftMinutes, fields, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("editDate-failure", "failed to edit date", "${throwable.message}\n${throwable.stackTraceToString()}")
})
}

View file

@ -267,7 +267,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
if (metadataMap.isNotEmpty()) {
result.success(metadataMap)
} else {
result.error("getAllMetadata-failure", "failed to get metadata for uri=$uri", null)
result.error("getAllMetadata-failure", "failed to get metadata for mimeType=$mimeType uri=$uri", null)
}
}
@ -474,9 +474,9 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get catalog metadata by metadata-extractor for uri=$uri, mimeType=$mimeType", e)
Log.w(LOG_TAG, "failed to get catalog metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
} catch (e: NoClassDefFoundError) {
Log.w(LOG_TAG, "failed to get catalog metadata by metadata-extractor for uri=$uri, mimeType=$mimeType", e)
Log.w(LOG_TAG, "failed to get catalog metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
}
}
@ -502,7 +502,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
} catch (e: Exception) {
// ExifInterface initialization can fail with a RuntimeException
// caused by an internal MediaMetadataRetriever failure
Log.w(LOG_TAG, "failed to get metadata by ExifInterface for uri=$uri", e)
Log.w(LOG_TAG, "failed to get metadata by ExifInterface for mimeType=$mimeType uri=$uri", e)
}
}
@ -597,9 +597,9 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for uri=$uri", e)
Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
} catch (e: NoClassDefFoundError) {
Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for uri=$uri", e)
Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
}
}
@ -616,7 +616,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
} catch (e: Exception) {
// ExifInterface initialization can fail with a RuntimeException
// caused by an internal MediaMetadataRetriever failure
Log.w(LOG_TAG, "failed to get metadata by ExifInterface for uri=$uri", e)
Log.w(LOG_TAG, "failed to get metadata by ExifInterface for mimeType=$mimeType uri=$uri", e)
}
}
@ -639,7 +639,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
else -> null
}
if (pages?.isEmpty() == true) {
result.error("getMultiPageInfo-empty", "failed to get pages for uri=$uri", null)
result.error("getMultiPageInfo-empty", "failed to get pages for mimeType=$mimeType uri=$uri", null)
} else {
result.success(pages)
}
@ -680,7 +680,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
Log.w(LOG_TAG, "failed to read XMP", e)
}
}
result.error("getPanoramaInfo-empty", "failed to read XMP from uri=$uri", null)
result.error("getPanoramaInfo-empty", "failed to read XMP for mimeType=$mimeType uri=$uri", null)
}
private fun hasContentResolverProp(call: MethodCall, result: MethodChannel.Result) {

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

@ -0,0 +1,57 @@
package deckers.thibault.aves.metadata
import pixy.meta.meta.Metadata
import pixy.meta.meta.MetadataEntry
import pixy.meta.meta.MetadataType
import pixy.meta.meta.jpeg.JPGMeta
import pixy.meta.meta.xmp.XMP
import pixy.meta.string.XMLUtils
import java.io.InputStream
import java.io.OutputStream
import java.util.*
object PixyMetaHelper {
fun describe(input: InputStream): HashMap<String, String> {
val metadataMap = HashMap<String, String>()
fun fetch(parents: String, entries: Iterable<MetadataEntry>) {
for (entry in entries) {
metadataMap["$parents ${entry.key}"] = entry.value
if (entry.isMetadataEntryGroup) {
fetch("$parents ${entry.key} /", entry.metadataEntries)
}
}
}
val metadataByType = Metadata.readMetadata(input)
for ((type, metadata) in metadataByType.entries) {
if (type == MetadataType.XMP) {
val xmp = metadataByType[MetadataType.XMP] as XMP?
if (xmp != null) {
metadataMap["XMP"] = xmp.xmpDocString()
if (xmp.hasExtendedXmp()) {
metadataMap["XMP extended"] = xmp.extendedXmpDocString()
}
}
} else {
fetch("$type /", metadata)
}
}
return metadataMap
}
fun getXmp(input: InputStream): XMP? = Metadata.readMetadata(input)[MetadataType.XMP] as XMP?
fun setXmp(input: InputStream, output: OutputStream, xmpString: String, extendedXmpString: String?) {
if (extendedXmpString != null) {
JPGMeta.insertXMP(input, output, xmpString, extendedXmpString)
} else {
Metadata.insertXMP(input, output, xmpString)
}
}
fun XMP.xmpDocString(): String = XMLUtils.serializeToString(xmpDocument)
fun XMP.extendedXmpDocString(): String = XMLUtils.serializeToString(extendedXmpDocument)
}

View file

@ -18,21 +18,24 @@ 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.metadata.PixyMetaHelper
import deckers.thibault.aves.metadata.PixyMetaHelper.extendedXmpDocString
import deckers.thibault.aves.metadata.PixyMetaHelper.xmpDocString
import deckers.thibault.aves.metadata.XMP
import deckers.thibault.aves.model.AvesEntry
import deckers.thibault.aves.model.ExifOrientationOp
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.*
import deckers.thibault.aves.utils.MimeTypes.extensionFor
import deckers.thibault.aves.utils.MimeTypes.isImage
import deckers.thibault.aves.utils.MimeTypes.isSupportedByPixyMeta
import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.StorageUtils.createDirectoryIfAbsent
import deckers.thibault.aves.utils.StorageUtils.getDocumentFile
import deckers.thibault.aves.utils.UriUtils.tryParseId
import java.io.ByteArrayInputStream
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
import java.io.*
import java.util.*
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@ -329,36 +332,62 @@ 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.3
private fun canEditExif(mimeType: String): Boolean {
return when (mimeType) {
MimeTypes.DNG,
MimeTypes.JPEG,
MimeTypes.PNG,
MimeTypes.WEBP -> true
else -> false
}
}
// support for writing XMP
private fun canEditXmp(mimeType: String): Boolean {
return isSupportedByPixyMeta(mimeType)
}
private fun editExif(
context: Context,
path: String,
uri: Uri,
mimeType: String,
callback: ImageOpCallback,
trailerDiff: Int = 0,
edit: (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()
val originalFileSize = File(path).length()
val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff }
var videoBytes: ByteArray? = null
val editableFile = File.createTempFile("aves", null).apply {
deleteOnExit()
try {
outputStream().use { output ->
if (videoSizeBytes != null) {
if (videoSize != null) {
// handle motion photo and embedded video separately
val imageSizeBytes = (sizeBytes - videoSizeBytes).toInt()
videoBytes = ByteArray(videoSizeBytes)
val imageSize = (originalFileSize - videoSize).toInt()
videoBytes = ByteArray(videoSize)
StorageUtils.openInputStream(context, uri)?.let { input ->
val imageBytes = ByteArray(imageSizeBytes)
input.read(imageBytes, 0, imageSizeBytes)
input.read(videoBytes, 0, videoSizeBytes)
val imageBytes = ByteArray(imageSize)
input.read(imageBytes, 0, imageSize)
input.read(videoBytes, 0, videoSize)
// copy only the image to a temporary file for editing
// video will be appended after EXIF modification
// video will be appended after metadata modification
ByteArrayInputStream(imageBytes).use { imageInput ->
imageInput.copyTo(output)
}
@ -372,13 +401,154 @@ abstract class ImageProvider {
}
} catch (e: Exception) {
callback.onFailure(e)
return
return false
}
}
val newFields = HashMap<String, Any?>()
try {
val exif = ExifInterface(editableFile)
edit(ExifInterface(editableFile))
if (videoBytes != null) {
// append trailer video, if any
editableFile.appendBytes(videoBytes!!)
}
// copy the edited temporary file back to the original
DocumentFileCompat.fromFile(editableFile).copyTo(originalDocumentFile)
if (!checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
return false
}
} catch (e: IOException) {
callback.onFailure(e)
return false
}
return true
}
private fun editXmp(
context: Context,
path: String,
uri: Uri,
mimeType: String,
callback: ImageOpCallback,
trailerDiff: Int = 0,
edit: (xmp: String) -> String,
): Boolean {
if (!canEditXmp(mimeType)) {
callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType"))
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 false
}
val originalFileSize = File(path).length()
val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff }
val editableFile = File.createTempFile("aves", null).apply {
deleteOnExit()
try {
val xmp = originalDocumentFile.openInputStream().use { input -> PixyMetaHelper.getXmp(input) }
if (xmp == null) {
callback.onFailure(Exception("failed to find XMP for path=$path, uri=$uri"))
return false
}
outputStream().use { output ->
// reopen input to read from start
originalDocumentFile.openInputStream().use { input ->
val editedXmpString = edit(xmp.xmpDocString())
val extendedXmpString = if (xmp.hasExtendedXmp()) xmp.extendedXmpDocString() else null
PixyMetaHelper.setXmp(input, output, editedXmpString, extendedXmpString)
}
}
} catch (e: Exception) {
callback.onFailure(e)
return false
}
}
try {
// copy the edited temporary file back to the original
DocumentFileCompat.fromFile(editableFile).copyTo(originalDocumentFile)
if (!checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
return false
}
} catch (e: IOException) {
callback.onFailure(e)
return false
}
return true
}
// A few bytes are sometimes appended when writing to a document output stream.
// In that case, we need to adjust the trailer video offset accordingly and rewrite the file.
// return whether the file at `path` is fine
private fun checkTrailerOffset(
context: Context,
path: String,
uri: Uri,
mimeType: String,
trailerOffset: Int?,
editedFile: File,
callback: ImageOpCallback,
): Boolean {
if (trailerOffset == null) return true
val expectedLength = editedFile.length()
val actualLength = File(path).length()
val diff = (actualLength - expectedLength).toInt()
if (diff == 0) return true
Log.w(
LOG_TAG, "Edited file length=$expectedLength does not match final document file length=$actualLength. " +
"We need to edit XMP to adjust trailer video offset by $diff bytes."
)
val newTrailerOffset = trailerOffset + diff
return editXmp(context, path, uri, mimeType, callback, trailerDiff = diff) { xmp ->
xmp.replace(
// GCamera motion photo
"${XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME}=\"$trailerOffset\"",
"${XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME}=\"$newTrailerOffset\"",
).replace(
// Container motion photo
"${XMP.CONTAINER_ITEM_LENGTH_PROP_NAME}=\"$trailerOffset\"",
"${XMP.CONTAINER_ITEM_LENGTH_PROP_NAME}=\"$newTrailerOffset\"",
)
}
}
private fun scanPostExifEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: HashMap<String, Any?>, callback: ImageOpCallback) {
MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, _ ->
val projection = arrayOf(
MediaStore.MediaColumns.DATE_MODIFIED,
MediaStore.MediaColumns.SIZE,
)
try {
val cursor = context.contentResolver.query(uri, projection, null, null, null)
if (cursor != null && cursor.moveToFirst()) {
cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields["dateModifiedSecs"] = cursor.getInt(it) }
cursor.getColumnIndex(MediaStore.MediaColumns.SIZE).let { if (it != -1) newFields["sizeBytes"] = cursor.getLong(it) }
cursor.close()
}
} catch (e: Exception) {
callback.onFailure(e)
return@scanFile
}
callback.onSuccess(newFields)
}
}
fun changeOrientation(context: Context, path: String, uri: Uri, mimeType: String, op: ExifOrientationOp, callback: ImageOpCallback) {
val newFields = HashMap<String, Any?>()
val success = editExif(context, path, uri, mimeType, 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,43 +563,106 @@ 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
}
MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, _ ->
val projection = arrayOf(MediaStore.MediaColumns.DATE_MODIFIED)
try {
val cursor = context.contentResolver.query(uri, projection, null, null, null)
if (cursor != null && cursor.moveToFirst()) {
cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields["dateModifiedSecs"] = cursor.getInt(it) }
cursor.close()
}
} catch (e: Exception) {
callback.onFailure(e)
return@scanFile
}
callback.onSuccess(newFields)
if (success) {
scanPostExifEdit(context, path, uri, mimeType, newFields, callback)
}
}
// 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,
dateMillis: Long?,
shiftMinutes: Long?,
fields: List<String>,
callback: ImageOpCallback,
) {
if (dateMillis != null && dateMillis < 0) {
callback.onFailure(Exception("dateMillis=$dateMillis cannot be negative"))
return
}
val success = editExif(context, path, uri, mimeType, 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) {
scanPostExifEdit(context, path, uri, mimeType, HashMap<String, Any?>(), callback)
}
}

View file

@ -23,7 +23,7 @@ object MimeTypes {
// raw raster
private const val ARW = "image/x-sony-arw"
private const val CR2 = "image/x-canon-cr2"
private const val DNG = "image/x-adobe-dng"
const val DNG = "image/x-adobe-dng"
private const val NEF = "image/x-nikon-nef"
private const val NRW = "image/x-nikon-nrw"
private const val ORF = "image/x-olympus-orf"
@ -81,6 +81,11 @@ object MimeTypes {
// no support for TIFF images, but it can actually open them (maybe other formats too)
fun isSupportedByExifInterface(mimeType: String, strict: Boolean = true) = ExifInterface.isSupportedMimeType(mimeType) || !strict
fun isSupportedByPixyMeta(mimeType: String) = when (mimeType) {
JPEG, TIFF, PNG, GIF, BMP -> true
else -> false
}
// Glide automatically applies EXIF orientation when decoding images of known formats
// but we need to rotate the decoded bitmap for the other formats
// maybe related to ExifInterface version used by Glide:

View file

@ -1,12 +1,12 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext.kotlin_version = '1.5.21'
ext.kotlin_version = '1.5.30'
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.0.0'
classpath 'com.android.tools.build:gradle:7.0.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath 'com.google.gms:google-services:4.3.10'
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.7.1'

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": {},
@ -352,8 +373,8 @@
"@aboutUpdateLinks2": {},
"aboutUpdateLinks3": ".",
"@aboutUpdateLinks3": {},
"aboutUpdateGithub": "Github",
"@aboutUpdateGithub": {},
"aboutUpdateGitHub": "GitHub",
"@aboutUpdateGitHub": {},
"aboutUpdateGooglePlay": "Google Play",
"@aboutUpdateGooglePlay": {},
"aboutCredits": "Credits",
@ -425,14 +446,6 @@
"@dateYesterday": {},
"dateThisMonth": "This month",
"@dateThisMonth": {},
"errorUnsupportedMimeType": "{mimeType} not supported",
"@errorUnsupportedMimeType": {
"placeholders": {
"mimeType": {
"type": "String"
}
}
},
"collectionDeleteFailureFeedback": "{count, plural, =1{Failed to delete 1 item} other{Failed to delete {count} items}}",
"@collectionDeleteFailureFeedback": {
"placeholders": {
@ -614,6 +627,13 @@
"settingsThumbnailShowVideoDuration": "Show video duration",
"@settingsThumbnailShowVideoDuration": {},
"settingsCollectionSelectionQuickActionsTile": "Quick actions for item selection",
"@settingsCollectionSelectionQuickActionsTile": {},
"settingsCollectionSelectionQuickActionEditorTitle": "Quick Actions",
"@settingsCollectionSelectionQuickActionEditorTitle": {},
"settingsCollectionSelectionQuickActionEditorBanner": "Touch and hold to move buttons and select which actions are displayed when selecting items.",
"@settingsCollectionSelectionQuickActionEditorBanner": {},
"settingsSectionViewer": "Viewer",
"@settingsSectionViewer": {},
"settingsImageBackground": "Image background",
@ -658,9 +678,9 @@
"@settingsVideoLoopModeTile": {},
"settingsVideoLoopModeTitle": "Loop Mode",
"@settingsVideoLoopModeTitle": {},
"settingsVideoQuickActionsTile": "Quick video actions",
"settingsVideoQuickActionsTile": "Quick actions for videos",
"@settingsVideoQuickActionsTile": {},
"settingsVideoQuickActionEditorTitle": "Quick Video Actions",
"settingsVideoQuickActionEditorTitle": "Quick Actions",
"@settingsVideoQuickActionEditorTitle": {},
"settingsSubtitleThemeTile": "Subtitles",

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": "동영상",
@ -163,7 +175,7 @@
"aboutUpdateLinks1": "앱의 최신 버전을",
"aboutUpdateLinks2": "와",
"aboutUpdateLinks3": "에서 다운로드 사용 가능합니다.",
"aboutUpdateGithub": "깃허브",
"aboutUpdateGitHub": "깃허브",
"aboutUpdateGooglePlay": "구글 플레이",
"aboutCredits": "크레딧",
"aboutCreditsWorldAtlas1": "이 앱은",
@ -200,7 +212,6 @@
"dateToday": "오늘",
"dateYesterday": "어제",
"dateThisMonth": "이번 달",
"errorUnsupportedMimeType": "{mimeType} 지원되지 않음",
"collectionDeleteFailureFeedback": "{count, plural, other{항목 {count}개를 삭제하지 못했습니다}}",
"collectionCopyFailureFeedback": "{count, plural, other{항목 {count}개를 복사하지 못했습니다}}",
"collectionMoveFailureFeedback": "{count, plural, other{항목 {count}개를 이동하지 못했습니다}}",
@ -288,6 +299,10 @@
"settingsThumbnailShowRawIcon": "Raw 아이콘 표시",
"settingsThumbnailShowVideoDuration": "동영상 길이 표시",
"settingsCollectionSelectionQuickActionsTile": "항목 선택의 빠른 작업",
"settingsCollectionSelectionQuickActionEditorTitle": "빠른 작업",
"settingsCollectionSelectionQuickActionEditorBanner": "버튼을 길게 누른 후 이동하여 항목 선택할 때 표시될 버튼을 선택하세요.",
"settingsSectionViewer": "뷰어",
"settingsImageBackground": "사진 배경",
"settingsViewerShowMinimap": "미니맵 표시",
@ -311,8 +326,8 @@
"settingsVideoEnableAutoPlay": "자동 재생",
"settingsVideoLoopModeTile": "반복 모드",
"settingsVideoLoopModeTitle": "반복 모드",
"settingsVideoQuickActionsTile": "빠른 동영상 작업",
"settingsVideoQuickActionEditorTitle": "빠른 동영상 작업",
"settingsVideoQuickActionsTile": "동영상의 빠른 작업",
"settingsVideoQuickActionEditorTitle": "빠른 작업",
"settingsSubtitleThemeTile": "자막",
"settingsSubtitleThemeTitle": "자막",

View file

@ -32,11 +32,6 @@ enum EntryAction {
}
class EntryActions {
static const selection = [
EntryAction.share,
EntryAction.delete,
];
static const inApp = [
EntryAction.info,
EntryAction.toggleFavourite,

View file

@ -0,0 +1,3 @@
enum EntryInfoAction {
editDate,
}

View file

@ -15,11 +15,25 @@ enum EntrySetAction {
map,
stats,
// entry selection
share,
delete,
copy,
move,
refreshMetadata,
}
class EntrySetActions {
static const selection = [
EntrySetAction.share,
EntrySetAction.delete,
EntrySetAction.copy,
EntrySetAction.move,
EntrySetAction.refreshMetadata,
EntrySetAction.map,
EntrySetAction.stats,
];
}
extension ExtraEntrySetAction on EntrySetAction {
String getText(BuildContext context) {
switch (this) {
@ -43,6 +57,10 @@ extension ExtraEntrySetAction on EntrySetAction {
case EntrySetAction.stats:
return context.l10n.menuActionStats;
// entry selection
case EntrySetAction.share:
return context.l10n.entryActionShare;
case EntrySetAction.delete:
return context.l10n.entryActionDelete;
case EntrySetAction.copy:
return context.l10n.collectionActionCopy;
case EntrySetAction.move:
@ -78,6 +96,10 @@ extension ExtraEntrySetAction on EntrySetAction {
case EntrySetAction.stats:
return AIcons.stats;
// entry selection
case EntrySetAction.share:
return AIcons.share;
case EntrySetAction.delete:
return AIcons.delete;
case EntrySetAction.copy:
return AIcons.copy;
case EntrySetAction.move:

View file

@ -3,7 +3,9 @@ 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/metadata/date_modifier.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/video/metadata.dart';
@ -12,8 +14,8 @@ import 'package:aves/services/geocoding_service.dart';
import 'package:aves/services/service_policy.dart';
import 'package:aves/services/services.dart';
import 'package:aves/services/svg_metadata_service.dart';
import 'package:aves/theme/format.dart';
import 'package:aves/utils/change_notifier.dart';
import 'package:aves/utils/time_utils.dart';
import 'package:collection/collection.dart';
import 'package:country_code/country_code.dart';
import 'package:flutter/foundation.dart';
@ -28,7 +30,7 @@ class AvesEntry {
int width;
int height;
int sourceRotationDegrees;
final int? sizeBytes;
int? sizeBytes;
String? _sourceTitle;
// `dateModifiedSecs` can be missing in viewer mode
@ -413,8 +415,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
@ -429,10 +431,14 @@ class AvesEntry {
} else {
if (isVideo && (!isSized || durationMillis == 0)) {
// exotic video that is not sized during loading
final fields = await VideoMetadataFormatter.getCatalogMetadata(this);
final fields = await VideoMetadataFormatter.getLoadingMetadata(this);
await _applyNewFields(fields, persist: persist);
}
catalogMetadata = await metadataService.getCatalogMetadata(this, background: background);
if (isVideo && (catalogMetadata?.dateMillis ?? 0) == 0) {
catalogMetadata = await VideoMetadataFormatter.getCatalogMetadata(this);
}
}
}
@ -555,6 +561,8 @@ class AvesEntry {
final durationMillis = newFields['durationMillis'];
if (durationMillis is int) this.durationMillis = durationMillis;
final sizeBytes = newFields['sizeBytes'];
if (sizeBytes is int) this.sizeBytes = sizeBytes;
final dateModifiedSecs = newFields['dateModifiedSecs'];
if (dateModifiedSecs is int) this.dateModifiedSecs = dateModifiedSecs;
final rotationDegrees = newFields['rotationDegrees'];
@ -594,6 +602,15 @@ class AvesEntry {
return true;
}
Future<bool> editDate(DateModifier modifier, {required bool persist}) async {
final newFields = await imageFileService.editDate(this, modifier);
if (newFields.isEmpty) return false;
await _applyNewFields(newFields, persist: persist);
await catalog(background: false, persist: persist, force: true);
return true;
}
Future<bool> delete() {
final completer = Completer<bool>();
imageFileService.delete([this]).listen(

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,31 +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 DateMetadata {
final int? contentId, dateMillis;
DateMetadata({
this.contentId,
this.dateMillis,
});
factory DateMetadata.fromMap(Map map) {
return DateMetadata(
contentId: map['contentId'],
dateMillis: map['dateMillis'] ?? 0,
);
}
Map<String, dynamic> toMap() => {
'contentId': contentId,
'dateMillis': dateMillis,
};
@override
String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, dateMillis=$dateMillis}';
}
class CatalogMetadata {
final int? contentId, dateMillis;
@ -75,13 +49,14 @@ class CatalogMetadata {
CatalogMetadata copyWith({
int? contentId,
String? mimeType,
int? dateMillis,
bool? isMultiPage,
int? rotationDegrees,
}) {
return CatalogMetadata(
contentId: contentId ?? this.contentId,
mimeType: mimeType ?? this.mimeType,
dateMillis: dateMillis,
dateMillis: dateMillis ?? this.dateMillis,
isAnimated: isAnimated,
isFlipped: isFlipped,
isGeotiff: isGeotiff,
@ -130,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';
@ -36,7 +37,7 @@ abstract class MetadataDb {
Future<void> clearDates();
Future<List<DateMetadata>> loadDates();
Future<Map<int?, int?>> loadDates();
// catalog metadata
@ -260,12 +261,10 @@ class SqfliteMetadataDb implements MetadataDb {
}
@override
Future<List<DateMetadata>> loadDates() async {
// final stopwatch = Stopwatch()..start();
Future<Map<int?, int?>> loadDates() async {
final db = await _database;
final maps = await db.query(dateTakenTable);
final metadataEntries = maps.map((map) => DateMetadata.fromMap(map)).toList();
// debugPrint('$runtimeType loadDates complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries');
final metadataEntries = Map.fromEntries(maps.map((map) => MapEntry(map['contentId'] as int, (map['dateMillis'] ?? 0) as int)));
return metadataEntries;
}
@ -280,11 +279,9 @@ class SqfliteMetadataDb implements MetadataDb {
@override
Future<List<CatalogMetadata>> loadMetadataEntries() async {
// final stopwatch = Stopwatch()..start();
final db = await _database;
final maps = await db.query(metadataTable);
final metadataEntries = maps.map((map) => CatalogMetadata.fromMap(map)).toList();
// debugPrint('$runtimeType loadMetadataEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries');
return metadataEntries;
}
@ -318,7 +315,10 @@ class SqfliteMetadataDb implements MetadataDb {
if (metadata.dateMillis != 0) {
batch.insert(
dateTakenTable,
DateMetadata(contentId: metadata.contentId, dateMillis: metadata.dateMillis).toMap(),
{
'contentId': metadata.contentId,
'dateMillis': metadata.dateMillis,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
@ -340,11 +340,9 @@ class SqfliteMetadataDb implements MetadataDb {
@override
Future<List<AddressDetails>> loadAddresses() async {
// final stopwatch = Stopwatch()..start();
final db = await _database;
final maps = await db.query(addressTable);
final addresses = maps.map((map) => AddressDetails.fromMap(map)).toList();
// debugPrint('$runtimeType loadAddresses complete in ${stopwatch.elapsed.inMilliseconds}ms for ${addresses.length} entries');
return addresses;
}

View file

@ -0,0 +1,41 @@
import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/actions/entry_set_actions.dart';
import 'package:aves/model/actions/video_actions.dart';
import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/mime.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart';
import 'package:aves/widgets/filter_grids/countries_page.dart';
import 'package:aves/widgets/filter_grids/tags_page.dart';
class SettingsDefaults {
// drawer
static final drawerTypeBookmarks = [
null,
MimeFilter.video,
FavouriteFilter.instance,
];
static final drawerPageBookmarks = [
AlbumListPage.routeName,
CountryListPage.routeName,
TagListPage.routeName,
];
// collection
static const collectionSelectionQuickActions = [
EntrySetAction.share,
EntrySetAction.delete,
];
// viewer
static const viewerQuickActions = [
EntryAction.toggleFavourite,
EntryAction.share,
EntryAction.rotateScreen,
];
// video
static const videoQuickActions = [
VideoAction.replay10,
VideoAction.togglePlay,
];
}

View file

@ -2,10 +2,10 @@ import 'dart:convert';
import 'dart:math';
import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/actions/entry_set_actions.dart';
import 'package:aves/model/actions/video_actions.dart';
import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/settings/defaults.dart';
import 'package:aves/model/settings/enums.dart';
import 'package:aves/model/settings/map_style.dart';
import 'package:aves/model/settings/screen_on.dart';
@ -13,9 +13,6 @@ import 'package:aves/model/source/enums.dart';
import 'package:aves/services/device_service.dart';
import 'package:aves/services/services.dart';
import 'package:aves/utils/pedantic.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart';
import 'package:aves/widgets/filter_grids/countries_page.dart';
import 'package:aves/widgets/filter_grids/tags_page.dart';
import 'package:collection/collection.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
@ -59,6 +56,7 @@ class Settings extends ChangeNotifier {
// collection
static const collectionGroupFactorKey = 'collection_group_factor';
static const collectionSortFactorKey = 'collection_sort_factor';
static const collectionSelectionQuickActionsKey = 'collection_selection_quick_actions';
static const showThumbnailLocationKey = 'show_thumbnail_location';
static const showThumbnailRawKey = 'show_thumbnail_raw';
static const showThumbnailVideoDurationKey = 'show_thumbnail_video_duration';
@ -108,27 +106,6 @@ class Settings extends ChangeNotifier {
// version
static const lastVersionCheckDateKey = 'last_version_check_date';
// defaults
static final drawerTypeBookmarksDefault = [
null,
MimeFilter.video,
FavouriteFilter.instance,
];
static final drawerPageBookmarksDefault = [
AlbumListPage.routeName,
CountryListPage.routeName,
TagListPage.routeName,
];
static const viewerQuickActionsDefault = [
EntryAction.toggleFavourite,
EntryAction.share,
EntryAction.rotateScreen,
];
static const videoQuickActionsDefault = [
VideoAction.replay10,
VideoAction.togglePlay,
];
Future<void> init() async {
_prefs = await SharedPreferences.getInstance();
_isRotationLocked = await windowService.isRotationLocked();
@ -235,7 +212,7 @@ class Settings extends ChangeNotifier {
if (v.isEmpty) return null;
return CollectionFilter.fromJson(v);
}).toList() ??
drawerTypeBookmarksDefault;
SettingsDefaults.drawerTypeBookmarks;
set drawerTypeBookmarks(List<CollectionFilter?> newValue) => setAndNotify(drawerTypeBookmarksKey, newValue.map((filter) => filter?.toJson() ?? '').toList());
@ -243,7 +220,7 @@ class Settings extends ChangeNotifier {
set drawerAlbumBookmarks(List<String>? newValue) => setAndNotify(drawerAlbumBookmarksKey, newValue);
List<String> get drawerPageBookmarks => _prefs!.getStringList(drawerPageBookmarksKey) ?? drawerPageBookmarksDefault;
List<String> get drawerPageBookmarks => _prefs!.getStringList(drawerPageBookmarksKey) ?? SettingsDefaults.drawerPageBookmarks;
set drawerPageBookmarks(List<String> newValue) => setAndNotify(drawerPageBookmarksKey, newValue);
@ -257,6 +234,10 @@ class Settings extends ChangeNotifier {
set collectionSortFactor(EntrySortFactor newValue) => setAndNotify(collectionSortFactorKey, newValue.toString());
List<EntrySetAction> get collectionSelectionQuickActions => getEnumListOrDefault(collectionSelectionQuickActionsKey, SettingsDefaults.collectionSelectionQuickActions, EntrySetAction.values);
set collectionSelectionQuickActions(List<EntrySetAction> newValue) => setAndNotify(collectionSelectionQuickActionsKey, newValue.map((v) => v.toString()).toList());
bool get showThumbnailLocation => getBoolOrDefault(showThumbnailLocationKey, true);
set showThumbnailLocation(bool newValue) => setAndNotify(showThumbnailLocationKey, newValue);
@ -297,7 +278,7 @@ class Settings extends ChangeNotifier {
// viewer
List<EntryAction> get viewerQuickActions => getEnumListOrDefault(viewerQuickActionsKey, viewerQuickActionsDefault, EntryAction.values);
List<EntryAction> get viewerQuickActions => getEnumListOrDefault(viewerQuickActionsKey, SettingsDefaults.viewerQuickActions, EntryAction.values);
set viewerQuickActions(List<EntryAction> newValue) => setAndNotify(viewerQuickActionsKey, newValue.map((v) => v.toString()).toList());
@ -323,7 +304,7 @@ class Settings extends ChangeNotifier {
// video
List<VideoAction> get videoQuickActions => getEnumListOrDefault(videoQuickActionsKey, videoQuickActionsDefault, VideoAction.values);
List<VideoAction> get videoQuickActions => getEnumListOrDefault(videoQuickActionsKey, SettingsDefaults.videoQuickActions, VideoAction.values);
set videoQuickActions(List<VideoAction> newValue) => setAndNotify(videoQuickActionsKey, newValue.map((v) => v.toString()).toList());
@ -556,6 +537,7 @@ class Settings extends ChangeNotifier {
case drawerPageBookmarksKey:
case pinnedFiltersKey:
case hiddenFiltersKey:
case collectionSelectionQuickActionsKey:
case viewerQuickActionsKey:
case videoQuickActionsKey:
if (value is List) {

View file

@ -7,7 +7,6 @@ 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.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/album.dart';
import 'package:aves/model/source/enums.dart';
@ -22,6 +21,8 @@ import 'package:flutter/foundation.dart';
mixin SourceBase {
EventBus get eventBus;
Map<int?, AvesEntry> get entryById;
Set<AvesEntry> get visibleEntries;
List<AvesEntry> get sortedEntriesByDate;
@ -41,6 +42,11 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
@override
EventBus get eventBus => _eventBus;
final Map<int?, AvesEntry> _entryById = {};
@override
Map<int?, AvesEntry> get entryById => Map.unmodifiable(_entryById);
final Set<AvesEntry> _rawEntries = {};
Set<AvesEntry> get allEntries => Set.unmodifiable(_rawEntries);
@ -61,11 +67,11 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
return _sortedEntriesByDate!;
}
late List<DateMetadata> _savedDates;
late Map<int?, int?> _savedDates;
Future<void> loadDates() async {
final stopwatch = Stopwatch()..start();
_savedDates = List.unmodifiable(await metadataDb.loadDates());
_savedDates = Map.unmodifiable(await metadataDb.loadDates());
debugPrint('$runtimeType loadDates complete in ${stopwatch.elapsed.inMilliseconds}ms for ${_savedDates.length} entries');
}
@ -84,14 +90,16 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
void addEntries(Set<AvesEntry> entries) {
if (entries.isEmpty) return;
final newIdMapEntries = Map.fromEntries(entries.map((v) => MapEntry(v.contentId, v)));
if (_rawEntries.isNotEmpty) {
final newContentIds = entries.map((entry) => entry.contentId).toSet();
final newContentIds = newIdMapEntries.keys.toSet();
_rawEntries.removeWhere((entry) => newContentIds.contains(entry.contentId));
}
entries.forEach((entry) {
final contentId = entry.contentId;
entry.catalogDateMillis = _savedDates.firstWhereOrNull((metadata) => metadata.contentId == contentId)?.dateMillis;
});
entries.forEach((entry) => entry.catalogDateMillis = _savedDates[entry.contentId]);
_entryById.addAll(newIdMapEntries);
_rawEntries.addAll(entries);
_invalidate(entries);
@ -104,6 +112,8 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
final entries = _rawEntries.where((entry) => uris.contains(entry.uri)).toSet();
await favourites.remove(entries);
await covers.removeEntries(entries);
entries.forEach((v) => _entryById.remove(v.contentId));
_rawEntries.removeAll(entries);
_invalidate(entries);
@ -114,6 +124,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
}
void clearEntries() {
_entryById.clear();
_rawEntries.clear();
_invalidate();

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';
@ -20,10 +20,8 @@ mixin LocationMixin on SourceBase {
Future<void> loadAddresses() async {
final stopwatch = Stopwatch()..start();
final saved = await metadataDb.loadAddresses();
visibleEntries.forEach((entry) {
final contentId = entry.contentId;
entry.addressDetails = saved.firstWhereOrNull((address) => address.contentId == contentId);
});
final idMap = entryById;
saved.forEach((metadata) => idMap[metadata.contentId]?.addressDetails = metadata);
debugPrint('$runtimeType loadAddresses complete in ${stopwatch.elapsed.inMilliseconds}ms for ${saved.length} entries');
onAddressMetadataChanged();
}

View file

@ -36,7 +36,7 @@ class MediaStoreSource extends CollectionSource {
settings.catalogTimeZone = currentTimeZone;
}
}
await loadDates(); // 100ms for 5400 entries
await loadDates();
_initialized = true;
debugPrint('$runtimeType init done, elapsed=${stopwatch.elapsed}');
}
@ -49,15 +49,15 @@ class MediaStoreSource extends CollectionSource {
stateNotifier.value = SourceState.loading;
clearEntries();
final oldEntries = await metadataDb.loadEntries(); // 400ms for 5500 entries
final oldEntries = await metadataDb.loadEntries();
final knownDateById = Map.fromEntries(oldEntries.map((entry) => MapEntry(entry.contentId!, entry.dateModifiedSecs!)));
final obsoleteContentIds = (await mediaStoreService.checkObsoleteContentIds(knownDateById.keys.toList())).toSet();
oldEntries.removeWhere((entry) => obsoleteContentIds.contains(entry.contentId));
// show known entries
addEntries(oldEntries);
await loadCatalogMetadata(); // 600ms for 5500 entries
await loadAddresses(); // 200ms for 3000 entries
await loadCatalogMetadata();
await loadAddresses();
debugPrint('$runtimeType refresh loaded ${oldEntries.length} known entries, elapsed=${stopwatch.elapsed}');
// clean up obsolete entries
@ -94,7 +94,7 @@ class MediaStoreSource extends CollectionSource {
addPendingEntries();
debugPrint('$runtimeType refresh loaded ${allNewEntries.length} new entries, elapsed=${stopwatch.elapsed}');
await metadataDb.saveEntries(allNewEntries); // 700ms for 5500 entries
await metadataDb.saveEntries(allNewEntries);
if (allNewEntries.isNotEmpty) {
// new entries include existing entries with obsolete paths

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';
@ -15,10 +15,8 @@ mixin TagMixin on SourceBase {
Future<void> loadCatalogMetadata() async {
final stopwatch = Stopwatch()..start();
final saved = await metadataDb.loadMetadataEntries();
visibleEntries.forEach((entry) {
final contentId = entry.contentId;
entry.catalogMetadata = saved.firstWhereOrNull((metadata) => metadata.contentId == contentId);
});
final idMap = entryById;
saved.forEach((metadata) => idMap[metadata.contentId]?.catalogMetadata = metadata);
debugPrint('$runtimeType loadCatalogMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${saved.length} entries');
onCatalogMetadataChanged();
}

View file

@ -1,6 +1,7 @@
import 'dart:async';
import 'package:aves/model/entry.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';
@ -9,10 +10,11 @@ import 'package:aves/model/video/profiles/h264.dart';
import 'package:aves/model/video/profiles/hevc.dart';
import 'package:aves/ref/languages.dart';
import 'package:aves/ref/mp4.dart';
import 'package:aves/services/services.dart';
import 'package:aves/theme/format.dart';
import 'package:aves/utils/file_utils.dart';
import 'package:aves/utils/math_utils.dart';
import 'package:aves/utils/string_utils.dart';
import 'package:aves/utils/time_utils.dart';
import 'package:aves/widgets/viewer/video/fijkplayer.dart';
import 'package:collection/collection.dart';
import 'package:fijkplayer/fijkplayer.dart';
@ -50,7 +52,7 @@ class VideoMetadataFormatter {
return info;
}
static Future<Map<String, int>> getCatalogMetadata(AvesEntry entry) async {
static Future<Map<String, int>> getLoadingMetadata(AvesEntry entry) async {
final mediaInfo = await getVideoMetadata(entry);
final fields = <String, int>{};
@ -75,6 +77,30 @@ class VideoMetadataFormatter {
return fields;
}
static Future<CatalogMetadata?> getCatalogMetadata(AvesEntry entry) async {
final mediaInfo = await getVideoMetadata(entry);
int? dateMillis;
final dateString = mediaInfo[Keys.date];
if (dateString is String && dateString != '0') {
final date = DateTime.tryParse(dateString);
if (date != null) {
dateMillis = date.millisecondsSinceEpoch;
} else {
await reportService.recordError('getCatalogMetadata failed to parse date=$dateString for mimeType=${entry.mimeType} entry=$entry', null);
}
}
if (dateMillis != null) {
return (entry.catalogMetadata ?? CatalogMetadata(contentId: entry.contentId)).copyWith(
dateMillis: dateMillis,
);
}
return entry.catalogMetadata;
}
// pattern to extract optional language code suffix, e.g. 'location-eng'
static final keyWithLanguagePattern = RegExp(r'^(.*)-([a-z]{3})$');

View file

@ -41,6 +41,7 @@ class MimeTypes {
static const anyVideo = 'video/*';
static const avi = 'video/avi';
static const aviVnd = 'video/vnd.avi';
static const mkv = 'video/x-matroska';
static const mov = 'video/quicktime';
static const mp2t = 'video/mp2t'; // .m2ts
@ -56,12 +57,12 @@ 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};
static const Set<String> _knownVideos = {avi, mkv, mov, mp2t, mp4, ogg};
static const Set<String> _knownVideos = {avi, aviVnd, mkv, mov, mp2t, mp4, ogg};
static final Set<String> knownMediaTypes = {..._knownOpaqueImages, ...alphaImages, ...rawImages, ...undecodableImages, ..._knownVideos};

View file

@ -4,6 +4,7 @@ class XMP {
// cf https://exiftool.org/TagNames/XMP.html
static const Map<String, String> namespaces = {
'acdsee': 'ACDSee',
'adsml-at': 'AdsML',
'aux': 'Exif Aux',
'avm': 'Astronomy Visualization',

View file

@ -136,6 +136,20 @@ class AndroidDebugService {
return {};
}
static Future<Map> getPixyMetadata(AvesEntry entry) async {
try {
// returns map with all data available from the `PixyMeta` library
final result = await platform.invokeMethod('getPixyMetadata', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
});
if (result != null) return result as Map;
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return {};
}
static Future<Map> getTiffStructure(AvesEntry entry) async {
if (entry.mimeType != MimeTypes.tiff) return {};

View file

@ -1,10 +1,10 @@
import 'dart:ui';
import 'package:aves/services/services.dart';
import 'package:aves/theme/format.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:intl/intl.dart';
class GlobalSearch {
static const platform = MethodChannel('deckers.thibault/aves/global_search');
@ -55,7 +55,7 @@ Future<List<Map<String, String?>>> _getSuggestions(dynamic args) async {
'data': entry.uri,
'mimeType': entry.mimeType,
'title': entry.bestTitle,
'subtitle': date != null ? '${DateFormat.yMMMd(locale).format(date)}${DateFormat.Hm(locale).format(date)}' : null,
'subtitle': date != null ? formatDateTime(date, locale) : null,
'iconUri': entry.uri,
};
}));

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<Map<String, dynamic>> editDate(AvesEntry entry, DateModifier modifier);
}
class PlatformImageFileService implements ImageFileService {
@ -408,4 +412,33 @@ class PlatformImageFileService implements ImageFileService {
}
return {};
}
@override
Future<Map<String, dynamic>> 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 Map).cast<String, dynamic>();
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return {};
}
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';

23
lib/theme/format.dart Normal file
View file

@ -0,0 +1,23 @@
import 'package:intl/intl.dart';
String formatDay(DateTime date, String locale) => DateFormat.yMMMd(locale).format(date);
String formatTime(DateTime date, String locale) => DateFormat.Hm(locale).format(date);
String formatDateTime(DateTime date, String locale) => '${formatDay(date, locale)}${formatTime(date, locale)}';
String formatFriendlyDuration(Duration d) {
final seconds = (d.inSeconds.remainder(Duration.secondsPerMinute)).toString().padLeft(2, '0');
if (d.inHours == 0) return '${d.inMinutes}:$seconds';
final minutes = (d.inMinutes.remainder(Duration.minutesPerHour)).toString().padLeft(2, '0');
return '${d.inHours}:$minutes:$seconds';
}
String formatPreciseDuration(Duration d) {
final millis = ((d.inMicroseconds / 1000.0).round() % 1000).toString().padLeft(3, '0');
final seconds = (d.inSeconds.remainder(Duration.secondsPerMinute)).toString().padLeft(2, '0');
final minutes = (d.inMinutes.remainder(Duration.minutesPerHour)).toString().padLeft(2, '0');
final hours = (d.inHours).toString().padLeft(2, '0');
return '$hours:$minutes:$seconds.$millis';
}

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

@ -77,6 +77,11 @@ class Constants {
license: 'Apache 2.0',
sourceUrl: 'https://github.com/drewnoakes/metadata-extractor',
),
Dependency(
name: 'PixyMeta Android (Aves fork)',
license: 'Eclipse Public License 1.0',
sourceUrl: 'https://github.com/deckerst/pixymeta-android',
),
];
static const List<Dependency> flutterPlugins = [
@ -265,7 +270,7 @@ class Constants {
sourceUrl: 'https://github.com/fluttercommunity/get_it',
),
Dependency(
name: 'Github',
name: 'GitHub',
license: 'MIT',
sourceUrl: 'https://github.com/SpinlockLabs/github.dart',
),

View file

@ -1,19 +1,3 @@
String formatFriendlyDuration(Duration d) {
final seconds = (d.inSeconds.remainder(Duration.secondsPerMinute)).toString().padLeft(2, '0');
if (d.inHours == 0) return '${d.inMinutes}:$seconds';
final minutes = (d.inMinutes.remainder(Duration.minutesPerHour)).toString().padLeft(2, '0');
return '${d.inHours}:$minutes:$seconds';
}
String formatPreciseDuration(Duration d) {
final millis = ((d.inMicroseconds / 1000.0).round() % 1000).toString().padLeft(3, '0');
final seconds = (d.inSeconds.remainder(Duration.secondsPerMinute)).toString().padLeft(2, '0');
final minutes = (d.inMinutes.remainder(Duration.minutesPerHour)).toString().padLeft(2, '0');
final hours = (d.inHours).toString().padLeft(2, '0');
return '$hours:$minutes:$seconds.$millis';
}
extension ExtraDateTime on DateTime {
bool isAtSameYearAs(DateTime? other) => year == other?.year;

View file

@ -61,7 +61,7 @@ class _AboutUpdateState extends State<AboutUpdate> {
TextSpan(text: context.l10n.aboutUpdateLinks1),
WidgetSpan(
child: LinkChip(
text: context.l10n.aboutUpdateGithub,
text: context.l10n.aboutUpdateGitHub,
url: 'https://github.com/deckerst/aves/releases',
textStyle: const TextStyle(fontWeight: FontWeight.bold),
),

View file

@ -1,7 +1,6 @@
import 'dart:async';
import 'package:aves/app_mode.dart';
import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/actions/entry_set_actions.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart';
@ -133,6 +132,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
tooltip = MaterialLocalizations.of(context).openAppDrawerTooltip;
}
return IconButton(
// key is expected by test driver
key: const Key('appbar-leading-button'),
icon: AnimatedIcon(
icon: AnimatedIcons.menu_arrow,
@ -167,6 +167,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
List<Widget> _buildActions(bool isSelecting) {
final appMode = context.watch<ValueNotifier<AppMode>>().value;
final selectionQuickActions = settings.collectionSelectionQuickActions;
return [
if (!isSelecting && appMode.canSearch)
CollectionSearchButton(
@ -174,11 +175,11 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
parentCollection: collection,
),
if (isSelecting)
...EntryActions.selection.map((action) => Selector<Selection<AvesEntry>, bool>(
...selectionQuickActions.map((action) => Selector<Selection<AvesEntry>, bool>(
selector: (context, selection) => selection.selectedItems.isEmpty,
builder: (context, isEmpty, child) => IconButton(
icon: action.getIcon() ?? const SizedBox(),
onPressed: isEmpty ? null : () => _actionDelegate.onEntryActionSelected(context, action),
icon: action.getIcon(),
onPressed: isEmpty ? null : () => _onCollectionActionSelected(action),
tooltip: action.getText(context),
),
)),
@ -188,6 +189,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
final canAddShortcuts = snapshot.data ?? false;
return MenuIconTheme(
child: PopupMenuButton<EntrySetAction>(
// key is expected by test driver
key: const Key('appbar-menu-button'),
itemBuilder: (context) {
final groupable = collection.sortFactor == EntrySortFactor.date;
@ -201,11 +203,13 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
return [
_toMenuItem(
EntrySetAction.sort,
// key is expected by test driver
key: const Key('menu-sort'),
),
if (groupable)
_toMenuItem(
EntrySetAction.group,
// key is expected by test driver
key: const Key('menu-group'),
),
if (appMode == AppMode.main) ...[
@ -215,16 +219,12 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
enabled: hasItems,
),
const PopupMenuDivider(),
if (isSelecting)
if (isSelecting) ...EntrySetActions.selection.where((v) => !selectionQuickActions.contains(v)).map((v) => _toMenuItem(v, enabled: hasSelection)),
if (!isSelecting)
...[
EntrySetAction.copy,
EntrySetAction.move,
EntrySetAction.refreshMetadata,
].map((v) => _toMenuItem(v, enabled: hasSelection)),
...[
EntrySetAction.map,
EntrySetAction.stats,
].map((v) => _toMenuItem(v, enabled: otherViewEnabled)),
EntrySetAction.map,
EntrySetAction.stats,
].map((v) => _toMenuItem(v, enabled: otherViewEnabled)),
if (!isSelecting && canAddShortcuts) ...[
const PopupMenuDivider(),
_toMenuItem(EntrySetAction.addShortcut),
@ -286,12 +286,14 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
Future<void> _onCollectionActionSelected(EntrySetAction action) async {
switch (action) {
case EntrySetAction.share:
case EntrySetAction.delete:
case EntrySetAction.copy:
case EntrySetAction.move:
case EntrySetAction.refreshMetadata:
case EntrySetAction.map:
case EntrySetAction.stats:
_actionDelegate.onCollectionActionSelected(context, action);
_actionDelegate.onActionSelected(context, action);
break;
case EntrySetAction.select:
context.read<Selection<AvesEntry>>().select();

View file

@ -56,6 +56,7 @@ class _CollectionPageState extends State<CollectionPage> {
child: ChangeNotifierProvider<CollectionLens>.value(
value: collection,
child: const CollectionGrid(
// key is expected by test driver
key: Key('collection-grid'),
),
),

View file

@ -1,6 +1,5 @@
import 'dart:async';
import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/actions/entry_set_actions.dart';
import 'package:aves/model/actions/move_type.dart';
import 'package:aves/model/entry.dart';
@ -30,21 +29,14 @@ import 'package:flutter/widgets.dart';
import 'package:provider/provider.dart';
class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
void onEntryActionSelected(BuildContext context, EntryAction action) {
void onActionSelected(BuildContext context, EntrySetAction action) {
switch (action) {
case EntryAction.delete:
_showDeleteDialog(context);
break;
case EntryAction.share:
case EntrySetAction.share:
_share(context);
break;
default:
case EntrySetAction.delete:
_showDeleteDialog(context);
break;
}
}
void onCollectionActionSelected(BuildContext context, EntrySetAction action) {
switch (action) {
case EntrySetAction.copy:
_moveSelection(context, moveType: MoveType.copy);
break;

View file

@ -1,3 +1,4 @@
import 'package:aves/theme/format.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/grid/section_layout.dart';
import 'package:flutter/material.dart';
@ -62,6 +63,6 @@ class DraggableThumbLabel<T> extends StatelessWidget {
static String formatDayThumbLabel(BuildContext context, DateTime? date) {
final l10n = context.l10n;
if (date == null) return l10n.sectionUnknown;
return DateFormat.yMMMd(l10n.localeName).format(date);
return formatDay(date, l10n.localeName);
}
}

View file

@ -48,6 +48,7 @@ class AvesExpansionTile extends StatelessWidget {
accentColor: Colors.white,
),
child: ExpansionTileCard(
// key is expected by test driver
key: Key('tilecard-$value'),
value: value,
expandedNotifier: expandedNotifier,

View file

@ -11,13 +11,11 @@ import 'package:flutter/material.dart';
class ErrorThumbnail extends StatefulWidget {
final AvesEntry entry;
final double extent;
final String tooltip;
const ErrorThumbnail({
Key? key,
required this.entry,
required this.extent,
required this.tooltip,
}) : super(key: key);
@override
@ -48,27 +46,27 @@ class _ErrorThumbnailState extends State<ErrorThumbnail> {
child = const SizedBox();
} else {
final exists = snapshot.data!;
child = Tooltip(
message: exists ? widget.tooltip : context.l10n.viewerErrorDoesNotExist,
preferBelow: false,
child: exists
? LayoutBuilder(builder: (context, constraints) {
final fontSize = min(extent, constraints.biggest.width) / 5;
return Text(
MimeUtils.displayType(entry.mimeType),
style: TextStyle(
color: color,
fontSize: fontSize,
),
textAlign: TextAlign.center,
);
})
: Icon(
child = exists
? LayoutBuilder(builder: (context, constraints) {
final fontSize = min(extent, constraints.biggest.width) / 5;
return Text(
MimeUtils.displayType(entry.mimeType),
style: TextStyle(
color: color,
fontSize: fontSize,
),
textAlign: TextAlign.center,
);
})
: Tooltip(
message: context.l10n.viewerErrorDoesNotExist,
preferBelow: false,
child: Icon(
AIcons.broken,
size: extent / 2,
color: color,
),
);
);
}
return Container(
alignment: Alignment.center,

View file

@ -8,7 +8,6 @@ import 'package:aves/model/settings/entry_background.dart';
import 'package:aves/model/settings/enums.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/services/services.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/fx/checkered_decoration.dart';
import 'package:aves/widgets/common/fx/transition_image.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
@ -173,10 +172,8 @@ class _ThumbnailImageState extends State<ThumbnailImage> {
@override
Widget build(BuildContext context) {
if (!entry.canDecode) {
return _buildError(context, context.l10n.errorUnsupportedMimeType(entry.mimeType), null);
} else if (_lastException != null) {
return _buildError(context, _lastException.toString(), null);
if (!entry.canDecode || _lastException != null) {
return _buildError(context);
}
// use `RawImage` instead of `Image`, using `ImageInfo` to check dimensions
@ -246,11 +243,10 @@ class _ThumbnailImageState extends State<ThumbnailImage> {
: image;
}
Widget _buildError(BuildContext context, Object error, StackTrace? stackTrace) {
Widget _buildError(BuildContext context) {
final child = ErrorThumbnail(
entry: entry,
extent: extent,
tooltip: error.toString(),
);
return widget.heroTag != null
? Hero(

View file

@ -95,6 +95,14 @@ class _AppDebugPageState extends State<AppDebugPage> {
},
title: const Text('Show tasks overlay'),
),
ElevatedButton(
onPressed: () async {
final source = context.read<CollectionSource>();
await source.init();
await source.refresh();
},
child: const Text('Source full refresh'),
),
const Divider(),
Padding(
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8),

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';
@ -17,7 +18,7 @@ class DebugAppDatabaseSection extends StatefulWidget {
class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with AutomaticKeepAliveClientMixin {
late Future<int> _dbFileSizeLoader;
late Future<Set<AvesEntry>> _dbEntryLoader;
late Future<List<DateMetadata>> _dbDateLoader;
late Future<Map<int?, int?>> _dbDateLoader;
late Future<List<CatalogMetadata>> _dbMetadataLoader;
late Future<List<AddressDetails>> _dbAddressLoader;
late Future<Set<FavouriteRow>> _dbFavouritesLoader;
@ -82,7 +83,7 @@ class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with
);
},
),
FutureBuilder<List>(
FutureBuilder<Map<int?, int?>>(
future: _dbDateLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());

View file

@ -52,6 +52,7 @@ class DebugSettingsSection extends StatelessWidget {
'tileExtent - Countries': '${settings.getTileExtent(CountryListPage.routeName)}',
'tileExtent - Tags': '${settings.getTileExtent(TagListPage.routeName)}',
'infoMapZoom': '${settings.infoMapZoom}',
'collectionSelectionQuickActions': '${settings.collectionSelectionQuickActions}',
'viewerQuickActions': '${settings.viewerQuickActions}',
'videoQuickActions': '${settings.videoQuickActions}',
'drawerTypeBookmarks': toMultiline(settings.drawerTypeBookmarks),

View file

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

View file

@ -51,6 +51,7 @@ class _AvesSelectionDialogState<T> extends State<AvesSelectionDialog<T>> {
Widget _buildRadioListTile(T value, String title) {
final subtitle = widget.optionSubtitleBuilder?.call(value);
return ReselectableRadioListTile<T>(
// key is expected by test driver
key: Key(value.toString()),
value: value,
groupValue: _selectedValue,

View file

@ -73,15 +73,17 @@ class _CoverSelectionDialogState extends State<CoverSelectionDialog> {
setState(() {});
},
title: isCustom
? Row(children: [
title,
const Spacer(),
IconButton(
icon: const Icon(AIcons.setCover),
onPressed: _isCustom ? _pickEntry : null,
tooltip: context.l10n.changeTooltip,
),
])
? Row(
children: [
title,
const Spacer(),
IconButton(
icon: const Icon(AIcons.setCover),
onPressed: _isCustom ? _pickEntry : null,
tooltip: context.l10n.changeTooltip,
),
],
)
: title,
);
},

View file

@ -0,0 +1,420 @@
import 'package:aves/model/entry.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 EditEntryDateDialog extends StatefulWidget {
final AvesEntry entry;
const EditEntryDateDialog({
Key? key,
required this.entry,
}) : super(key: key);
@override
_EditEntryDateDialogState createState() => _EditEntryDateDialogState();
}
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();
_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,
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),
),
],
);
}
}
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(),
),
),
),
);
}
}

View file

@ -142,6 +142,7 @@ class _AppDrawerState extends State<AppDrawer> {
runSpacing: 8,
children: [
OutlinedButton.icon(
// key is expected by test driver
key: const Key('drawer-about-button'),
onPressed: () => goTo(AboutPage.routeName, (_) => const AboutPage()),
icon: const Icon(AIcons.info),
@ -178,6 +179,7 @@ class _AppDrawerState extends State<AppDrawer> {
),
),
OutlinedButton.icon(
// key is expected by test driver
key: const Key('drawer-settings-button'),
onPressed: () => goTo(SettingsPage.routeName, (_) => const SettingsPage()),
icon: const Icon(AIcons.settings),

View file

@ -24,6 +24,7 @@ class PageNavTile extends StatelessWidget {
top: false,
bottom: false,
child: ListTile(
// key is expected by test driver
key: Key('$routeName-tile'),
leading: DrawerPageIcon(route: routeName),
title: DrawerPageTitle(route: routeName),

View file

@ -98,6 +98,7 @@ class _FilterGridAppBarState<T extends CollectionFilter> extends State<FilterGri
tooltip = MaterialLocalizations.of(context).openAppDrawerTooltip;
}
return IconButton(
// key is expected by test driver
key: const Key('appbar-leading-button'),
icon: AnimatedIcon(
icon: AnimatedIcons.menu_arrow,
@ -170,7 +171,6 @@ class _FilterGridAppBarState<T extends CollectionFilter> extends State<FilterGri
...buttonActions,
MenuIconTheme(
child: PopupMenuButton<ChipSetAction>(
key: const Key('appbar-menu-button'),
itemBuilder: (context) {
final selectedItems = selection.selectedItems;
final hasSelection = selectedItems.isNotEmpty;

View file

@ -90,6 +90,8 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
child: AnimatedBuilder(
animation: covers,
builder: (context, child) => FilterGrid<T>(
// key is expected by test driver
key: const Key('filter-grid'),
settingsRouteKey: settingsRouteKey,
appBar: appBar,
appBarHeight: appBarHeight,

View file

@ -39,7 +39,6 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
return SelectionProvider<FilterGridItem<T>>(
child: Builder(
builder: (context) => FilterGridPage<T>(
key: const Key('filter-grid-page'),
appBar: FilterGridAppBar<T>(
source: source,
title: title,

View file

@ -17,6 +17,7 @@ class CollectionSearchButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return IconButton(
// key is expected by test driver
key: const Key('search-button'),
icon: const Icon(AIcons.search),
onPressed: () => _goToSearch(context),

View file

@ -14,7 +14,7 @@ import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/settings/language/language.dart';
import 'package:aves/widgets/settings/navigation/navigation.dart';
import 'package:aves/widgets/settings/privacy/privacy.dart';
import 'package:aves/widgets/settings/thumbnails.dart';
import 'package:aves/widgets/settings/thumbnails/thumbnails.dart';
import 'package:aves/widgets/settings/video/video.dart';
import 'package:aves/widgets/settings/viewer/viewer.dart';
import 'package:flutter/material.dart';

View file

@ -0,0 +1,44 @@
import 'package:aves/model/actions/entry_set_actions.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/settings/common/quick_actions/editor_page.dart';
import 'package:flutter/material.dart';
class SelectionActionsTile extends StatelessWidget {
const SelectionActionsTile({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(context.l10n.settingsCollectionSelectionQuickActionsTile),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: SelectionActionEditorPage.routeName),
builder: (context) => const SelectionActionEditorPage(),
),
);
},
);
}
}
class SelectionActionEditorPage extends StatelessWidget {
static const routeName = '/settings/collection_selection_actions';
const SelectionActionEditorPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return QuickActionEditorPage<EntrySetAction>(
title: context.l10n.settingsCollectionSelectionQuickActionEditorTitle,
bannerText: context.l10n.settingsCollectionSelectionQuickActionEditorBanner,
allAvailableActions: EntrySetActions.selection,
actionIcon: (action) => action.getIcon(),
actionText: (context, action) => action.getText(context),
load: () => settings.collectionSelectionQuickActions.toList(),
save: (actions) => settings.collectionSelectionQuickActions = actions,
);
}
}

View file

@ -5,6 +5,7 @@ import 'package:aves/utils/color_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
import 'package:aves/widgets/settings/common/tile_leading.dart';
import 'package:aves/widgets/settings/thumbnails/selection_actions_editor.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@ -34,6 +35,7 @@ class ThumbnailsSection extends StatelessWidget {
expandedNotifier: expandedNotifier,
showHighlight: false,
children: [
const SelectionActionsTile(),
SwitchListTile(
value: currentShowThumbnailLocation,
onChanged: (v) => settings.showThumbnailLocation = v,

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';
@ -18,7 +19,7 @@ class DbTab extends StatefulWidget {
}
class _DbTabState extends State<DbTab> {
late Future<DateMetadata?> _dbDateLoader;
late Future<int?> _dbDateLoader;
late Future<AvesEntry?> _dbEntryLoader;
late Future<CatalogMetadata?> _dbMetadataLoader;
late Future<AddressDetails?> _dbAddressLoader;
@ -33,7 +34,7 @@ class _DbTabState extends State<DbTab> {
void _loadDatabase() {
final contentId = entry.contentId;
_dbDateLoader = metadataDb.loadDates().then((values) => values.firstWhereOrNull((row) => row.contentId == contentId));
_dbDateLoader = metadataDb.loadDates().then((values) => values[contentId]);
_dbEntryLoader = metadataDb.loadEntries().then((values) => values.firstWhereOrNull((row) => row.contentId == contentId));
_dbMetadataLoader = metadataDb.loadMetadataEntries().then((values) => values.firstWhereOrNull((row) => row.contentId == contentId));
_dbAddressLoader = metadataDb.loadAddresses().then((values) => values.firstWhereOrNull((row) => row.contentId == contentId));
@ -45,7 +46,7 @@ class _DbTabState extends State<DbTab> {
return ListView(
padding: const EdgeInsets.all(16),
children: [
FutureBuilder<DateMetadata?>(
FutureBuilder<int?>(
future: _dbDateLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
@ -58,7 +59,7 @@ class _DbTabState extends State<DbTab> {
if (data != null)
InfoRowGroup(
info: {
'dateMillis': '${data.dateMillis}',
'dateMillis': '$data',
},
),
],

View file

@ -22,7 +22,7 @@ class MetadataTab extends StatefulWidget {
}
class _MetadataTabState extends State<MetadataTab> {
late Future<Map> _bitmapFactoryLoader, _contentResolverMetadataLoader, _exifInterfaceMetadataLoader, _mediaMetadataLoader, _metadataExtractorLoader, _tiffStructureLoader;
late Future<Map> _bitmapFactoryLoader, _contentResolverMetadataLoader, _exifInterfaceMetadataLoader, _mediaMetadataLoader, _metadataExtractorLoader, _pixyMetaLoader, _tiffStructureLoader;
// MediaStore timestamp keys
static const secondTimestampKeys = ['date_added', 'date_modified', 'date_expires', 'isPlayed'];
@ -42,6 +42,7 @@ class _MetadataTabState extends State<MetadataTab> {
_exifInterfaceMetadataLoader = AndroidDebugService.getExifInterfaceMetadata(entry);
_mediaMetadataLoader = AndroidDebugService.getMediaMetadataRetrieverMetadata(entry);
_metadataExtractorLoader = AndroidDebugService.getMetadataExtractorSummary(entry);
_pixyMetaLoader = AndroidDebugService.getPixyMetadata(entry);
_tiffStructureLoader = AndroidDebugService.getTiffStructure(entry);
setState(() {});
}
@ -107,6 +108,10 @@ class _MetadataTabState extends State<MetadataTab> {
future: _metadataExtractorLoader,
builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Metadata Extractor'),
),
FutureBuilder<Map>(
future: _pixyMetaLoader,
builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Pixy Meta'),
),
if (entry.mimeType == MimeTypes.tiff)
FutureBuilder<Map>(
future: _tiffStructureLoader,

View file

@ -38,6 +38,7 @@ class _MultiEntryScrollerState extends State<MultiEntryScroller> with AutomaticK
return MagnifierGestureDetectorScope(
axis: const [Axis.horizontal, Axis.vertical],
child: PageView.builder(
// key is expected by test driver
key: const Key('horizontal-pageview'),
scrollDirection: Axis.horizontal,
controller: pageController,
@ -81,6 +82,7 @@ class _MultiEntryScrollerState extends State<MultiEntryScroller> with AutomaticK
Widget _buildViewer(AvesEntry mainEntry, {AvesEntry? pageEntry}) {
return EntryPageView(
// key is expected by test driver
key: const Key('imageview'),
mainEntry: mainEntry,
pageEntry: pageEntry ?? mainEntry,

View file

@ -131,6 +131,7 @@ class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
child: child,
),
child: PageView(
// key is expected by test driver
key: const Key('vertical-pageview'),
scrollDirection: Axis.vertical,
controller: widget.verticalPager,

View file

@ -8,6 +8,7 @@ import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/filters/type.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/services/services.dart';
import 'package:aves/theme/format.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/file_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
@ -16,7 +17,6 @@ import 'package:aves/widgets/viewer/info/common.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
class BasicSection extends StatelessWidget {
final AvesEntry entry;
@ -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 ? '${DateFormat.yMMMd(locale).format(date)}${DateFormat.Hm(locale).format(date)}' : 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) {

View file

@ -1,12 +1,20 @@
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/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';
class InfoAppBar extends StatelessWidget {
class InfoAppBar extends StatelessWidget with FeedbackMixin, PermissionAwareMixin {
final AvesEntry entry;
final ValueNotifier<Map<String, MetadataDirectory>> metadataNotifier;
final VoidCallback onBackPressed;
@ -22,6 +30,7 @@ class InfoAppBar extends StatelessWidget {
Widget build(BuildContext context) {
return SliverAppBar(
leading: IconButton(
// key is expected by test driver
key: const Key('back-button'),
icon: const Icon(AIcons.goUp),
onPressed: onBackPressed,
@ -37,6 +46,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,
@ -53,4 +79,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 entry.editDate(modifier, persist: true);
if (success) {
showFeedback(context, context.l10n.genericSuccessFeedback);
} else {
showFeedback(context, context.l10n.genericFailureFeedback);
}
}
}

View file

@ -1,12 +1,13 @@
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';
import 'package:aves/services/services.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/format.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
@ -18,7 +19,6 @@ import 'package:aves/widgets/viewer/page_entry_builder.dart';
import 'package:decorated_icon/decorated_icon.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
@ -387,7 +387,7 @@ class _DateRow extends StatelessWidget {
Widget build(BuildContext context) {
final locale = context.l10n.localeName;
final date = entry.bestDate;
final dateText = date != null ? '${DateFormat.yMMMd(locale).format(date)}${DateFormat.Hm(locale).format(date)}' : Constants.overlayUnknown;
final dateText = date != null ? formatDateTime(date, locale) : Constants.overlayUnknown;
final resolutionText = entry.isSvg
? entry.aspectRatioText
: entry.isSized

View file

@ -5,9 +5,9 @@ import 'package:aves/model/entry.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/services/android_app_service.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/format.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/utils/time_utils.dart';
import 'package:aves/widgets/common/basic/menu.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/fx/blurred.dart';

View file

@ -109,6 +109,7 @@ class _WelcomePageState extends State<WelcomePage> {
text: context.l10n.welcomeCrashReportToggle,
),
LabeledCheckbox(
// key is expected by test driver
key: const Key('agree-checkbox'),
value: _hasAcceptedTerms,
onChanged: (v) {
@ -120,6 +121,7 @@ class _WelcomePageState extends State<WelcomePage> {
);
final button = ElevatedButton(
// key is expected by test driver
key: const Key('continue-button'),
onPressed: _hasAcceptedTerms
? () {
@ -165,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

@ -28,7 +28,7 @@ packages:
name: args
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.1"
version: "2.2.0"
async:
dependency: transitive
description:
@ -105,14 +105,14 @@ packages:
name: connectivity_plus
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.6"
version: "1.0.7"
connectivity_plus_linux:
dependency: transitive
description:
name: connectivity_plus_linux
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.3"
version: "1.1.0"
connectivity_plus_macos:
dependency: transitive
description:
@ -126,14 +126,14 @@ packages:
name: connectivity_plus_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.2"
version: "1.1.0"
connectivity_plus_web:
dependency: transitive
description:
name: connectivity_plus_web
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.2"
version: "1.1.0"
connectivity_plus_windows:
dependency: transitive
description:
@ -182,7 +182,7 @@ packages:
name: dbus
url: "https://pub.dartlang.org"
source: hosted
version: "0.5.4"
version: "0.5.6"
decorated_icon:
dependency: "direct main"
description:
@ -249,7 +249,7 @@ packages:
name: firebase_core
url: "https://pub.dartlang.org"
source: hosted
version: "1.4.0"
version: "1.5.0"
firebase_core_platform_interface:
dependency: transitive
description:
@ -270,14 +270,14 @@ packages:
name: firebase_crashlytics
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.1"
version: "2.2.0"
firebase_crashlytics_platform_interface:
dependency: transitive
description:
name: firebase_crashlytics_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "3.1.0"
version: "3.1.1"
flex_color_picker:
dependency: "direct main"
description:
@ -341,7 +341,7 @@ packages:
name: flutter_markdown
url: "https://pub.dartlang.org"
source: hosted
version: "0.6.2"
version: "0.6.4"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
@ -412,7 +412,7 @@ packages:
name: google_maps_flutter_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0"
version: "2.1.1"
highlight:
dependency: transitive
description:
@ -560,6 +560,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
nm:
dependency: transitive
description:
name: nm
url: "https://pub.dartlang.org"
source: hosted
version: "0.3.0"
node_preamble:
dependency: transitive
description:
@ -587,7 +594,7 @@ packages:
name: package_info_plus
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.4"
version: "1.0.5"
package_info_plus_linux:
dependency: transitive
description:
@ -657,7 +664,7 @@ packages:
name: path_provider_linux
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
version: "2.0.2"
path_provider_platform_interface:
dependency: transitive
description:
@ -671,7 +678,7 @@ packages:
name: path_provider_windows
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.1"
version: "2.0.3"
pdf:
dependency: "direct main"
description:
@ -699,14 +706,14 @@ packages:
name: permission_handler
url: "https://pub.dartlang.org"
source: hosted
version: "8.1.2"
version: "8.1.4+2"
permission_handler_platform_interface:
dependency: transitive
description:
name: permission_handler_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "3.6.0"
version: "3.6.1"
petitparser:
dependency: transitive
description:
@ -748,7 +755,7 @@ packages:
name: printing
url: "https://pub.dartlang.org"
source: hosted
version: "5.4.3"
version: "5.5.0"
process:
dependency: transitive
description:
@ -769,7 +776,7 @@ packages:
name: provider
url: "https://pub.dartlang.org"
source: hosted
version: "5.0.0"
version: "6.0.0"
pub_semver:
dependency: transitive
description:
@ -804,14 +811,14 @@ packages:
name: shared_preferences_linux
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
version: "2.0.2"
shared_preferences_macos:
dependency: transitive
description:
name: shared_preferences_macos
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
version: "2.0.2"
shared_preferences_platform_interface:
dependency: transitive
description:
@ -825,14 +832,14 @@ packages:
name: shared_preferences_web
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
version: "2.0.1"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
version: "2.0.2"
shelf:
dependency: transitive
description:
@ -1021,14 +1028,14 @@ packages:
name: url_launcher_linux
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
version: "2.0.1"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
version: "2.0.1"
url_launcher_platform_interface:
dependency: transitive
description:
@ -1042,14 +1049,14 @@ packages:
name: url_launcher_web
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.1"
version: "2.0.2"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
version: "2.0.2"
vector_math:
dependency: transitive
description:

View file

@ -1,7 +1,7 @@
name: aves
description: A visual media gallery and metadata explorer app.
repository: https://github.com/deckerst/aves
version: 1.4.9+53
version: 1.5.0+54
publish_to: none
environment:

File diff suppressed because one or more lines are too long

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';
@ -24,7 +25,7 @@ class FakeMetadataDb extends Fake implements MetadataDb {
Future<void> updateEntryId(int oldId, AvesEntry entry) => SynchronousFuture(null);
@override
Future<List<DateMetadata>> loadDates() => SynchronousFuture([]);
Future<Map<int?, int?>> loadDates() => SynchronousFuture({});
@override
Future<List<CatalogMetadata>> loadMetadataEntries() => SynchronousFuture([]);

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

View file

@ -129,10 +129,11 @@ void selectFirstAlbum() {
// wait for collection loading
await driver.waitForCondition(const NoPendingPlatformMessages());
// TODO TLAD fix finder
// delay to avoid flaky descendant resolution
await Future.delayed(const Duration(seconds: 2));
await driver.tap(find.descendant(
of: find.byValueKey('filter-grid-page'),
matching: find.byType('CoveredFilterChip'),
of: find.byValueKey('filter-grid'),
matching: find.byType('MetaData'),
firstMatchOnly: true,
));
await driver.waitUntilNoTransientCallbacks();
@ -158,9 +159,11 @@ void searchAlbum() {
void showViewer() {
test('[collection] show viewer', () async {
// delay to avoid flaky descendant resolution
await Future.delayed(const Duration(seconds: 2));
await driver.tap(find.descendant(
of: find.byValueKey('collection-grid'),
matching: find.byType('DecoratedThumbnail'),
matching: find.byType('MetaData'),
firstMatchOnly: true,
));
await driver.waitUntilNoTransientCallbacks();

View file

@ -3,10 +3,13 @@ import 'dart:io';
import 'package:path/path.dart' as p;
String get adb {
final env = Platform.environment;
// e.g. C:\Users\<username>\AppData\Local\Android\Sdk
final sdkDir = env['ANDROID_SDK_ROOT'] ?? env['ANDROID_SDK']!;
return p.join(sdkDir, 'platform-tools', Platform.isWindows ? 'adb.exe' : 'adb');
if (Platform.isWindows) {
final env = Platform.environment;
// e.g. C:\Users\<username>\AppData\Local\Android\Sdk
final sdkDir = env['ANDROID_SDK_ROOT'] ?? env['ANDROID_SDK']!;
return p.join(sdkDir, 'platform-tools', 'adb.exe');
}
return 'adb';
}
/*

View file

@ -1,7 +1,6 @@
Thanks for using Aves!
v1.4.9:
- open the map or get stats for selected items
- browse and navigate to items on the map
- customize the navigation menu
- create shortcuts on Android Nougat and older
Full changelog available on Github
v1.5.0:
- faster launch
- edit Exif dates
- customize quick actions when selecting pictures
Full changelog available on GitHub