Merge branch 'develop'
This commit is contained in:
commit
34135b9893
82 changed files with 1625 additions and 472 deletions
13
CHANGELOG.md
13
CHANGELOG.md
|
@ -3,6 +3,19 @@ All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [v1.4.9] - 2021-08-20
|
||||||
### Added
|
### Added
|
||||||
- Map & Stats from selection
|
- Map & Stats from selection
|
||||||
|
|
|
@ -32,7 +32,7 @@ if (keystorePropertiesFile.exists()) {
|
||||||
keystoreProperties.load(reader)
|
keystoreProperties.load(reader)
|
||||||
}
|
}
|
||||||
} else {
|
} 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
|
// warning: in property file, single quotes should be escaped with a backslash
|
||||||
// but they should not be escaped when stored in env variables
|
// but they should not be escaped when stored in env variables
|
||||||
keystoreProperties['storeFile'] = System.getenv('AVES_STORE_FILE')
|
keystoreProperties['storeFile'] = System.getenv('AVES_STORE_FILE')
|
||||||
|
@ -120,7 +120,10 @@ dependencies {
|
||||||
implementation 'com.caverock:androidsvg-aar:1.4'
|
implementation 'com.caverock:androidsvg-aar:1.4'
|
||||||
implementation 'com.commonsware.cwac:document:0.4.1'
|
implementation 'com.commonsware.cwac:document:0.4.1'
|
||||||
implementation 'com.drewnoakes:metadata-extractor:2.16.0'
|
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
|
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'
|
implementation 'com.github.bumptech.glide:glide:4.12.0'
|
||||||
|
|
||||||
kapt 'androidx.annotation:annotation:1.2.0'
|
kapt 'androidx.annotation:annotation:1.2.0'
|
||||||
|
|
|
@ -17,11 +17,13 @@ import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper
|
import deckers.thibault.aves.metadata.ExifInterfaceHelper
|
||||||
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper
|
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper
|
||||||
import deckers.thibault.aves.metadata.Metadata
|
import deckers.thibault.aves.metadata.Metadata
|
||||||
|
import deckers.thibault.aves.metadata.PixyMetaHelper
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isImage
|
import deckers.thibault.aves.utils.MimeTypes.isImage
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface
|
import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor
|
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.MimeTypes.isVideo
|
||||||
import deckers.thibault.aves.utils.StorageUtils
|
import deckers.thibault.aves.utils.StorageUtils
|
||||||
import deckers.thibault.aves.utils.UriUtils.tryParseId
|
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) }
|
"getExifInterfaceMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getExifInterfaceMetadata) }
|
||||||
"getMediaMetadataRetrieverMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMediaMetadataRetrieverMetadata) }
|
"getMediaMetadataRetrieverMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMediaMetadataRetrieverMetadata) }
|
||||||
"getMetadataExtractorSummary" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMetadataExtractorSummary) }
|
"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) }
|
"getTiffStructure" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getTiffStructure) }
|
||||||
else -> result.notImplemented()
|
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) {
|
private fun getContextDirs(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
|
||||||
val dirs = hashMapOf(
|
val dirs = hashMapOf(
|
||||||
"cacheDir" to context.cacheDir,
|
"cacheDir" to context.cacheDir,
|
||||||
|
|
|
@ -15,11 +15,10 @@ class DeviceHandler : MethodCallHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getPerformanceClass(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
|
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) {
|
||||||
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
result.success(Build.VERSION.MEDIA_PERFORMANCE_CLASS)
|
||||||
// result.success(Build.VERSION.MEDIA_PERFORMANCE_CLASS)
|
return
|
||||||
// return
|
}
|
||||||
// }
|
|
||||||
result.success(Build.VERSION.SDK_INT)
|
result.success(Build.VERSION.SDK_INT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -160,9 +160,9 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
try {
|
try {
|
||||||
val embedBytes: ByteArray = if (!dataPropPath.contains('/')) {
|
val embedBytes: ByteArray = if (!dataPropPath.contains('/')) {
|
||||||
val propNs = XMP.namespaceForPropPath(dataPropPath)
|
val propNs = XMP.namespaceForPropPath(dataPropPath)
|
||||||
xmpDirs.map { it.xmpMeta.getPropertyBase64(propNs, dataPropPath) }.filterNotNull().first()
|
xmpDirs.mapNotNull { it.xmpMeta.getPropertyBase64(propNs, dataPropPath) }.first()
|
||||||
} else {
|
} else {
|
||||||
xmpDirs.map { it.xmpMeta.getSafeStructField(dataPropPath) }.filterNotNull().first().let {
|
xmpDirs.mapNotNull { it.xmpMeta.getSafeStructField(dataPropPath) }.first().let {
|
||||||
XMPUtils.decodeBase64(it.value)
|
XMPUtils.decodeBase64(it.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -217,7 +217,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
result.success(resultFields)
|
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 {
|
} else {
|
||||||
|
|
|
@ -38,6 +38,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
|
||||||
"rename" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::rename) }
|
"rename" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::rename) }
|
||||||
"rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) }
|
"rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) }
|
||||||
"flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) }
|
"flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) }
|
||||||
|
"editDate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::editDate) }
|
||||||
"clearSizedThumbnailDiskCache" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::clearSizedThumbnailDiskCache) }
|
"clearSizedThumbnailDiskCache" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::clearSizedThumbnailDiskCache) }
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
|
@ -59,7 +60,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
|
||||||
|
|
||||||
provider.fetchSingle(activity, uri, mimeType, object : ImageOpCallback {
|
provider.fetchSingle(activity, uri, mimeType, object : ImageOpCallback {
|
||||||
override fun onSuccess(fields: FieldMap) = result.success(fields)
|
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)
|
destinationDir = ensureTrailingSeparator(destinationDir)
|
||||||
provider.captureFrame(activity, desiredName, exifFields, bytes, destinationDir, object : ImageOpCallback {
|
provider.captureFrame(activity, desiredName, exifFields, bytes, destinationDir, object : ImageOpCallback {
|
||||||
override fun onSuccess(fields: FieldMap) = result.success(fields)
|
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 {
|
provider.rename(activity, path, uri, mimeType, newName, object : ImageOpCallback {
|
||||||
override fun onSuccess(fields: FieldMap) = result.success(fields)
|
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 uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) }
|
||||||
val path = entryMap["path"] as String?
|
val path = entryMap["path"] as String?
|
||||||
val mimeType = entryMap["mimeType"] as String?
|
val mimeType = entryMap["mimeType"] as String?
|
||||||
val sizeBytes = (entryMap["sizeBytes"] as Number?)?.toLong()
|
if (uri == null || path == null || mimeType == null) {
|
||||||
if (uri == null || path == null || mimeType == null || sizeBytes == null) {
|
|
||||||
result.error("changeOrientation-args", "failed because entry fields are missing", null)
|
result.error("changeOrientation-args", "failed because entry fields are missing", null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -230,9 +230,39 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
provider.changeOrientation(activity, path, uri, mimeType, sizeBytes, op, object : ImageOpCallback {
|
provider.changeOrientation(activity, path, uri, mimeType, op, object : ImageOpCallback {
|
||||||
override fun onSuccess(fields: FieldMap) = result.success(fields)
|
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()}")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -267,7 +267,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
if (metadataMap.isNotEmpty()) {
|
if (metadataMap.isNotEmpty()) {
|
||||||
result.success(metadataMap)
|
result.success(metadataMap)
|
||||||
} else {
|
} 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) {
|
} 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) {
|
} 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) {
|
} catch (e: Exception) {
|
||||||
// ExifInterface initialization can fail with a RuntimeException
|
// ExifInterface initialization can fail with a RuntimeException
|
||||||
// caused by an internal MediaMetadataRetriever failure
|
// 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) {
|
} 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) {
|
} 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) {
|
} catch (e: Exception) {
|
||||||
// ExifInterface initialization can fail with a RuntimeException
|
// ExifInterface initialization can fail with a RuntimeException
|
||||||
// caused by an internal MediaMetadataRetriever failure
|
// 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
|
else -> null
|
||||||
}
|
}
|
||||||
if (pages?.isEmpty() == true) {
|
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 {
|
} else {
|
||||||
result.success(pages)
|
result.success(pages)
|
||||||
}
|
}
|
||||||
|
@ -680,7 +680,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
Log.w(LOG_TAG, "failed to read XMP", e)
|
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) {
|
private fun hasContentResolverProp(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
|
|
@ -19,6 +19,8 @@ import kotlin.math.roundToLong
|
||||||
object ExifInterfaceHelper {
|
object ExifInterfaceHelper {
|
||||||
private val LOG_TAG = LogUtils.createTag<ExifInterfaceHelper>()
|
private val LOG_TAG = LogUtils.createTag<ExifInterfaceHelper>()
|
||||||
val DATETIME_FORMAT = SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.ROOT)
|
val DATETIME_FORMAT = SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.ROOT)
|
||||||
|
val GPS_DATE_FORMAT = SimpleDateFormat("yyyy:MM:dd", Locale.ROOT)
|
||||||
|
val GPS_TIME_FORMAT = SimpleDateFormat("HH:mm:ss", Locale.ROOT)
|
||||||
|
|
||||||
private const val precisionErrorTolerance = 1e-10
|
private const val precisionErrorTolerance = 1e-10
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
|
@ -18,21 +18,24 @@ import com.commonsware.cwac.document.DocumentFileCompat
|
||||||
import deckers.thibault.aves.decoder.MultiTrackImage
|
import deckers.thibault.aves.decoder.MultiTrackImage
|
||||||
import deckers.thibault.aves.decoder.TiffImage
|
import deckers.thibault.aves.decoder.TiffImage
|
||||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper
|
import deckers.thibault.aves.metadata.ExifInterfaceHelper
|
||||||
|
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
|
||||||
import deckers.thibault.aves.metadata.MultiPage
|
import deckers.thibault.aves.metadata.MultiPage
|
||||||
|
import deckers.thibault.aves.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.AvesEntry
|
||||||
import deckers.thibault.aves.model.ExifOrientationOp
|
import deckers.thibault.aves.model.ExifOrientationOp
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.utils.*
|
import deckers.thibault.aves.utils.*
|
||||||
import deckers.thibault.aves.utils.MimeTypes.extensionFor
|
import deckers.thibault.aves.utils.MimeTypes.extensionFor
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isImage
|
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.MimeTypes.isVideo
|
||||||
import deckers.thibault.aves.utils.StorageUtils.createDirectoryIfAbsent
|
import deckers.thibault.aves.utils.StorageUtils.createDirectoryIfAbsent
|
||||||
import deckers.thibault.aves.utils.StorageUtils.getDocumentFile
|
import deckers.thibault.aves.utils.StorageUtils.getDocumentFile
|
||||||
import deckers.thibault.aves.utils.UriUtils.tryParseId
|
import deckers.thibault.aves.utils.UriUtils.tryParseId
|
||||||
import java.io.ByteArrayInputStream
|
import java.io.*
|
||||||
import java.io.File
|
|
||||||
import java.io.FileNotFoundException
|
|
||||||
import java.io.IOException
|
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
import kotlin.coroutines.resumeWithException
|
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)) {
|
if (!canEditExif(mimeType)) {
|
||||||
callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType"))
|
callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType"))
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
val originalDocumentFile = getDocumentFile(context, path, uri)
|
val originalDocumentFile = getDocumentFile(context, path, uri)
|
||||||
if (originalDocumentFile == null) {
|
if (originalDocumentFile == null) {
|
||||||
callback.onFailure(Exception("failed to get document file for path=$path, uri=$uri"))
|
callback.onFailure(Exception("failed to get document file for path=$path, uri=$uri"))
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
val videoSizeBytes = MultiPage.getMotionPhotoOffset(context, uri, mimeType, sizeBytes)?.toInt()
|
val originalFileSize = File(path).length()
|
||||||
|
val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff }
|
||||||
var videoBytes: ByteArray? = null
|
var videoBytes: ByteArray? = null
|
||||||
val editableFile = File.createTempFile("aves", null).apply {
|
val editableFile = File.createTempFile("aves", null).apply {
|
||||||
deleteOnExit()
|
deleteOnExit()
|
||||||
try {
|
try {
|
||||||
outputStream().use { output ->
|
outputStream().use { output ->
|
||||||
if (videoSizeBytes != null) {
|
if (videoSize != null) {
|
||||||
// handle motion photo and embedded video separately
|
// handle motion photo and embedded video separately
|
||||||
val imageSizeBytes = (sizeBytes - videoSizeBytes).toInt()
|
val imageSize = (originalFileSize - videoSize).toInt()
|
||||||
videoBytes = ByteArray(videoSizeBytes)
|
videoBytes = ByteArray(videoSize)
|
||||||
|
|
||||||
StorageUtils.openInputStream(context, uri)?.let { input ->
|
StorageUtils.openInputStream(context, uri)?.let { input ->
|
||||||
val imageBytes = ByteArray(imageSizeBytes)
|
val imageBytes = ByteArray(imageSize)
|
||||||
input.read(imageBytes, 0, imageSizeBytes)
|
input.read(imageBytes, 0, imageSize)
|
||||||
input.read(videoBytes, 0, videoSizeBytes)
|
input.read(videoBytes, 0, videoSize)
|
||||||
|
|
||||||
// copy only the image to a temporary file for editing
|
// 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 ->
|
ByteArrayInputStream(imageBytes).use { imageInput ->
|
||||||
imageInput.copyTo(output)
|
imageInput.copyTo(output)
|
||||||
}
|
}
|
||||||
|
@ -372,13 +401,154 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
callback.onFailure(e)
|
callback.onFailure(e)
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val newFields = HashMap<String, Any?>()
|
|
||||||
try {
|
try {
|
||||||
val exif = ExifInterface(editableFile)
|
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)`
|
// when the orientation is not defined, it returns `undefined (0)` instead of the orientation default value `normal (1)`
|
||||||
// in that case we explicitly set it to `normal` first
|
// in that case we explicitly set it to `normal` first
|
||||||
// because ExifInterface fails to rotate an image with undefined orientation
|
// because ExifInterface fails to rotate an image with undefined orientation
|
||||||
|
@ -393,43 +563,106 @@ abstract class ImageProvider {
|
||||||
ExifOrientationOp.FLIP -> exif.flipHorizontally()
|
ExifOrientationOp.FLIP -> exif.flipHorizontally()
|
||||||
}
|
}
|
||||||
exif.saveAttributes()
|
exif.saveAttributes()
|
||||||
|
|
||||||
if (videoBytes != null) {
|
|
||||||
// append motion photo video, if any
|
|
||||||
editableFile.appendBytes(videoBytes!!)
|
|
||||||
}
|
|
||||||
// copy the edited temporary file back to the original
|
|
||||||
DocumentFileCompat.fromFile(editableFile).copyTo(originalDocumentFile)
|
|
||||||
|
|
||||||
newFields["rotationDegrees"] = exif.rotationDegrees
|
newFields["rotationDegrees"] = exif.rotationDegrees
|
||||||
newFields["isFlipped"] = exif.isFlipped
|
newFields["isFlipped"] = exif.isFlipped
|
||||||
} catch (e: IOException) {
|
|
||||||
callback.onFailure(e)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, _ ->
|
if (success) {
|
||||||
val projection = arrayOf(MediaStore.MediaColumns.DATE_MODIFIED)
|
scanPostExifEdit(context, path, uri, mimeType, newFields, callback)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// support for writing EXIF
|
fun editDate(
|
||||||
// as of androidx.exifinterface:exifinterface:1.3.0
|
context: Context,
|
||||||
private fun canEditExif(mimeType: String): Boolean {
|
path: String,
|
||||||
return when (mimeType) {
|
uri: Uri,
|
||||||
MimeTypes.JPEG, MimeTypes.PNG, MimeTypes.WEBP -> true
|
mimeType: String,
|
||||||
else -> false
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -23,7 +23,7 @@ object MimeTypes {
|
||||||
// raw raster
|
// raw raster
|
||||||
private const val ARW = "image/x-sony-arw"
|
private const val ARW = "image/x-sony-arw"
|
||||||
private const val CR2 = "image/x-canon-cr2"
|
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 NEF = "image/x-nikon-nef"
|
||||||
private const val NRW = "image/x-nikon-nrw"
|
private const val NRW = "image/x-nikon-nrw"
|
||||||
private const val ORF = "image/x-olympus-orf"
|
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)
|
// 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 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
|
// Glide automatically applies EXIF orientation when decoding images of known formats
|
||||||
// but we need to rotate the decoded bitmap for the other formats
|
// but we need to rotate the decoded bitmap for the other formats
|
||||||
// maybe related to ExifInterface version used by Glide:
|
// maybe related to ExifInterface version used by Glide:
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
buildscript {
|
buildscript {
|
||||||
ext.kotlin_version = '1.5.21'
|
ext.kotlin_version = '1.5.30'
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
dependencies {
|
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 "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||||
classpath 'com.google.gms:google-services:4.3.10'
|
classpath 'com.google.gms:google-services:4.3.10'
|
||||||
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.7.1'
|
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.7.1'
|
||||||
|
|
|
@ -18,12 +18,15 @@
|
||||||
"@applyButtonLabel": {},
|
"@applyButtonLabel": {},
|
||||||
"deleteButtonLabel": "DELETE",
|
"deleteButtonLabel": "DELETE",
|
||||||
"@deleteButtonLabel": {},
|
"@deleteButtonLabel": {},
|
||||||
|
"nextButtonLabel": "NEXT",
|
||||||
|
"@nextButtonLabel": {},
|
||||||
"showButtonLabel": "SHOW",
|
"showButtonLabel": "SHOW",
|
||||||
"@showButtonLabel": {},
|
"@showButtonLabel": {},
|
||||||
"hideButtonLabel": "HIDE",
|
"hideButtonLabel": "HIDE",
|
||||||
"@hideButtonLabel": {},
|
"@hideButtonLabel": {},
|
||||||
"continueButtonLabel": "CONTINUE",
|
"continueButtonLabel": "CONTINUE",
|
||||||
"@continueButtonLabel": {},
|
"@continueButtonLabel": {},
|
||||||
|
|
||||||
"changeTooltip": "Change",
|
"changeTooltip": "Change",
|
||||||
"@changeTooltip": {},
|
"@changeTooltip": {},
|
||||||
"clearTooltip": "Clear",
|
"clearTooltip": "Clear",
|
||||||
|
@ -126,6 +129,9 @@
|
||||||
"videoActionSettings": "Settings",
|
"videoActionSettings": "Settings",
|
||||||
"@videoActionSettings": {},
|
"@videoActionSettings": {},
|
||||||
|
|
||||||
|
"entryInfoActionEditDate": "Edit date & time",
|
||||||
|
"@entryInfoActionEditDate": {},
|
||||||
|
|
||||||
"filterFavouriteLabel": "Favourite",
|
"filterFavouriteLabel": "Favourite",
|
||||||
"@filterFavouriteLabel": {},
|
"@filterFavouriteLabel": {},
|
||||||
"filterLocationEmptyLabel": "Unlocated",
|
"filterLocationEmptyLabel": "Unlocated",
|
||||||
|
@ -304,6 +310,21 @@
|
||||||
"renameEntryDialogLabel": "New name",
|
"renameEntryDialogLabel": "New name",
|
||||||
"@renameEntryDialogLabel": {},
|
"@renameEntryDialogLabel": {},
|
||||||
|
|
||||||
|
"editEntryDateDialogTitle": "Date & Time",
|
||||||
|
"@editEntryDateDialogTitle": {},
|
||||||
|
"editEntryDateDialogSet": "Set",
|
||||||
|
"@editEntryDateDialogSet": {},
|
||||||
|
"editEntryDateDialogShift": "Shift",
|
||||||
|
"@editEntryDateDialogShift": {},
|
||||||
|
"editEntryDateDialogClear": "Clear",
|
||||||
|
"@editEntryDateDialogClear": {},
|
||||||
|
"editEntryDateDialogFieldSelection": "Field selection",
|
||||||
|
"@editEntryDateDialogFieldSelection": {},
|
||||||
|
"editEntryDateDialogHours": "Hours",
|
||||||
|
"@editEntryDateDialogHours": {},
|
||||||
|
"editEntryDateDialogMinutes": "Minutes",
|
||||||
|
"@editEntryDateDialogMinutes": {},
|
||||||
|
|
||||||
"videoSpeedDialogLabel": "Playback speed",
|
"videoSpeedDialogLabel": "Playback speed",
|
||||||
"@videoSpeedDialogLabel": {},
|
"@videoSpeedDialogLabel": {},
|
||||||
|
|
||||||
|
@ -352,8 +373,8 @@
|
||||||
"@aboutUpdateLinks2": {},
|
"@aboutUpdateLinks2": {},
|
||||||
"aboutUpdateLinks3": ".",
|
"aboutUpdateLinks3": ".",
|
||||||
"@aboutUpdateLinks3": {},
|
"@aboutUpdateLinks3": {},
|
||||||
"aboutUpdateGithub": "Github",
|
"aboutUpdateGitHub": "GitHub",
|
||||||
"@aboutUpdateGithub": {},
|
"@aboutUpdateGitHub": {},
|
||||||
"aboutUpdateGooglePlay": "Google Play",
|
"aboutUpdateGooglePlay": "Google Play",
|
||||||
"@aboutUpdateGooglePlay": {},
|
"@aboutUpdateGooglePlay": {},
|
||||||
"aboutCredits": "Credits",
|
"aboutCredits": "Credits",
|
||||||
|
@ -425,14 +446,6 @@
|
||||||
"@dateYesterday": {},
|
"@dateYesterday": {},
|
||||||
"dateThisMonth": "This month",
|
"dateThisMonth": "This month",
|
||||||
"@dateThisMonth": {},
|
"@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": "{count, plural, =1{Failed to delete 1 item} other{Failed to delete {count} items}}",
|
||||||
"@collectionDeleteFailureFeedback": {
|
"@collectionDeleteFailureFeedback": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
@ -614,6 +627,13 @@
|
||||||
"settingsThumbnailShowVideoDuration": "Show video duration",
|
"settingsThumbnailShowVideoDuration": "Show video duration",
|
||||||
"@settingsThumbnailShowVideoDuration": {},
|
"@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": "Viewer",
|
||||||
"@settingsSectionViewer": {},
|
"@settingsSectionViewer": {},
|
||||||
"settingsImageBackground": "Image background",
|
"settingsImageBackground": "Image background",
|
||||||
|
@ -658,9 +678,9 @@
|
||||||
"@settingsVideoLoopModeTile": {},
|
"@settingsVideoLoopModeTile": {},
|
||||||
"settingsVideoLoopModeTitle": "Loop Mode",
|
"settingsVideoLoopModeTitle": "Loop Mode",
|
||||||
"@settingsVideoLoopModeTitle": {},
|
"@settingsVideoLoopModeTitle": {},
|
||||||
"settingsVideoQuickActionsTile": "Quick video actions",
|
"settingsVideoQuickActionsTile": "Quick actions for videos",
|
||||||
"@settingsVideoQuickActionsTile": {},
|
"@settingsVideoQuickActionsTile": {},
|
||||||
"settingsVideoQuickActionEditorTitle": "Quick Video Actions",
|
"settingsVideoQuickActionEditorTitle": "Quick Actions",
|
||||||
"@settingsVideoQuickActionEditorTitle": {},
|
"@settingsVideoQuickActionEditorTitle": {},
|
||||||
|
|
||||||
"settingsSubtitleThemeTile": "Subtitles",
|
"settingsSubtitleThemeTile": "Subtitles",
|
||||||
|
|
|
@ -7,9 +7,11 @@
|
||||||
|
|
||||||
"applyButtonLabel": "확인",
|
"applyButtonLabel": "확인",
|
||||||
"deleteButtonLabel": "삭제",
|
"deleteButtonLabel": "삭제",
|
||||||
|
"nextButtonLabel": "다음",
|
||||||
"showButtonLabel": "보기",
|
"showButtonLabel": "보기",
|
||||||
"hideButtonLabel": "숨기기",
|
"hideButtonLabel": "숨기기",
|
||||||
"continueButtonLabel": "다음",
|
"continueButtonLabel": "다음",
|
||||||
|
|
||||||
"changeTooltip": "변경",
|
"changeTooltip": "변경",
|
||||||
"clearTooltip": "초기화",
|
"clearTooltip": "초기화",
|
||||||
"previousTooltip": "이전",
|
"previousTooltip": "이전",
|
||||||
|
@ -64,6 +66,8 @@
|
||||||
"videoActionSetSpeed": "재생 배속",
|
"videoActionSetSpeed": "재생 배속",
|
||||||
"videoActionSettings": "설정",
|
"videoActionSettings": "설정",
|
||||||
|
|
||||||
|
"entryInfoActionEditDate": "날짜와 시간 수정",
|
||||||
|
|
||||||
"filterFavouriteLabel": "즐겨찾기",
|
"filterFavouriteLabel": "즐겨찾기",
|
||||||
"filterLocationEmptyLabel": "장소 없음",
|
"filterLocationEmptyLabel": "장소 없음",
|
||||||
"filterTagEmptyLabel": "태그 없음",
|
"filterTagEmptyLabel": "태그 없음",
|
||||||
|
@ -137,6 +141,14 @@
|
||||||
|
|
||||||
"renameEntryDialogLabel": "이름",
|
"renameEntryDialogLabel": "이름",
|
||||||
|
|
||||||
|
"editEntryDateDialogTitle": "날짜 및 시간",
|
||||||
|
"editEntryDateDialogSet": "설정",
|
||||||
|
"editEntryDateDialogShift": "앞뒤로",
|
||||||
|
"editEntryDateDialogClear": "삭제",
|
||||||
|
"editEntryDateDialogFieldSelection": "필드 선택",
|
||||||
|
"editEntryDateDialogHours": "시간",
|
||||||
|
"editEntryDateDialogMinutes": "분",
|
||||||
|
|
||||||
"videoSpeedDialogLabel": "재생 배속",
|
"videoSpeedDialogLabel": "재생 배속",
|
||||||
|
|
||||||
"videoStreamSelectionDialogVideo": "동영상",
|
"videoStreamSelectionDialogVideo": "동영상",
|
||||||
|
@ -163,7 +175,7 @@
|
||||||
"aboutUpdateLinks1": "앱의 최신 버전을",
|
"aboutUpdateLinks1": "앱의 최신 버전을",
|
||||||
"aboutUpdateLinks2": "와",
|
"aboutUpdateLinks2": "와",
|
||||||
"aboutUpdateLinks3": "에서 다운로드 사용 가능합니다.",
|
"aboutUpdateLinks3": "에서 다운로드 사용 가능합니다.",
|
||||||
"aboutUpdateGithub": "깃허브",
|
"aboutUpdateGitHub": "깃허브",
|
||||||
"aboutUpdateGooglePlay": "구글 플레이",
|
"aboutUpdateGooglePlay": "구글 플레이",
|
||||||
"aboutCredits": "크레딧",
|
"aboutCredits": "크레딧",
|
||||||
"aboutCreditsWorldAtlas1": "이 앱은",
|
"aboutCreditsWorldAtlas1": "이 앱은",
|
||||||
|
@ -200,7 +212,6 @@
|
||||||
"dateToday": "오늘",
|
"dateToday": "오늘",
|
||||||
"dateYesterday": "어제",
|
"dateYesterday": "어제",
|
||||||
"dateThisMonth": "이번 달",
|
"dateThisMonth": "이번 달",
|
||||||
"errorUnsupportedMimeType": "{mimeType} 지원되지 않음",
|
|
||||||
"collectionDeleteFailureFeedback": "{count, plural, other{항목 {count}개를 삭제하지 못했습니다}}",
|
"collectionDeleteFailureFeedback": "{count, plural, other{항목 {count}개를 삭제하지 못했습니다}}",
|
||||||
"collectionCopyFailureFeedback": "{count, plural, other{항목 {count}개를 복사하지 못했습니다}}",
|
"collectionCopyFailureFeedback": "{count, plural, other{항목 {count}개를 복사하지 못했습니다}}",
|
||||||
"collectionMoveFailureFeedback": "{count, plural, other{항목 {count}개를 이동하지 못했습니다}}",
|
"collectionMoveFailureFeedback": "{count, plural, other{항목 {count}개를 이동하지 못했습니다}}",
|
||||||
|
@ -288,6 +299,10 @@
|
||||||
"settingsThumbnailShowRawIcon": "Raw 아이콘 표시",
|
"settingsThumbnailShowRawIcon": "Raw 아이콘 표시",
|
||||||
"settingsThumbnailShowVideoDuration": "동영상 길이 표시",
|
"settingsThumbnailShowVideoDuration": "동영상 길이 표시",
|
||||||
|
|
||||||
|
"settingsCollectionSelectionQuickActionsTile": "항목 선택의 빠른 작업",
|
||||||
|
"settingsCollectionSelectionQuickActionEditorTitle": "빠른 작업",
|
||||||
|
"settingsCollectionSelectionQuickActionEditorBanner": "버튼을 길게 누른 후 이동하여 항목 선택할 때 표시될 버튼을 선택하세요.",
|
||||||
|
|
||||||
"settingsSectionViewer": "뷰어",
|
"settingsSectionViewer": "뷰어",
|
||||||
"settingsImageBackground": "사진 배경",
|
"settingsImageBackground": "사진 배경",
|
||||||
"settingsViewerShowMinimap": "미니맵 표시",
|
"settingsViewerShowMinimap": "미니맵 표시",
|
||||||
|
@ -311,8 +326,8 @@
|
||||||
"settingsVideoEnableAutoPlay": "자동 재생",
|
"settingsVideoEnableAutoPlay": "자동 재생",
|
||||||
"settingsVideoLoopModeTile": "반복 모드",
|
"settingsVideoLoopModeTile": "반복 모드",
|
||||||
"settingsVideoLoopModeTitle": "반복 모드",
|
"settingsVideoLoopModeTitle": "반복 모드",
|
||||||
"settingsVideoQuickActionsTile": "빠른 동영상 작업",
|
"settingsVideoQuickActionsTile": "동영상의 빠른 작업",
|
||||||
"settingsVideoQuickActionEditorTitle": "빠른 동영상 작업",
|
"settingsVideoQuickActionEditorTitle": "빠른 작업",
|
||||||
|
|
||||||
"settingsSubtitleThemeTile": "자막",
|
"settingsSubtitleThemeTile": "자막",
|
||||||
"settingsSubtitleThemeTitle": "자막",
|
"settingsSubtitleThemeTitle": "자막",
|
||||||
|
|
|
@ -32,11 +32,6 @@ enum EntryAction {
|
||||||
}
|
}
|
||||||
|
|
||||||
class EntryActions {
|
class EntryActions {
|
||||||
static const selection = [
|
|
||||||
EntryAction.share,
|
|
||||||
EntryAction.delete,
|
|
||||||
];
|
|
||||||
|
|
||||||
static const inApp = [
|
static const inApp = [
|
||||||
EntryAction.info,
|
EntryAction.info,
|
||||||
EntryAction.toggleFavourite,
|
EntryAction.toggleFavourite,
|
||||||
|
|
3
lib/model/actions/entry_info_actions.dart
Normal file
3
lib/model/actions/entry_info_actions.dart
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
enum EntryInfoAction {
|
||||||
|
editDate,
|
||||||
|
}
|
|
@ -15,11 +15,25 @@ enum EntrySetAction {
|
||||||
map,
|
map,
|
||||||
stats,
|
stats,
|
||||||
// entry selection
|
// entry selection
|
||||||
|
share,
|
||||||
|
delete,
|
||||||
copy,
|
copy,
|
||||||
move,
|
move,
|
||||||
refreshMetadata,
|
refreshMetadata,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class EntrySetActions {
|
||||||
|
static const selection = [
|
||||||
|
EntrySetAction.share,
|
||||||
|
EntrySetAction.delete,
|
||||||
|
EntrySetAction.copy,
|
||||||
|
EntrySetAction.move,
|
||||||
|
EntrySetAction.refreshMetadata,
|
||||||
|
EntrySetAction.map,
|
||||||
|
EntrySetAction.stats,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
extension ExtraEntrySetAction on EntrySetAction {
|
extension ExtraEntrySetAction on EntrySetAction {
|
||||||
String getText(BuildContext context) {
|
String getText(BuildContext context) {
|
||||||
switch (this) {
|
switch (this) {
|
||||||
|
@ -43,6 +57,10 @@ extension ExtraEntrySetAction on EntrySetAction {
|
||||||
case EntrySetAction.stats:
|
case EntrySetAction.stats:
|
||||||
return context.l10n.menuActionStats;
|
return context.l10n.menuActionStats;
|
||||||
// entry selection
|
// entry selection
|
||||||
|
case EntrySetAction.share:
|
||||||
|
return context.l10n.entryActionShare;
|
||||||
|
case EntrySetAction.delete:
|
||||||
|
return context.l10n.entryActionDelete;
|
||||||
case EntrySetAction.copy:
|
case EntrySetAction.copy:
|
||||||
return context.l10n.collectionActionCopy;
|
return context.l10n.collectionActionCopy;
|
||||||
case EntrySetAction.move:
|
case EntrySetAction.move:
|
||||||
|
@ -78,6 +96,10 @@ extension ExtraEntrySetAction on EntrySetAction {
|
||||||
case EntrySetAction.stats:
|
case EntrySetAction.stats:
|
||||||
return AIcons.stats;
|
return AIcons.stats;
|
||||||
// entry selection
|
// entry selection
|
||||||
|
case EntrySetAction.share:
|
||||||
|
return AIcons.share;
|
||||||
|
case EntrySetAction.delete:
|
||||||
|
return AIcons.delete;
|
||||||
case EntrySetAction.copy:
|
case EntrySetAction.copy:
|
||||||
return AIcons.copy;
|
return AIcons.copy;
|
||||||
case EntrySetAction.move:
|
case EntrySetAction.move:
|
||||||
|
|
|
@ -3,7 +3,9 @@ import 'dart:async';
|
||||||
import 'package:aves/geo/countries.dart';
|
import 'package:aves/geo/countries.dart';
|
||||||
import 'package:aves/model/entry_cache.dart';
|
import 'package:aves/model/entry_cache.dart';
|
||||||
import 'package:aves/model/favourites.dart';
|
import 'package:aves/model/favourites.dart';
|
||||||
import 'package:aves/model/metadata.dart';
|
import 'package:aves/model/metadata/address.dart';
|
||||||
|
import 'package:aves/model/metadata/catalog.dart';
|
||||||
|
import 'package:aves/model/metadata/date_modifier.dart';
|
||||||
import 'package:aves/model/multipage.dart';
|
import 'package:aves/model/multipage.dart';
|
||||||
import 'package:aves/model/settings/settings.dart';
|
import 'package:aves/model/settings/settings.dart';
|
||||||
import 'package:aves/model/video/metadata.dart';
|
import 'package:aves/model/video/metadata.dart';
|
||||||
|
@ -12,8 +14,8 @@ import 'package:aves/services/geocoding_service.dart';
|
||||||
import 'package:aves/services/service_policy.dart';
|
import 'package:aves/services/service_policy.dart';
|
||||||
import 'package:aves/services/services.dart';
|
import 'package:aves/services/services.dart';
|
||||||
import 'package:aves/services/svg_metadata_service.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/change_notifier.dart';
|
||||||
import 'package:aves/utils/time_utils.dart';
|
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:country_code/country_code.dart';
|
import 'package:country_code/country_code.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
@ -28,7 +30,7 @@ class AvesEntry {
|
||||||
int width;
|
int width;
|
||||||
int height;
|
int height;
|
||||||
int sourceRotationDegrees;
|
int sourceRotationDegrees;
|
||||||
final int? sizeBytes;
|
int? sizeBytes;
|
||||||
String? _sourceTitle;
|
String? _sourceTitle;
|
||||||
|
|
||||||
// `dateModifiedSecs` can be missing in viewer mode
|
// `dateModifiedSecs` can be missing in viewer mode
|
||||||
|
@ -413,8 +415,8 @@ class AvesEntry {
|
||||||
addressDetails = null;
|
addressDetails = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> catalog({bool background = false, bool persist = true}) async {
|
Future<void> catalog({bool background = false, bool persist = true, bool force = false}) async {
|
||||||
if (isCatalogued) return;
|
if (isCatalogued && !force) return;
|
||||||
if (isSvg) {
|
if (isSvg) {
|
||||||
// vector image sizing is not essential, so we should not spend time for it during loading
|
// vector image sizing is not essential, so we should not spend time for it during loading
|
||||||
// but it is useful anyway (for aspect ratios etc.) so we size them during cataloguing
|
// but it is useful anyway (for aspect ratios etc.) so we size them during cataloguing
|
||||||
|
@ -429,10 +431,14 @@ class AvesEntry {
|
||||||
} else {
|
} else {
|
||||||
if (isVideo && (!isSized || durationMillis == 0)) {
|
if (isVideo && (!isSized || durationMillis == 0)) {
|
||||||
// exotic video that is not sized during loading
|
// 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);
|
await _applyNewFields(fields, persist: persist);
|
||||||
}
|
}
|
||||||
catalogMetadata = await metadataService.getCatalogMetadata(this, background: background);
|
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'];
|
final durationMillis = newFields['durationMillis'];
|
||||||
if (durationMillis is int) this.durationMillis = durationMillis;
|
if (durationMillis is int) this.durationMillis = durationMillis;
|
||||||
|
|
||||||
|
final sizeBytes = newFields['sizeBytes'];
|
||||||
|
if (sizeBytes is int) this.sizeBytes = sizeBytes;
|
||||||
final dateModifiedSecs = newFields['dateModifiedSecs'];
|
final dateModifiedSecs = newFields['dateModifiedSecs'];
|
||||||
if (dateModifiedSecs is int) this.dateModifiedSecs = dateModifiedSecs;
|
if (dateModifiedSecs is int) this.dateModifiedSecs = dateModifiedSecs;
|
||||||
final rotationDegrees = newFields['rotationDegrees'];
|
final rotationDegrees = newFields['rotationDegrees'];
|
||||||
|
@ -594,6 +602,15 @@ class AvesEntry {
|
||||||
return true;
|
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() {
|
Future<bool> delete() {
|
||||||
final completer = Completer<bool>();
|
final completer = Completer<bool>();
|
||||||
imageFileService.delete([this]).listen(
|
imageFileService.delete([this]).listen(
|
||||||
|
|
51
lib/model/metadata/address.dart
Normal file
51
lib/model/metadata/address.dart
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
@immutable
|
||||||
|
class AddressDetails {
|
||||||
|
final int? contentId;
|
||||||
|
final String? countryCode, countryName, adminArea, locality;
|
||||||
|
|
||||||
|
String? get place => locality != null && locality!.isNotEmpty ? locality : adminArea;
|
||||||
|
|
||||||
|
const AddressDetails({
|
||||||
|
this.contentId,
|
||||||
|
this.countryCode,
|
||||||
|
this.countryName,
|
||||||
|
this.adminArea,
|
||||||
|
this.locality,
|
||||||
|
});
|
||||||
|
|
||||||
|
AddressDetails copyWith({
|
||||||
|
int? contentId,
|
||||||
|
}) {
|
||||||
|
return AddressDetails(
|
||||||
|
contentId: contentId ?? this.contentId,
|
||||||
|
countryCode: countryCode,
|
||||||
|
countryName: countryName,
|
||||||
|
adminArea: adminArea,
|
||||||
|
locality: locality,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory AddressDetails.fromMap(Map map) {
|
||||||
|
return AddressDetails(
|
||||||
|
contentId: map['contentId'] as int?,
|
||||||
|
countryCode: map['countryCode'] as String?,
|
||||||
|
countryName: map['countryName'] as String?,
|
||||||
|
adminArea: map['adminArea'] as String?,
|
||||||
|
locality: map['locality'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() => {
|
||||||
|
'contentId': contentId,
|
||||||
|
'countryCode': countryCode,
|
||||||
|
'countryName': countryName,
|
||||||
|
'adminArea': adminArea,
|
||||||
|
'locality': locality,
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, countryCode=$countryCode, countryName=$countryName, adminArea=$adminArea, locality=$locality}';
|
||||||
|
}
|
|
@ -1,31 +1,5 @@
|
||||||
import 'package:aves/services/geocoding_service.dart';
|
import 'package:aves/services/geocoding_service.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
|
|
||||||
class 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 {
|
class CatalogMetadata {
|
||||||
final int? contentId, dateMillis;
|
final int? contentId, dateMillis;
|
||||||
|
@ -75,13 +49,14 @@ class CatalogMetadata {
|
||||||
CatalogMetadata copyWith({
|
CatalogMetadata copyWith({
|
||||||
int? contentId,
|
int? contentId,
|
||||||
String? mimeType,
|
String? mimeType,
|
||||||
|
int? dateMillis,
|
||||||
bool? isMultiPage,
|
bool? isMultiPage,
|
||||||
int? rotationDegrees,
|
int? rotationDegrees,
|
||||||
}) {
|
}) {
|
||||||
return CatalogMetadata(
|
return CatalogMetadata(
|
||||||
contentId: contentId ?? this.contentId,
|
contentId: contentId ?? this.contentId,
|
||||||
mimeType: mimeType ?? this.mimeType,
|
mimeType: mimeType ?? this.mimeType,
|
||||||
dateMillis: dateMillis,
|
dateMillis: dateMillis ?? this.dateMillis,
|
||||||
isAnimated: isAnimated,
|
isAnimated: isAnimated,
|
||||||
isFlipped: isFlipped,
|
isFlipped: isFlipped,
|
||||||
isGeotiff: isGeotiff,
|
isGeotiff: isGeotiff,
|
||||||
|
@ -130,82 +105,3 @@ class CatalogMetadata {
|
||||||
@override
|
@override
|
||||||
String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, is360=$is360, isMultiPage=$isMultiPage, rotationDegrees=$rotationDegrees, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}';
|
String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, is360=$is360, isMultiPage=$isMultiPage, rotationDegrees=$rotationDegrees, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}';
|
||||||
}
|
}
|
||||||
|
|
||||||
class OverlayMetadata {
|
|
||||||
final String? aperture, exposureTime, focalLength, iso;
|
|
||||||
|
|
||||||
static final apertureFormat = NumberFormat('0.0', 'en_US');
|
|
||||||
static final focalLengthFormat = NumberFormat('0.#', 'en_US');
|
|
||||||
|
|
||||||
OverlayMetadata({
|
|
||||||
double? aperture,
|
|
||||||
this.exposureTime,
|
|
||||||
double? focalLength,
|
|
||||||
int? iso,
|
|
||||||
}) : aperture = aperture != null ? 'ƒ/${apertureFormat.format(aperture)}' : null,
|
|
||||||
focalLength = focalLength != null ? '${focalLengthFormat.format(focalLength)} mm' : null,
|
|
||||||
iso = iso != null ? 'ISO$iso' : null;
|
|
||||||
|
|
||||||
factory OverlayMetadata.fromMap(Map map) {
|
|
||||||
return OverlayMetadata(
|
|
||||||
aperture: map['aperture'] as double?,
|
|
||||||
exposureTime: map['exposureTime'] as String?,
|
|
||||||
focalLength: map['focalLength'] as double?,
|
|
||||||
iso: map['iso'] as int?,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool get isEmpty => aperture == null && exposureTime == null && focalLength == null && iso == null;
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() => '$runtimeType#${shortHash(this)}{aperture=$aperture, exposureTime=$exposureTime, focalLength=$focalLength, iso=$iso}';
|
|
||||||
}
|
|
||||||
|
|
||||||
@immutable
|
|
||||||
class AddressDetails {
|
|
||||||
final int? contentId;
|
|
||||||
final String? countryCode, countryName, adminArea, locality;
|
|
||||||
|
|
||||||
String? get place => locality != null && locality!.isNotEmpty ? locality : adminArea;
|
|
||||||
|
|
||||||
const AddressDetails({
|
|
||||||
this.contentId,
|
|
||||||
this.countryCode,
|
|
||||||
this.countryName,
|
|
||||||
this.adminArea,
|
|
||||||
this.locality,
|
|
||||||
});
|
|
||||||
|
|
||||||
AddressDetails copyWith({
|
|
||||||
int? contentId,
|
|
||||||
}) {
|
|
||||||
return AddressDetails(
|
|
||||||
contentId: contentId ?? this.contentId,
|
|
||||||
countryCode: countryCode,
|
|
||||||
countryName: countryName,
|
|
||||||
adminArea: adminArea,
|
|
||||||
locality: locality,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
factory AddressDetails.fromMap(Map map) {
|
|
||||||
return AddressDetails(
|
|
||||||
contentId: map['contentId'] as int?,
|
|
||||||
countryCode: map['countryCode'] as String?,
|
|
||||||
countryName: map['countryName'] as String?,
|
|
||||||
adminArea: map['adminArea'] as String?,
|
|
||||||
locality: map['locality'] as String?,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, dynamic> toMap() => {
|
|
||||||
'contentId': contentId,
|
|
||||||
'countryCode': countryCode,
|
|
||||||
'countryName': countryName,
|
|
||||||
'adminArea': adminArea,
|
|
||||||
'locality': locality,
|
|
||||||
};
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, countryCode=$countryCode, countryName=$countryName, adminArea=$adminArea, locality=$locality}';
|
|
||||||
}
|
|
20
lib/model/metadata/date_modifier.dart
Normal file
20
lib/model/metadata/date_modifier.dart
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import 'package:aves/model/metadata/enums.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
@immutable
|
||||||
|
class DateModifier {
|
||||||
|
static const allDateFields = [
|
||||||
|
MetadataField.exifDate,
|
||||||
|
MetadataField.exifDateOriginal,
|
||||||
|
MetadataField.exifDateDigitized,
|
||||||
|
MetadataField.exifGpsDate,
|
||||||
|
];
|
||||||
|
|
||||||
|
final DateEditAction action;
|
||||||
|
final Set<MetadataField> fields;
|
||||||
|
final DateTime? dateTime;
|
||||||
|
final int? shiftMinutes;
|
||||||
|
|
||||||
|
const DateModifier(this.action, this.fields, {this.dateTime, this.shiftMinutes});
|
||||||
|
}
|
12
lib/model/metadata/enums.dart
Normal file
12
lib/model/metadata/enums.dart
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
enum MetadataField {
|
||||||
|
exifDate,
|
||||||
|
exifDateOriginal,
|
||||||
|
exifDateDigitized,
|
||||||
|
exifGpsDate,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DateEditAction {
|
||||||
|
set,
|
||||||
|
shift,
|
||||||
|
clear,
|
||||||
|
}
|
32
lib/model/metadata/overlay.dart
Normal file
32
lib/model/metadata/overlay.dart
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
class OverlayMetadata {
|
||||||
|
final String? aperture, exposureTime, focalLength, iso;
|
||||||
|
|
||||||
|
static final apertureFormat = NumberFormat('0.0', 'en_US');
|
||||||
|
static final focalLengthFormat = NumberFormat('0.#', 'en_US');
|
||||||
|
|
||||||
|
OverlayMetadata({
|
||||||
|
double? aperture,
|
||||||
|
this.exposureTime,
|
||||||
|
double? focalLength,
|
||||||
|
int? iso,
|
||||||
|
}) : aperture = aperture != null ? 'ƒ/${apertureFormat.format(aperture)}' : null,
|
||||||
|
focalLength = focalLength != null ? '${focalLengthFormat.format(focalLength)} mm' : null,
|
||||||
|
iso = iso != null ? 'ISO$iso' : null;
|
||||||
|
|
||||||
|
factory OverlayMetadata.fromMap(Map map) {
|
||||||
|
return OverlayMetadata(
|
||||||
|
aperture: map['aperture'] as double?,
|
||||||
|
exposureTime: map['exposureTime'] as String?,
|
||||||
|
focalLength: map['focalLength'] as double?,
|
||||||
|
iso: map['iso'] as int?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get isEmpty => aperture == null && exposureTime == null && focalLength == null && iso == null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => '$runtimeType#${shortHash(this)}{aperture=$aperture, exposureTime=$exposureTime, focalLength=$focalLength, iso=$iso}';
|
||||||
|
}
|
|
@ -4,7 +4,8 @@ import 'package:aves/model/covers.dart';
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/favourites.dart';
|
import 'package:aves/model/favourites.dart';
|
||||||
import 'package:aves/model/filters/filters.dart';
|
import 'package:aves/model/filters/filters.dart';
|
||||||
import 'package:aves/model/metadata.dart';
|
import 'package:aves/model/metadata/address.dart';
|
||||||
|
import 'package:aves/model/metadata/catalog.dart';
|
||||||
import 'package:aves/model/metadata_db_upgrade.dart';
|
import 'package:aves/model/metadata_db_upgrade.dart';
|
||||||
import 'package:aves/services/services.dart';
|
import 'package:aves/services/services.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
|
@ -36,7 +37,7 @@ abstract class MetadataDb {
|
||||||
|
|
||||||
Future<void> clearDates();
|
Future<void> clearDates();
|
||||||
|
|
||||||
Future<List<DateMetadata>> loadDates();
|
Future<Map<int?, int?>> loadDates();
|
||||||
|
|
||||||
// catalog metadata
|
// catalog metadata
|
||||||
|
|
||||||
|
@ -260,12 +261,10 @@ class SqfliteMetadataDb implements MetadataDb {
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<DateMetadata>> loadDates() async {
|
Future<Map<int?, int?>> loadDates() async {
|
||||||
// final stopwatch = Stopwatch()..start();
|
|
||||||
final db = await _database;
|
final db = await _database;
|
||||||
final maps = await db.query(dateTakenTable);
|
final maps = await db.query(dateTakenTable);
|
||||||
final metadataEntries = maps.map((map) => DateMetadata.fromMap(map)).toList();
|
final metadataEntries = Map.fromEntries(maps.map((map) => MapEntry(map['contentId'] as int, (map['dateMillis'] ?? 0) as int)));
|
||||||
// debugPrint('$runtimeType loadDates complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries');
|
|
||||||
return metadataEntries;
|
return metadataEntries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -280,11 +279,9 @@ class SqfliteMetadataDb implements MetadataDb {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<CatalogMetadata>> loadMetadataEntries() async {
|
Future<List<CatalogMetadata>> loadMetadataEntries() async {
|
||||||
// final stopwatch = Stopwatch()..start();
|
|
||||||
final db = await _database;
|
final db = await _database;
|
||||||
final maps = await db.query(metadataTable);
|
final maps = await db.query(metadataTable);
|
||||||
final metadataEntries = maps.map((map) => CatalogMetadata.fromMap(map)).toList();
|
final metadataEntries = maps.map((map) => CatalogMetadata.fromMap(map)).toList();
|
||||||
// debugPrint('$runtimeType loadMetadataEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries');
|
|
||||||
return metadataEntries;
|
return metadataEntries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -318,7 +315,10 @@ class SqfliteMetadataDb implements MetadataDb {
|
||||||
if (metadata.dateMillis != 0) {
|
if (metadata.dateMillis != 0) {
|
||||||
batch.insert(
|
batch.insert(
|
||||||
dateTakenTable,
|
dateTakenTable,
|
||||||
DateMetadata(contentId: metadata.contentId, dateMillis: metadata.dateMillis).toMap(),
|
{
|
||||||
|
'contentId': metadata.contentId,
|
||||||
|
'dateMillis': metadata.dateMillis,
|
||||||
|
},
|
||||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -340,11 +340,9 @@ class SqfliteMetadataDb implements MetadataDb {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<AddressDetails>> loadAddresses() async {
|
Future<List<AddressDetails>> loadAddresses() async {
|
||||||
// final stopwatch = Stopwatch()..start();
|
|
||||||
final db = await _database;
|
final db = await _database;
|
||||||
final maps = await db.query(addressTable);
|
final maps = await db.query(addressTable);
|
||||||
final addresses = maps.map((map) => AddressDetails.fromMap(map)).toList();
|
final addresses = maps.map((map) => AddressDetails.fromMap(map)).toList();
|
||||||
// debugPrint('$runtimeType loadAddresses complete in ${stopwatch.elapsed.inMilliseconds}ms for ${addresses.length} entries');
|
|
||||||
return addresses;
|
return addresses;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
41
lib/model/settings/defaults.dart
Normal file
41
lib/model/settings/defaults.dart
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
|
@ -2,10 +2,10 @@ import 'dart:convert';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:aves/model/actions/entry_actions.dart';
|
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/actions/video_actions.dart';
|
||||||
import 'package:aves/model/filters/favourite.dart';
|
|
||||||
import 'package:aves/model/filters/filters.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/enums.dart';
|
||||||
import 'package:aves/model/settings/map_style.dart';
|
import 'package:aves/model/settings/map_style.dart';
|
||||||
import 'package:aves/model/settings/screen_on.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/device_service.dart';
|
||||||
import 'package:aves/services/services.dart';
|
import 'package:aves/services/services.dart';
|
||||||
import 'package:aves/utils/pedantic.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:collection/collection.dart';
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
@ -59,6 +56,7 @@ class Settings extends ChangeNotifier {
|
||||||
// collection
|
// collection
|
||||||
static const collectionGroupFactorKey = 'collection_group_factor';
|
static const collectionGroupFactorKey = 'collection_group_factor';
|
||||||
static const collectionSortFactorKey = 'collection_sort_factor';
|
static const collectionSortFactorKey = 'collection_sort_factor';
|
||||||
|
static const collectionSelectionQuickActionsKey = 'collection_selection_quick_actions';
|
||||||
static const showThumbnailLocationKey = 'show_thumbnail_location';
|
static const showThumbnailLocationKey = 'show_thumbnail_location';
|
||||||
static const showThumbnailRawKey = 'show_thumbnail_raw';
|
static const showThumbnailRawKey = 'show_thumbnail_raw';
|
||||||
static const showThumbnailVideoDurationKey = 'show_thumbnail_video_duration';
|
static const showThumbnailVideoDurationKey = 'show_thumbnail_video_duration';
|
||||||
|
@ -108,27 +106,6 @@ class Settings extends ChangeNotifier {
|
||||||
// version
|
// version
|
||||||
static const lastVersionCheckDateKey = 'last_version_check_date';
|
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 {
|
Future<void> init() async {
|
||||||
_prefs = await SharedPreferences.getInstance();
|
_prefs = await SharedPreferences.getInstance();
|
||||||
_isRotationLocked = await windowService.isRotationLocked();
|
_isRotationLocked = await windowService.isRotationLocked();
|
||||||
|
@ -235,7 +212,7 @@ class Settings extends ChangeNotifier {
|
||||||
if (v.isEmpty) return null;
|
if (v.isEmpty) return null;
|
||||||
return CollectionFilter.fromJson(v);
|
return CollectionFilter.fromJson(v);
|
||||||
}).toList() ??
|
}).toList() ??
|
||||||
drawerTypeBookmarksDefault;
|
SettingsDefaults.drawerTypeBookmarks;
|
||||||
|
|
||||||
set drawerTypeBookmarks(List<CollectionFilter?> newValue) => setAndNotify(drawerTypeBookmarksKey, newValue.map((filter) => filter?.toJson() ?? '').toList());
|
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);
|
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);
|
set drawerPageBookmarks(List<String> newValue) => setAndNotify(drawerPageBookmarksKey, newValue);
|
||||||
|
|
||||||
|
@ -257,6 +234,10 @@ class Settings extends ChangeNotifier {
|
||||||
|
|
||||||
set collectionSortFactor(EntrySortFactor newValue) => setAndNotify(collectionSortFactorKey, newValue.toString());
|
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);
|
bool get showThumbnailLocation => getBoolOrDefault(showThumbnailLocationKey, true);
|
||||||
|
|
||||||
set showThumbnailLocation(bool newValue) => setAndNotify(showThumbnailLocationKey, newValue);
|
set showThumbnailLocation(bool newValue) => setAndNotify(showThumbnailLocationKey, newValue);
|
||||||
|
@ -297,7 +278,7 @@ class Settings extends ChangeNotifier {
|
||||||
|
|
||||||
// viewer
|
// 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());
|
set viewerQuickActions(List<EntryAction> newValue) => setAndNotify(viewerQuickActionsKey, newValue.map((v) => v.toString()).toList());
|
||||||
|
|
||||||
|
@ -323,7 +304,7 @@ class Settings extends ChangeNotifier {
|
||||||
|
|
||||||
// video
|
// 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());
|
set videoQuickActions(List<VideoAction> newValue) => setAndNotify(videoQuickActionsKey, newValue.map((v) => v.toString()).toList());
|
||||||
|
|
||||||
|
@ -556,6 +537,7 @@ class Settings extends ChangeNotifier {
|
||||||
case drawerPageBookmarksKey:
|
case drawerPageBookmarksKey:
|
||||||
case pinnedFiltersKey:
|
case pinnedFiltersKey:
|
||||||
case hiddenFiltersKey:
|
case hiddenFiltersKey:
|
||||||
|
case collectionSelectionQuickActionsKey:
|
||||||
case viewerQuickActionsKey:
|
case viewerQuickActionsKey:
|
||||||
case videoQuickActionsKey:
|
case videoQuickActionsKey:
|
||||||
if (value is List) {
|
if (value is List) {
|
||||||
|
|
|
@ -7,7 +7,6 @@ import 'package:aves/model/filters/album.dart';
|
||||||
import 'package:aves/model/filters/filters.dart';
|
import 'package:aves/model/filters/filters.dart';
|
||||||
import 'package:aves/model/filters/location.dart';
|
import 'package:aves/model/filters/location.dart';
|
||||||
import 'package:aves/model/filters/tag.dart';
|
import 'package:aves/model/filters/tag.dart';
|
||||||
import 'package:aves/model/metadata.dart';
|
|
||||||
import 'package:aves/model/settings/settings.dart';
|
import 'package:aves/model/settings/settings.dart';
|
||||||
import 'package:aves/model/source/album.dart';
|
import 'package:aves/model/source/album.dart';
|
||||||
import 'package:aves/model/source/enums.dart';
|
import 'package:aves/model/source/enums.dart';
|
||||||
|
@ -22,6 +21,8 @@ import 'package:flutter/foundation.dart';
|
||||||
mixin SourceBase {
|
mixin SourceBase {
|
||||||
EventBus get eventBus;
|
EventBus get eventBus;
|
||||||
|
|
||||||
|
Map<int?, AvesEntry> get entryById;
|
||||||
|
|
||||||
Set<AvesEntry> get visibleEntries;
|
Set<AvesEntry> get visibleEntries;
|
||||||
|
|
||||||
List<AvesEntry> get sortedEntriesByDate;
|
List<AvesEntry> get sortedEntriesByDate;
|
||||||
|
@ -41,6 +42,11 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
||||||
@override
|
@override
|
||||||
EventBus get eventBus => _eventBus;
|
EventBus get eventBus => _eventBus;
|
||||||
|
|
||||||
|
final Map<int?, AvesEntry> _entryById = {};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<int?, AvesEntry> get entryById => Map.unmodifiable(_entryById);
|
||||||
|
|
||||||
final Set<AvesEntry> _rawEntries = {};
|
final Set<AvesEntry> _rawEntries = {};
|
||||||
|
|
||||||
Set<AvesEntry> get allEntries => Set.unmodifiable(_rawEntries);
|
Set<AvesEntry> get allEntries => Set.unmodifiable(_rawEntries);
|
||||||
|
@ -61,11 +67,11 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
||||||
return _sortedEntriesByDate!;
|
return _sortedEntriesByDate!;
|
||||||
}
|
}
|
||||||
|
|
||||||
late List<DateMetadata> _savedDates;
|
late Map<int?, int?> _savedDates;
|
||||||
|
|
||||||
Future<void> loadDates() async {
|
Future<void> loadDates() async {
|
||||||
final stopwatch = Stopwatch()..start();
|
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');
|
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) {
|
void addEntries(Set<AvesEntry> entries) {
|
||||||
if (entries.isEmpty) return;
|
if (entries.isEmpty) return;
|
||||||
|
|
||||||
|
final newIdMapEntries = Map.fromEntries(entries.map((v) => MapEntry(v.contentId, v)));
|
||||||
if (_rawEntries.isNotEmpty) {
|
if (_rawEntries.isNotEmpty) {
|
||||||
final newContentIds = entries.map((entry) => entry.contentId).toSet();
|
final newContentIds = newIdMapEntries.keys.toSet();
|
||||||
_rawEntries.removeWhere((entry) => newContentIds.contains(entry.contentId));
|
_rawEntries.removeWhere((entry) => newContentIds.contains(entry.contentId));
|
||||||
}
|
}
|
||||||
entries.forEach((entry) {
|
|
||||||
final contentId = entry.contentId;
|
entries.forEach((entry) => entry.catalogDateMillis = _savedDates[entry.contentId]);
|
||||||
entry.catalogDateMillis = _savedDates.firstWhereOrNull((metadata) => metadata.contentId == contentId)?.dateMillis;
|
|
||||||
});
|
_entryById.addAll(newIdMapEntries);
|
||||||
_rawEntries.addAll(entries);
|
_rawEntries.addAll(entries);
|
||||||
_invalidate(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();
|
final entries = _rawEntries.where((entry) => uris.contains(entry.uri)).toSet();
|
||||||
await favourites.remove(entries);
|
await favourites.remove(entries);
|
||||||
await covers.removeEntries(entries);
|
await covers.removeEntries(entries);
|
||||||
|
|
||||||
|
entries.forEach((v) => _entryById.remove(v.contentId));
|
||||||
_rawEntries.removeAll(entries);
|
_rawEntries.removeAll(entries);
|
||||||
_invalidate(entries);
|
_invalidate(entries);
|
||||||
|
|
||||||
|
@ -114,6 +124,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
||||||
}
|
}
|
||||||
|
|
||||||
void clearEntries() {
|
void clearEntries() {
|
||||||
|
_entryById.clear();
|
||||||
_rawEntries.clear();
|
_rawEntries.clear();
|
||||||
_invalidate();
|
_invalidate();
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,7 @@ import 'dart:math';
|
||||||
import 'package:aves/geo/countries.dart';
|
import 'package:aves/geo/countries.dart';
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/filters/location.dart';
|
import 'package:aves/model/filters/location.dart';
|
||||||
import 'package:aves/model/metadata.dart';
|
import 'package:aves/model/metadata/address.dart';
|
||||||
import 'package:aves/model/source/collection_source.dart';
|
import 'package:aves/model/source/collection_source.dart';
|
||||||
import 'package:aves/model/source/enums.dart';
|
import 'package:aves/model/source/enums.dart';
|
||||||
import 'package:aves/services/services.dart';
|
import 'package:aves/services/services.dart';
|
||||||
|
@ -20,10 +20,8 @@ mixin LocationMixin on SourceBase {
|
||||||
Future<void> loadAddresses() async {
|
Future<void> loadAddresses() async {
|
||||||
final stopwatch = Stopwatch()..start();
|
final stopwatch = Stopwatch()..start();
|
||||||
final saved = await metadataDb.loadAddresses();
|
final saved = await metadataDb.loadAddresses();
|
||||||
visibleEntries.forEach((entry) {
|
final idMap = entryById;
|
||||||
final contentId = entry.contentId;
|
saved.forEach((metadata) => idMap[metadata.contentId]?.addressDetails = metadata);
|
||||||
entry.addressDetails = saved.firstWhereOrNull((address) => address.contentId == contentId);
|
|
||||||
});
|
|
||||||
debugPrint('$runtimeType loadAddresses complete in ${stopwatch.elapsed.inMilliseconds}ms for ${saved.length} entries');
|
debugPrint('$runtimeType loadAddresses complete in ${stopwatch.elapsed.inMilliseconds}ms for ${saved.length} entries');
|
||||||
onAddressMetadataChanged();
|
onAddressMetadataChanged();
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,7 +36,7 @@ class MediaStoreSource extends CollectionSource {
|
||||||
settings.catalogTimeZone = currentTimeZone;
|
settings.catalogTimeZone = currentTimeZone;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await loadDates(); // 100ms for 5400 entries
|
await loadDates();
|
||||||
_initialized = true;
|
_initialized = true;
|
||||||
debugPrint('$runtimeType init done, elapsed=${stopwatch.elapsed}');
|
debugPrint('$runtimeType init done, elapsed=${stopwatch.elapsed}');
|
||||||
}
|
}
|
||||||
|
@ -49,15 +49,15 @@ class MediaStoreSource extends CollectionSource {
|
||||||
stateNotifier.value = SourceState.loading;
|
stateNotifier.value = SourceState.loading;
|
||||||
clearEntries();
|
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 knownDateById = Map.fromEntries(oldEntries.map((entry) => MapEntry(entry.contentId!, entry.dateModifiedSecs!)));
|
||||||
final obsoleteContentIds = (await mediaStoreService.checkObsoleteContentIds(knownDateById.keys.toList())).toSet();
|
final obsoleteContentIds = (await mediaStoreService.checkObsoleteContentIds(knownDateById.keys.toList())).toSet();
|
||||||
oldEntries.removeWhere((entry) => obsoleteContentIds.contains(entry.contentId));
|
oldEntries.removeWhere((entry) => obsoleteContentIds.contains(entry.contentId));
|
||||||
|
|
||||||
// show known entries
|
// show known entries
|
||||||
addEntries(oldEntries);
|
addEntries(oldEntries);
|
||||||
await loadCatalogMetadata(); // 600ms for 5500 entries
|
await loadCatalogMetadata();
|
||||||
await loadAddresses(); // 200ms for 3000 entries
|
await loadAddresses();
|
||||||
debugPrint('$runtimeType refresh loaded ${oldEntries.length} known entries, elapsed=${stopwatch.elapsed}');
|
debugPrint('$runtimeType refresh loaded ${oldEntries.length} known entries, elapsed=${stopwatch.elapsed}');
|
||||||
|
|
||||||
// clean up obsolete entries
|
// clean up obsolete entries
|
||||||
|
@ -94,7 +94,7 @@ class MediaStoreSource extends CollectionSource {
|
||||||
addPendingEntries();
|
addPendingEntries();
|
||||||
debugPrint('$runtimeType refresh loaded ${allNewEntries.length} new entries, elapsed=${stopwatch.elapsed}');
|
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) {
|
if (allNewEntries.isNotEmpty) {
|
||||||
// new entries include existing entries with obsolete paths
|
// new entries include existing entries with obsolete paths
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/filters/tag.dart';
|
import 'package:aves/model/filters/tag.dart';
|
||||||
import 'package:aves/model/metadata.dart';
|
import 'package:aves/model/metadata/catalog.dart';
|
||||||
import 'package:aves/model/source/collection_source.dart';
|
import 'package:aves/model/source/collection_source.dart';
|
||||||
import 'package:aves/model/source/enums.dart';
|
import 'package:aves/model/source/enums.dart';
|
||||||
import 'package:aves/services/services.dart';
|
import 'package:aves/services/services.dart';
|
||||||
|
@ -15,10 +15,8 @@ mixin TagMixin on SourceBase {
|
||||||
Future<void> loadCatalogMetadata() async {
|
Future<void> loadCatalogMetadata() async {
|
||||||
final stopwatch = Stopwatch()..start();
|
final stopwatch = Stopwatch()..start();
|
||||||
final saved = await metadataDb.loadMetadataEntries();
|
final saved = await metadataDb.loadMetadataEntries();
|
||||||
visibleEntries.forEach((entry) {
|
final idMap = entryById;
|
||||||
final contentId = entry.contentId;
|
saved.forEach((metadata) => idMap[metadata.contentId]?.catalogMetadata = metadata);
|
||||||
entry.catalogMetadata = saved.firstWhereOrNull((metadata) => metadata.contentId == contentId);
|
|
||||||
});
|
|
||||||
debugPrint('$runtimeType loadCatalogMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${saved.length} entries');
|
debugPrint('$runtimeType loadCatalogMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${saved.length} entries');
|
||||||
onCatalogMetadataChanged();
|
onCatalogMetadataChanged();
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
|
import 'package:aves/model/metadata/catalog.dart';
|
||||||
import 'package:aves/model/video/channel_layouts.dart';
|
import 'package:aves/model/video/channel_layouts.dart';
|
||||||
import 'package:aves/model/video/codecs.dart';
|
import 'package:aves/model/video/codecs.dart';
|
||||||
import 'package:aves/model/video/keys.dart';
|
import 'package:aves/model/video/keys.dart';
|
||||||
|
@ -9,10 +10,11 @@ import 'package:aves/model/video/profiles/h264.dart';
|
||||||
import 'package:aves/model/video/profiles/hevc.dart';
|
import 'package:aves/model/video/profiles/hevc.dart';
|
||||||
import 'package:aves/ref/languages.dart';
|
import 'package:aves/ref/languages.dart';
|
||||||
import 'package:aves/ref/mp4.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/file_utils.dart';
|
||||||
import 'package:aves/utils/math_utils.dart';
|
import 'package:aves/utils/math_utils.dart';
|
||||||
import 'package:aves/utils/string_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:aves/widgets/viewer/video/fijkplayer.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:fijkplayer/fijkplayer.dart';
|
import 'package:fijkplayer/fijkplayer.dart';
|
||||||
|
@ -50,7 +52,7 @@ class VideoMetadataFormatter {
|
||||||
return info;
|
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 mediaInfo = await getVideoMetadata(entry);
|
||||||
final fields = <String, int>{};
|
final fields = <String, int>{};
|
||||||
|
|
||||||
|
@ -75,6 +77,30 @@ class VideoMetadataFormatter {
|
||||||
return fields;
|
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'
|
// pattern to extract optional language code suffix, e.g. 'location-eng'
|
||||||
static final keyWithLanguagePattern = RegExp(r'^(.*)-([a-z]{3})$');
|
static final keyWithLanguagePattern = RegExp(r'^(.*)-([a-z]{3})$');
|
||||||
|
|
||||||
|
|
|
@ -41,6 +41,7 @@ class MimeTypes {
|
||||||
static const anyVideo = 'video/*';
|
static const anyVideo = 'video/*';
|
||||||
|
|
||||||
static const avi = 'video/avi';
|
static const avi = 'video/avi';
|
||||||
|
static const aviVnd = 'video/vnd.avi';
|
||||||
static const mkv = 'video/x-matroska';
|
static const mkv = 'video/x-matroska';
|
||||||
static const mov = 'video/quicktime';
|
static const mov = 'video/quicktime';
|
||||||
static const mp2t = 'video/mp2t'; // .m2ts
|
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};
|
static const Set<String> rawImages = {arw, cr2, crw, dcr, dng, erf, k25, kdc, mrw, nef, nrw, orf, pef, raf, raw, rw2, sr2, srf, srw, x3f};
|
||||||
|
|
||||||
// TODO TLAD make it dynamic if it depends on OS/lib versions
|
// TODO TLAD [codec] make it dynamic if it depends on OS/lib versions
|
||||||
static const Set<String> undecodableImages = {art, crw, djvu, psdVnd, psdX};
|
static const Set<String> undecodableImages = {art, crw, djvu, psdVnd, psdX};
|
||||||
|
|
||||||
static const Set<String> _knownOpaqueImages = {heic, heif, jpeg};
|
static const Set<String> _knownOpaqueImages = {heic, heif, jpeg};
|
||||||
|
|
||||||
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};
|
static final Set<String> knownMediaTypes = {..._knownOpaqueImages, ...alphaImages, ...rawImages, ...undecodableImages, ..._knownVideos};
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,7 @@ class XMP {
|
||||||
|
|
||||||
// cf https://exiftool.org/TagNames/XMP.html
|
// cf https://exiftool.org/TagNames/XMP.html
|
||||||
static const Map<String, String> namespaces = {
|
static const Map<String, String> namespaces = {
|
||||||
|
'acdsee': 'ACDSee',
|
||||||
'adsml-at': 'AdsML',
|
'adsml-at': 'AdsML',
|
||||||
'aux': 'Exif Aux',
|
'aux': 'Exif Aux',
|
||||||
'avm': 'Astronomy Visualization',
|
'avm': 'Astronomy Visualization',
|
||||||
|
|
|
@ -136,6 +136,20 @@ class AndroidDebugService {
|
||||||
return {};
|
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 {
|
static Future<Map> getTiffStructure(AvesEntry entry) async {
|
||||||
if (entry.mimeType != MimeTypes.tiff) return {};
|
if (entry.mimeType != MimeTypes.tiff) return {};
|
||||||
|
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:aves/services/services.dart';
|
import 'package:aves/services/services.dart';
|
||||||
|
import 'package:aves/theme/format.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:intl/date_symbol_data_local.dart';
|
import 'package:intl/date_symbol_data_local.dart';
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
|
|
||||||
class GlobalSearch {
|
class GlobalSearch {
|
||||||
static const platform = MethodChannel('deckers.thibault/aves/global_search');
|
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,
|
'data': entry.uri,
|
||||||
'mimeType': entry.mimeType,
|
'mimeType': entry.mimeType,
|
||||||
'title': entry.bestTitle,
|
'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,
|
'iconUri': entry.uri,
|
||||||
};
|
};
|
||||||
}));
|
}));
|
||||||
|
|
|
@ -4,6 +4,8 @@ import 'dart:typed_data';
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
|
import 'package:aves/model/metadata/date_modifier.dart';
|
||||||
|
import 'package:aves/model/metadata/enums.dart';
|
||||||
import 'package:aves/ref/mime_types.dart';
|
import 'package:aves/ref/mime_types.dart';
|
||||||
import 'package:aves/services/image_op_events.dart';
|
import 'package:aves/services/image_op_events.dart';
|
||||||
import 'package:aves/services/output_buffer.dart';
|
import 'package:aves/services/output_buffer.dart';
|
||||||
|
@ -94,6 +96,8 @@ abstract class ImageFileService {
|
||||||
Future<Map<String, dynamic>> rotate(AvesEntry entry, {required bool clockwise});
|
Future<Map<String, dynamic>> rotate(AvesEntry entry, {required bool clockwise});
|
||||||
|
|
||||||
Future<Map<String, dynamic>> flip(AvesEntry entry);
|
Future<Map<String, dynamic>> flip(AvesEntry entry);
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> editDate(AvesEntry entry, DateModifier modifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
class PlatformImageFileService implements ImageFileService {
|
class PlatformImageFileService implements ImageFileService {
|
||||||
|
@ -408,4 +412,33 @@ class PlatformImageFileService implements ImageFileService {
|
||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<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';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/metadata.dart';
|
import 'package:aves/model/metadata/catalog.dart';
|
||||||
|
import 'package:aves/model/metadata/overlay.dart';
|
||||||
import 'package:aves/model/multipage.dart';
|
import 'package:aves/model/multipage.dart';
|
||||||
import 'package:aves/model/panorama.dart';
|
import 'package:aves/model/panorama.dart';
|
||||||
import 'package:aves/services/service_policy.dart';
|
import 'package:aves/services/service_policy.dart';
|
||||||
|
|
23
lib/theme/format.dart
Normal file
23
lib/theme/format.dart
Normal 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';
|
||||||
|
}
|
|
@ -40,6 +40,7 @@ class AIcons {
|
||||||
static const IconData copy = Icons.file_copy_outlined;
|
static const IconData copy = Icons.file_copy_outlined;
|
||||||
static const IconData debug = Icons.whatshot_outlined;
|
static const IconData debug = Icons.whatshot_outlined;
|
||||||
static const IconData delete = Icons.delete_outlined;
|
static const IconData delete = Icons.delete_outlined;
|
||||||
|
static const IconData edit = Icons.edit_outlined;
|
||||||
static const IconData export = MdiIcons.fileExportOutline;
|
static const IconData export = MdiIcons.fileExportOutline;
|
||||||
static const IconData flip = Icons.flip_outlined;
|
static const IconData flip = Icons.flip_outlined;
|
||||||
static const IconData favourite = Icons.favorite_border;
|
static const IconData favourite = Icons.favorite_border;
|
||||||
|
|
|
@ -77,6 +77,11 @@ class Constants {
|
||||||
license: 'Apache 2.0',
|
license: 'Apache 2.0',
|
||||||
sourceUrl: 'https://github.com/drewnoakes/metadata-extractor',
|
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 = [
|
static const List<Dependency> flutterPlugins = [
|
||||||
|
@ -265,7 +270,7 @@ class Constants {
|
||||||
sourceUrl: 'https://github.com/fluttercommunity/get_it',
|
sourceUrl: 'https://github.com/fluttercommunity/get_it',
|
||||||
),
|
),
|
||||||
Dependency(
|
Dependency(
|
||||||
name: 'Github',
|
name: 'GitHub',
|
||||||
license: 'MIT',
|
license: 'MIT',
|
||||||
sourceUrl: 'https://github.com/SpinlockLabs/github.dart',
|
sourceUrl: 'https://github.com/SpinlockLabs/github.dart',
|
||||||
),
|
),
|
||||||
|
|
|
@ -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 {
|
extension ExtraDateTime on DateTime {
|
||||||
bool isAtSameYearAs(DateTime? other) => year == other?.year;
|
bool isAtSameYearAs(DateTime? other) => year == other?.year;
|
||||||
|
|
||||||
|
|
|
@ -61,7 +61,7 @@ class _AboutUpdateState extends State<AboutUpdate> {
|
||||||
TextSpan(text: context.l10n.aboutUpdateLinks1),
|
TextSpan(text: context.l10n.aboutUpdateLinks1),
|
||||||
WidgetSpan(
|
WidgetSpan(
|
||||||
child: LinkChip(
|
child: LinkChip(
|
||||||
text: context.l10n.aboutUpdateGithub,
|
text: context.l10n.aboutUpdateGitHub,
|
||||||
url: 'https://github.com/deckerst/aves/releases',
|
url: 'https://github.com/deckerst/aves/releases',
|
||||||
textStyle: const TextStyle(fontWeight: FontWeight.bold),
|
textStyle: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:aves/app_mode.dart';
|
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/actions/entry_set_actions.dart';
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/filters/filters.dart';
|
import 'package:aves/model/filters/filters.dart';
|
||||||
|
@ -133,6 +132,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
||||||
tooltip = MaterialLocalizations.of(context).openAppDrawerTooltip;
|
tooltip = MaterialLocalizations.of(context).openAppDrawerTooltip;
|
||||||
}
|
}
|
||||||
return IconButton(
|
return IconButton(
|
||||||
|
// key is expected by test driver
|
||||||
key: const Key('appbar-leading-button'),
|
key: const Key('appbar-leading-button'),
|
||||||
icon: AnimatedIcon(
|
icon: AnimatedIcon(
|
||||||
icon: AnimatedIcons.menu_arrow,
|
icon: AnimatedIcons.menu_arrow,
|
||||||
|
@ -167,6 +167,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
||||||
|
|
||||||
List<Widget> _buildActions(bool isSelecting) {
|
List<Widget> _buildActions(bool isSelecting) {
|
||||||
final appMode = context.watch<ValueNotifier<AppMode>>().value;
|
final appMode = context.watch<ValueNotifier<AppMode>>().value;
|
||||||
|
final selectionQuickActions = settings.collectionSelectionQuickActions;
|
||||||
return [
|
return [
|
||||||
if (!isSelecting && appMode.canSearch)
|
if (!isSelecting && appMode.canSearch)
|
||||||
CollectionSearchButton(
|
CollectionSearchButton(
|
||||||
|
@ -174,11 +175,11 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
||||||
parentCollection: collection,
|
parentCollection: collection,
|
||||||
),
|
),
|
||||||
if (isSelecting)
|
if (isSelecting)
|
||||||
...EntryActions.selection.map((action) => Selector<Selection<AvesEntry>, bool>(
|
...selectionQuickActions.map((action) => Selector<Selection<AvesEntry>, bool>(
|
||||||
selector: (context, selection) => selection.selectedItems.isEmpty,
|
selector: (context, selection) => selection.selectedItems.isEmpty,
|
||||||
builder: (context, isEmpty, child) => IconButton(
|
builder: (context, isEmpty, child) => IconButton(
|
||||||
icon: action.getIcon() ?? const SizedBox(),
|
icon: action.getIcon(),
|
||||||
onPressed: isEmpty ? null : () => _actionDelegate.onEntryActionSelected(context, action),
|
onPressed: isEmpty ? null : () => _onCollectionActionSelected(action),
|
||||||
tooltip: action.getText(context),
|
tooltip: action.getText(context),
|
||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
|
@ -188,6 +189,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
||||||
final canAddShortcuts = snapshot.data ?? false;
|
final canAddShortcuts = snapshot.data ?? false;
|
||||||
return MenuIconTheme(
|
return MenuIconTheme(
|
||||||
child: PopupMenuButton<EntrySetAction>(
|
child: PopupMenuButton<EntrySetAction>(
|
||||||
|
// key is expected by test driver
|
||||||
key: const Key('appbar-menu-button'),
|
key: const Key('appbar-menu-button'),
|
||||||
itemBuilder: (context) {
|
itemBuilder: (context) {
|
||||||
final groupable = collection.sortFactor == EntrySortFactor.date;
|
final groupable = collection.sortFactor == EntrySortFactor.date;
|
||||||
|
@ -201,11 +203,13 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
||||||
return [
|
return [
|
||||||
_toMenuItem(
|
_toMenuItem(
|
||||||
EntrySetAction.sort,
|
EntrySetAction.sort,
|
||||||
|
// key is expected by test driver
|
||||||
key: const Key('menu-sort'),
|
key: const Key('menu-sort'),
|
||||||
),
|
),
|
||||||
if (groupable)
|
if (groupable)
|
||||||
_toMenuItem(
|
_toMenuItem(
|
||||||
EntrySetAction.group,
|
EntrySetAction.group,
|
||||||
|
// key is expected by test driver
|
||||||
key: const Key('menu-group'),
|
key: const Key('menu-group'),
|
||||||
),
|
),
|
||||||
if (appMode == AppMode.main) ...[
|
if (appMode == AppMode.main) ...[
|
||||||
|
@ -215,16 +219,12 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
||||||
enabled: hasItems,
|
enabled: hasItems,
|
||||||
),
|
),
|
||||||
const PopupMenuDivider(),
|
const PopupMenuDivider(),
|
||||||
if (isSelecting)
|
if (isSelecting) ...EntrySetActions.selection.where((v) => !selectionQuickActions.contains(v)).map((v) => _toMenuItem(v, enabled: hasSelection)),
|
||||||
|
if (!isSelecting)
|
||||||
...[
|
...[
|
||||||
EntrySetAction.copy,
|
EntrySetAction.map,
|
||||||
EntrySetAction.move,
|
EntrySetAction.stats,
|
||||||
EntrySetAction.refreshMetadata,
|
].map((v) => _toMenuItem(v, enabled: otherViewEnabled)),
|
||||||
].map((v) => _toMenuItem(v, enabled: hasSelection)),
|
|
||||||
...[
|
|
||||||
EntrySetAction.map,
|
|
||||||
EntrySetAction.stats,
|
|
||||||
].map((v) => _toMenuItem(v, enabled: otherViewEnabled)),
|
|
||||||
if (!isSelecting && canAddShortcuts) ...[
|
if (!isSelecting && canAddShortcuts) ...[
|
||||||
const PopupMenuDivider(),
|
const PopupMenuDivider(),
|
||||||
_toMenuItem(EntrySetAction.addShortcut),
|
_toMenuItem(EntrySetAction.addShortcut),
|
||||||
|
@ -286,12 +286,14 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
||||||
|
|
||||||
Future<void> _onCollectionActionSelected(EntrySetAction action) async {
|
Future<void> _onCollectionActionSelected(EntrySetAction action) async {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
|
case EntrySetAction.share:
|
||||||
|
case EntrySetAction.delete:
|
||||||
case EntrySetAction.copy:
|
case EntrySetAction.copy:
|
||||||
case EntrySetAction.move:
|
case EntrySetAction.move:
|
||||||
case EntrySetAction.refreshMetadata:
|
case EntrySetAction.refreshMetadata:
|
||||||
case EntrySetAction.map:
|
case EntrySetAction.map:
|
||||||
case EntrySetAction.stats:
|
case EntrySetAction.stats:
|
||||||
_actionDelegate.onCollectionActionSelected(context, action);
|
_actionDelegate.onActionSelected(context, action);
|
||||||
break;
|
break;
|
||||||
case EntrySetAction.select:
|
case EntrySetAction.select:
|
||||||
context.read<Selection<AvesEntry>>().select();
|
context.read<Selection<AvesEntry>>().select();
|
||||||
|
|
|
@ -56,6 +56,7 @@ class _CollectionPageState extends State<CollectionPage> {
|
||||||
child: ChangeNotifierProvider<CollectionLens>.value(
|
child: ChangeNotifierProvider<CollectionLens>.value(
|
||||||
value: collection,
|
value: collection,
|
||||||
child: const CollectionGrid(
|
child: const CollectionGrid(
|
||||||
|
// key is expected by test driver
|
||||||
key: Key('collection-grid'),
|
key: Key('collection-grid'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import 'dart:async';
|
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/entry_set_actions.dart';
|
||||||
import 'package:aves/model/actions/move_type.dart';
|
import 'package:aves/model/actions/move_type.dart';
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
|
@ -30,21 +29,14 @@ import 'package:flutter/widgets.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
|
class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
|
||||||
void onEntryActionSelected(BuildContext context, EntryAction action) {
|
void onActionSelected(BuildContext context, EntrySetAction action) {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case EntryAction.delete:
|
case EntrySetAction.share:
|
||||||
_showDeleteDialog(context);
|
|
||||||
break;
|
|
||||||
case EntryAction.share:
|
|
||||||
_share(context);
|
_share(context);
|
||||||
break;
|
break;
|
||||||
default:
|
case EntrySetAction.delete:
|
||||||
|
_showDeleteDialog(context);
|
||||||
break;
|
break;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void onCollectionActionSelected(BuildContext context, EntrySetAction action) {
|
|
||||||
switch (action) {
|
|
||||||
case EntrySetAction.copy:
|
case EntrySetAction.copy:
|
||||||
_moveSelection(context, moveType: MoveType.copy);
|
_moveSelection(context, moveType: MoveType.copy);
|
||||||
break;
|
break;
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'package:aves/theme/format.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:aves/widgets/common/grid/section_layout.dart';
|
import 'package:aves/widgets/common/grid/section_layout.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
@ -62,6 +63,6 @@ class DraggableThumbLabel<T> extends StatelessWidget {
|
||||||
static String formatDayThumbLabel(BuildContext context, DateTime? date) {
|
static String formatDayThumbLabel(BuildContext context, DateTime? date) {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
if (date == null) return l10n.sectionUnknown;
|
if (date == null) return l10n.sectionUnknown;
|
||||||
return DateFormat.yMMMd(l10n.localeName).format(date);
|
return formatDay(date, l10n.localeName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -48,6 +48,7 @@ class AvesExpansionTile extends StatelessWidget {
|
||||||
accentColor: Colors.white,
|
accentColor: Colors.white,
|
||||||
),
|
),
|
||||||
child: ExpansionTileCard(
|
child: ExpansionTileCard(
|
||||||
|
// key is expected by test driver
|
||||||
key: Key('tilecard-$value'),
|
key: Key('tilecard-$value'),
|
||||||
value: value,
|
value: value,
|
||||||
expandedNotifier: expandedNotifier,
|
expandedNotifier: expandedNotifier,
|
||||||
|
|
|
@ -11,13 +11,11 @@ import 'package:flutter/material.dart';
|
||||||
class ErrorThumbnail extends StatefulWidget {
|
class ErrorThumbnail extends StatefulWidget {
|
||||||
final AvesEntry entry;
|
final AvesEntry entry;
|
||||||
final double extent;
|
final double extent;
|
||||||
final String tooltip;
|
|
||||||
|
|
||||||
const ErrorThumbnail({
|
const ErrorThumbnail({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.entry,
|
required this.entry,
|
||||||
required this.extent,
|
required this.extent,
|
||||||
required this.tooltip,
|
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -48,27 +46,27 @@ class _ErrorThumbnailState extends State<ErrorThumbnail> {
|
||||||
child = const SizedBox();
|
child = const SizedBox();
|
||||||
} else {
|
} else {
|
||||||
final exists = snapshot.data!;
|
final exists = snapshot.data!;
|
||||||
child = Tooltip(
|
child = exists
|
||||||
message: exists ? widget.tooltip : context.l10n.viewerErrorDoesNotExist,
|
? LayoutBuilder(builder: (context, constraints) {
|
||||||
preferBelow: false,
|
final fontSize = min(extent, constraints.biggest.width) / 5;
|
||||||
child: exists
|
return Text(
|
||||||
? LayoutBuilder(builder: (context, constraints) {
|
MimeUtils.displayType(entry.mimeType),
|
||||||
final fontSize = min(extent, constraints.biggest.width) / 5;
|
style: TextStyle(
|
||||||
return Text(
|
color: color,
|
||||||
MimeUtils.displayType(entry.mimeType),
|
fontSize: fontSize,
|
||||||
style: TextStyle(
|
),
|
||||||
color: color,
|
textAlign: TextAlign.center,
|
||||||
fontSize: fontSize,
|
);
|
||||||
),
|
})
|
||||||
textAlign: TextAlign.center,
|
: Tooltip(
|
||||||
);
|
message: context.l10n.viewerErrorDoesNotExist,
|
||||||
})
|
preferBelow: false,
|
||||||
: Icon(
|
child: Icon(
|
||||||
AIcons.broken,
|
AIcons.broken,
|
||||||
size: extent / 2,
|
size: extent / 2,
|
||||||
color: color,
|
color: color,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return Container(
|
return Container(
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
|
|
|
@ -8,7 +8,6 @@ import 'package:aves/model/settings/entry_background.dart';
|
||||||
import 'package:aves/model/settings/enums.dart';
|
import 'package:aves/model/settings/enums.dart';
|
||||||
import 'package:aves/model/settings/settings.dart';
|
import 'package:aves/model/settings/settings.dart';
|
||||||
import 'package:aves/services/services.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/checkered_decoration.dart';
|
||||||
import 'package:aves/widgets/common/fx/transition_image.dart';
|
import 'package:aves/widgets/common/fx/transition_image.dart';
|
||||||
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||||
|
@ -173,10 +172,8 @@ class _ThumbnailImageState extends State<ThumbnailImage> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (!entry.canDecode) {
|
if (!entry.canDecode || _lastException != null) {
|
||||||
return _buildError(context, context.l10n.errorUnsupportedMimeType(entry.mimeType), null);
|
return _buildError(context);
|
||||||
} else if (_lastException != null) {
|
|
||||||
return _buildError(context, _lastException.toString(), null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// use `RawImage` instead of `Image`, using `ImageInfo` to check dimensions
|
// use `RawImage` instead of `Image`, using `ImageInfo` to check dimensions
|
||||||
|
@ -246,11 +243,10 @@ class _ThumbnailImageState extends State<ThumbnailImage> {
|
||||||
: image;
|
: image;
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildError(BuildContext context, Object error, StackTrace? stackTrace) {
|
Widget _buildError(BuildContext context) {
|
||||||
final child = ErrorThumbnail(
|
final child = ErrorThumbnail(
|
||||||
entry: entry,
|
entry: entry,
|
||||||
extent: extent,
|
extent: extent,
|
||||||
tooltip: error.toString(),
|
|
||||||
);
|
);
|
||||||
return widget.heroTag != null
|
return widget.heroTag != null
|
||||||
? Hero(
|
? Hero(
|
||||||
|
|
|
@ -95,6 +95,14 @@ class _AppDebugPageState extends State<AppDebugPage> {
|
||||||
},
|
},
|
||||||
title: const Text('Show tasks overlay'),
|
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(),
|
const Divider(),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import 'package:aves/model/covers.dart';
|
import 'package:aves/model/covers.dart';
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/favourites.dart';
|
import 'package:aves/model/favourites.dart';
|
||||||
import 'package:aves/model/metadata.dart';
|
import 'package:aves/model/metadata/address.dart';
|
||||||
|
import 'package:aves/model/metadata/catalog.dart';
|
||||||
import 'package:aves/services/services.dart';
|
import 'package:aves/services/services.dart';
|
||||||
import 'package:aves/utils/file_utils.dart';
|
import 'package:aves/utils/file_utils.dart';
|
||||||
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
|
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
|
||||||
|
@ -17,7 +18,7 @@ class DebugAppDatabaseSection extends StatefulWidget {
|
||||||
class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with AutomaticKeepAliveClientMixin {
|
class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with AutomaticKeepAliveClientMixin {
|
||||||
late Future<int> _dbFileSizeLoader;
|
late Future<int> _dbFileSizeLoader;
|
||||||
late Future<Set<AvesEntry>> _dbEntryLoader;
|
late Future<Set<AvesEntry>> _dbEntryLoader;
|
||||||
late Future<List<DateMetadata>> _dbDateLoader;
|
late Future<Map<int?, int?>> _dbDateLoader;
|
||||||
late Future<List<CatalogMetadata>> _dbMetadataLoader;
|
late Future<List<CatalogMetadata>> _dbMetadataLoader;
|
||||||
late Future<List<AddressDetails>> _dbAddressLoader;
|
late Future<List<AddressDetails>> _dbAddressLoader;
|
||||||
late Future<Set<FavouriteRow>> _dbFavouritesLoader;
|
late Future<Set<FavouriteRow>> _dbFavouritesLoader;
|
||||||
|
@ -82,7 +83,7 @@ class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
FutureBuilder<List>(
|
FutureBuilder<Map<int?, int?>>(
|
||||||
future: _dbDateLoader,
|
future: _dbDateLoader,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.hasError) return Text(snapshot.error.toString());
|
if (snapshot.hasError) return Text(snapshot.error.toString());
|
||||||
|
|
|
@ -52,6 +52,7 @@ class DebugSettingsSection extends StatelessWidget {
|
||||||
'tileExtent - Countries': '${settings.getTileExtent(CountryListPage.routeName)}',
|
'tileExtent - Countries': '${settings.getTileExtent(CountryListPage.routeName)}',
|
||||||
'tileExtent - Tags': '${settings.getTileExtent(TagListPage.routeName)}',
|
'tileExtent - Tags': '${settings.getTileExtent(TagListPage.routeName)}',
|
||||||
'infoMapZoom': '${settings.infoMapZoom}',
|
'infoMapZoom': '${settings.infoMapZoom}',
|
||||||
|
'collectionSelectionQuickActions': '${settings.collectionSelectionQuickActions}',
|
||||||
'viewerQuickActions': '${settings.viewerQuickActions}',
|
'viewerQuickActions': '${settings.viewerQuickActions}',
|
||||||
'videoQuickActions': '${settings.videoQuickActions}',
|
'videoQuickActions': '${settings.videoQuickActions}',
|
||||||
'drawerTypeBookmarks': toMultiline(settings.drawerTypeBookmarks),
|
'drawerTypeBookmarks': toMultiline(settings.drawerTypeBookmarks),
|
||||||
|
|
|
@ -31,28 +31,7 @@ class AvesDialog extends AlertDialog {
|
||||||
// scroll both the title and the content together,
|
// scroll both the title and the content together,
|
||||||
// and overflow feedback ignores the dialog shape,
|
// and overflow feedback ignores the dialog shape,
|
||||||
// so we restrict scrolling to the content instead
|
// so we restrict scrolling to the content instead
|
||||||
content: scrollableContent != null
|
content: _buildContent(context, scrollController, scrollableContent, content),
|
||||||
? Container(
|
|
||||||
// padding to avoid transparent border overlapping
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: borderWidth),
|
|
||||||
// workaround because the dialog tries
|
|
||||||
// to size itself to the content intrinsic size,
|
|
||||||
// but the `ListView` viewport does not have one
|
|
||||||
width: 1,
|
|
||||||
child: DecoratedBox(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
border: Border(
|
|
||||||
bottom: Divider.createBorderSide(context, width: borderWidth),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: ListView(
|
|
||||||
controller: scrollController ?? ScrollController(),
|
|
||||||
shrinkWrap: true,
|
|
||||||
children: scrollableContent,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: content,
|
|
||||||
contentPadding: scrollableContent != null ? EdgeInsets.zero : const EdgeInsets.fromLTRB(24, 20, 24, 0),
|
contentPadding: scrollableContent != null ? EdgeInsets.zero : const EdgeInsets.fromLTRB(24, 20, 24, 0),
|
||||||
actions: actions,
|
actions: actions,
|
||||||
actionsPadding: const EdgeInsets.symmetric(horizontal: 8),
|
actionsPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
@ -61,6 +40,57 @@ class AvesDialog extends AlertDialog {
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(24)),
|
borderRadius: const BorderRadius.all(Radius.circular(24)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
static Widget _buildContent(
|
||||||
|
BuildContext context,
|
||||||
|
ScrollController? scrollController,
|
||||||
|
List<Widget>? scrollableContent,
|
||||||
|
Widget? content,
|
||||||
|
) {
|
||||||
|
if (content != null) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scrollableContent != null) {
|
||||||
|
scrollController ??= ScrollController();
|
||||||
|
return Container(
|
||||||
|
// padding to avoid transparent border overlapping
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: borderWidth),
|
||||||
|
// workaround because the dialog tries
|
||||||
|
// to size itself to the content intrinsic size,
|
||||||
|
// but the `ListView` viewport does not have one
|
||||||
|
width: 1,
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border(
|
||||||
|
bottom: Divider.createBorderSide(context, width: borderWidth),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Theme(
|
||||||
|
data: Theme.of(context).copyWith(
|
||||||
|
scrollbarTheme: const ScrollbarThemeData(
|
||||||
|
isAlwaysShown: true,
|
||||||
|
radius: Radius.circular(16),
|
||||||
|
crossAxisMargin: 4,
|
||||||
|
mainAxisMargin: 4,
|
||||||
|
interactive: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Scrollbar(
|
||||||
|
controller: scrollController,
|
||||||
|
child: ListView(
|
||||||
|
controller: scrollController,
|
||||||
|
shrinkWrap: true,
|
||||||
|
children: scrollableContent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return const SizedBox();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class DialogTitle extends StatelessWidget {
|
class DialogTitle extends StatelessWidget {
|
||||||
|
|
|
@ -51,6 +51,7 @@ class _AvesSelectionDialogState<T> extends State<AvesSelectionDialog<T>> {
|
||||||
Widget _buildRadioListTile(T value, String title) {
|
Widget _buildRadioListTile(T value, String title) {
|
||||||
final subtitle = widget.optionSubtitleBuilder?.call(value);
|
final subtitle = widget.optionSubtitleBuilder?.call(value);
|
||||||
return ReselectableRadioListTile<T>(
|
return ReselectableRadioListTile<T>(
|
||||||
|
// key is expected by test driver
|
||||||
key: Key(value.toString()),
|
key: Key(value.toString()),
|
||||||
value: value,
|
value: value,
|
||||||
groupValue: _selectedValue,
|
groupValue: _selectedValue,
|
||||||
|
|
|
@ -73,15 +73,17 @@ class _CoverSelectionDialogState extends State<CoverSelectionDialog> {
|
||||||
setState(() {});
|
setState(() {});
|
||||||
},
|
},
|
||||||
title: isCustom
|
title: isCustom
|
||||||
? Row(children: [
|
? Row(
|
||||||
title,
|
children: [
|
||||||
const Spacer(),
|
title,
|
||||||
IconButton(
|
const Spacer(),
|
||||||
icon: const Icon(AIcons.setCover),
|
IconButton(
|
||||||
onPressed: _isCustom ? _pickEntry : null,
|
icon: const Icon(AIcons.setCover),
|
||||||
tooltip: context.l10n.changeTooltip,
|
onPressed: _isCustom ? _pickEntry : null,
|
||||||
),
|
tooltip: context.l10n.changeTooltip,
|
||||||
])
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
: title,
|
: title,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
420
lib/widgets/dialogs/edit_entry_date_dialog.dart
Normal file
420
lib/widgets/dialogs/edit_entry_date_dialog.dart
Normal 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(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -142,6 +142,7 @@ class _AppDrawerState extends State<AppDrawer> {
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
children: [
|
children: [
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
|
// key is expected by test driver
|
||||||
key: const Key('drawer-about-button'),
|
key: const Key('drawer-about-button'),
|
||||||
onPressed: () => goTo(AboutPage.routeName, (_) => const AboutPage()),
|
onPressed: () => goTo(AboutPage.routeName, (_) => const AboutPage()),
|
||||||
icon: const Icon(AIcons.info),
|
icon: const Icon(AIcons.info),
|
||||||
|
@ -178,6 +179,7 @@ class _AppDrawerState extends State<AppDrawer> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
|
// key is expected by test driver
|
||||||
key: const Key('drawer-settings-button'),
|
key: const Key('drawer-settings-button'),
|
||||||
onPressed: () => goTo(SettingsPage.routeName, (_) => const SettingsPage()),
|
onPressed: () => goTo(SettingsPage.routeName, (_) => const SettingsPage()),
|
||||||
icon: const Icon(AIcons.settings),
|
icon: const Icon(AIcons.settings),
|
||||||
|
|
|
@ -24,6 +24,7 @@ class PageNavTile extends StatelessWidget {
|
||||||
top: false,
|
top: false,
|
||||||
bottom: false,
|
bottom: false,
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
|
// key is expected by test driver
|
||||||
key: Key('$routeName-tile'),
|
key: Key('$routeName-tile'),
|
||||||
leading: DrawerPageIcon(route: routeName),
|
leading: DrawerPageIcon(route: routeName),
|
||||||
title: DrawerPageTitle(route: routeName),
|
title: DrawerPageTitle(route: routeName),
|
||||||
|
|
|
@ -98,6 +98,7 @@ class _FilterGridAppBarState<T extends CollectionFilter> extends State<FilterGri
|
||||||
tooltip = MaterialLocalizations.of(context).openAppDrawerTooltip;
|
tooltip = MaterialLocalizations.of(context).openAppDrawerTooltip;
|
||||||
}
|
}
|
||||||
return IconButton(
|
return IconButton(
|
||||||
|
// key is expected by test driver
|
||||||
key: const Key('appbar-leading-button'),
|
key: const Key('appbar-leading-button'),
|
||||||
icon: AnimatedIcon(
|
icon: AnimatedIcon(
|
||||||
icon: AnimatedIcons.menu_arrow,
|
icon: AnimatedIcons.menu_arrow,
|
||||||
|
@ -170,7 +171,6 @@ class _FilterGridAppBarState<T extends CollectionFilter> extends State<FilterGri
|
||||||
...buttonActions,
|
...buttonActions,
|
||||||
MenuIconTheme(
|
MenuIconTheme(
|
||||||
child: PopupMenuButton<ChipSetAction>(
|
child: PopupMenuButton<ChipSetAction>(
|
||||||
key: const Key('appbar-menu-button'),
|
|
||||||
itemBuilder: (context) {
|
itemBuilder: (context) {
|
||||||
final selectedItems = selection.selectedItems;
|
final selectedItems = selection.selectedItems;
|
||||||
final hasSelection = selectedItems.isNotEmpty;
|
final hasSelection = selectedItems.isNotEmpty;
|
||||||
|
|
|
@ -90,6 +90,8 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
|
||||||
child: AnimatedBuilder(
|
child: AnimatedBuilder(
|
||||||
animation: covers,
|
animation: covers,
|
||||||
builder: (context, child) => FilterGrid<T>(
|
builder: (context, child) => FilterGrid<T>(
|
||||||
|
// key is expected by test driver
|
||||||
|
key: const Key('filter-grid'),
|
||||||
settingsRouteKey: settingsRouteKey,
|
settingsRouteKey: settingsRouteKey,
|
||||||
appBar: appBar,
|
appBar: appBar,
|
||||||
appBarHeight: appBarHeight,
|
appBarHeight: appBarHeight,
|
||||||
|
|
|
@ -39,7 +39,6 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
|
||||||
return SelectionProvider<FilterGridItem<T>>(
|
return SelectionProvider<FilterGridItem<T>>(
|
||||||
child: Builder(
|
child: Builder(
|
||||||
builder: (context) => FilterGridPage<T>(
|
builder: (context) => FilterGridPage<T>(
|
||||||
key: const Key('filter-grid-page'),
|
|
||||||
appBar: FilterGridAppBar<T>(
|
appBar: FilterGridAppBar<T>(
|
||||||
source: source,
|
source: source,
|
||||||
title: title,
|
title: title,
|
||||||
|
|
|
@ -17,6 +17,7 @@ class CollectionSearchButton extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return IconButton(
|
return IconButton(
|
||||||
|
// key is expected by test driver
|
||||||
key: const Key('search-button'),
|
key: const Key('search-button'),
|
||||||
icon: const Icon(AIcons.search),
|
icon: const Icon(AIcons.search),
|
||||||
onPressed: () => _goToSearch(context),
|
onPressed: () => _goToSearch(context),
|
||||||
|
|
|
@ -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/language/language.dart';
|
||||||
import 'package:aves/widgets/settings/navigation/navigation.dart';
|
import 'package:aves/widgets/settings/navigation/navigation.dart';
|
||||||
import 'package:aves/widgets/settings/privacy/privacy.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/video/video.dart';
|
||||||
import 'package:aves/widgets/settings/viewer/viewer.dart';
|
import 'package:aves/widgets/settings/viewer/viewer.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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/extensions/build_context.dart';
|
||||||
import 'package:aves/widgets/common/identity/aves_expansion_tile.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/common/tile_leading.dart';
|
||||||
|
import 'package:aves/widgets/settings/thumbnails/selection_actions_editor.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
@ -34,6 +35,7 @@ class ThumbnailsSection extends StatelessWidget {
|
||||||
expandedNotifier: expandedNotifier,
|
expandedNotifier: expandedNotifier,
|
||||||
showHighlight: false,
|
showHighlight: false,
|
||||||
children: [
|
children: [
|
||||||
|
const SelectionActionsTile(),
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
value: currentShowThumbnailLocation,
|
value: currentShowThumbnailLocation,
|
||||||
onChanged: (v) => settings.showThumbnailLocation = v,
|
onChanged: (v) => settings.showThumbnailLocation = v,
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/metadata.dart';
|
import 'package:aves/model/metadata/address.dart';
|
||||||
|
import 'package:aves/model/metadata/catalog.dart';
|
||||||
import 'package:aves/services/services.dart';
|
import 'package:aves/services/services.dart';
|
||||||
import 'package:aves/widgets/viewer/info/common.dart';
|
import 'package:aves/widgets/viewer/info/common.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
|
@ -18,7 +19,7 @@ class DbTab extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DbTabState extends State<DbTab> {
|
class _DbTabState extends State<DbTab> {
|
||||||
late Future<DateMetadata?> _dbDateLoader;
|
late Future<int?> _dbDateLoader;
|
||||||
late Future<AvesEntry?> _dbEntryLoader;
|
late Future<AvesEntry?> _dbEntryLoader;
|
||||||
late Future<CatalogMetadata?> _dbMetadataLoader;
|
late Future<CatalogMetadata?> _dbMetadataLoader;
|
||||||
late Future<AddressDetails?> _dbAddressLoader;
|
late Future<AddressDetails?> _dbAddressLoader;
|
||||||
|
@ -33,7 +34,7 @@ class _DbTabState extends State<DbTab> {
|
||||||
|
|
||||||
void _loadDatabase() {
|
void _loadDatabase() {
|
||||||
final contentId = entry.contentId;
|
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));
|
_dbEntryLoader = metadataDb.loadEntries().then((values) => values.firstWhereOrNull((row) => row.contentId == contentId));
|
||||||
_dbMetadataLoader = metadataDb.loadMetadataEntries().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));
|
_dbAddressLoader = metadataDb.loadAddresses().then((values) => values.firstWhereOrNull((row) => row.contentId == contentId));
|
||||||
|
@ -45,7 +46,7 @@ class _DbTabState extends State<DbTab> {
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
FutureBuilder<DateMetadata?>(
|
FutureBuilder<int?>(
|
||||||
future: _dbDateLoader,
|
future: _dbDateLoader,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.hasError) return Text(snapshot.error.toString());
|
if (snapshot.hasError) return Text(snapshot.error.toString());
|
||||||
|
@ -58,7 +59,7 @@ class _DbTabState extends State<DbTab> {
|
||||||
if (data != null)
|
if (data != null)
|
||||||
InfoRowGroup(
|
InfoRowGroup(
|
||||||
info: {
|
info: {
|
||||||
'dateMillis': '${data.dateMillis}',
|
'dateMillis': '$data',
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
@ -22,7 +22,7 @@ class MetadataTab extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MetadataTabState extends State<MetadataTab> {
|
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
|
// MediaStore timestamp keys
|
||||||
static const secondTimestampKeys = ['date_added', 'date_modified', 'date_expires', 'isPlayed'];
|
static const secondTimestampKeys = ['date_added', 'date_modified', 'date_expires', 'isPlayed'];
|
||||||
|
@ -42,6 +42,7 @@ class _MetadataTabState extends State<MetadataTab> {
|
||||||
_exifInterfaceMetadataLoader = AndroidDebugService.getExifInterfaceMetadata(entry);
|
_exifInterfaceMetadataLoader = AndroidDebugService.getExifInterfaceMetadata(entry);
|
||||||
_mediaMetadataLoader = AndroidDebugService.getMediaMetadataRetrieverMetadata(entry);
|
_mediaMetadataLoader = AndroidDebugService.getMediaMetadataRetrieverMetadata(entry);
|
||||||
_metadataExtractorLoader = AndroidDebugService.getMetadataExtractorSummary(entry);
|
_metadataExtractorLoader = AndroidDebugService.getMetadataExtractorSummary(entry);
|
||||||
|
_pixyMetaLoader = AndroidDebugService.getPixyMetadata(entry);
|
||||||
_tiffStructureLoader = AndroidDebugService.getTiffStructure(entry);
|
_tiffStructureLoader = AndroidDebugService.getTiffStructure(entry);
|
||||||
setState(() {});
|
setState(() {});
|
||||||
}
|
}
|
||||||
|
@ -107,6 +108,10 @@ class _MetadataTabState extends State<MetadataTab> {
|
||||||
future: _metadataExtractorLoader,
|
future: _metadataExtractorLoader,
|
||||||
builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Metadata Extractor'),
|
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)
|
if (entry.mimeType == MimeTypes.tiff)
|
||||||
FutureBuilder<Map>(
|
FutureBuilder<Map>(
|
||||||
future: _tiffStructureLoader,
|
future: _tiffStructureLoader,
|
||||||
|
|
|
@ -38,6 +38,7 @@ class _MultiEntryScrollerState extends State<MultiEntryScroller> with AutomaticK
|
||||||
return MagnifierGestureDetectorScope(
|
return MagnifierGestureDetectorScope(
|
||||||
axis: const [Axis.horizontal, Axis.vertical],
|
axis: const [Axis.horizontal, Axis.vertical],
|
||||||
child: PageView.builder(
|
child: PageView.builder(
|
||||||
|
// key is expected by test driver
|
||||||
key: const Key('horizontal-pageview'),
|
key: const Key('horizontal-pageview'),
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
controller: pageController,
|
controller: pageController,
|
||||||
|
@ -81,6 +82,7 @@ class _MultiEntryScrollerState extends State<MultiEntryScroller> with AutomaticK
|
||||||
|
|
||||||
Widget _buildViewer(AvesEntry mainEntry, {AvesEntry? pageEntry}) {
|
Widget _buildViewer(AvesEntry mainEntry, {AvesEntry? pageEntry}) {
|
||||||
return EntryPageView(
|
return EntryPageView(
|
||||||
|
// key is expected by test driver
|
||||||
key: const Key('imageview'),
|
key: const Key('imageview'),
|
||||||
mainEntry: mainEntry,
|
mainEntry: mainEntry,
|
||||||
pageEntry: pageEntry ?? mainEntry,
|
pageEntry: pageEntry ?? mainEntry,
|
||||||
|
|
|
@ -131,6 +131,7 @@ class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
|
||||||
child: child,
|
child: child,
|
||||||
),
|
),
|
||||||
child: PageView(
|
child: PageView(
|
||||||
|
// key is expected by test driver
|
||||||
key: const Key('vertical-pageview'),
|
key: const Key('vertical-pageview'),
|
||||||
scrollDirection: Axis.vertical,
|
scrollDirection: Axis.vertical,
|
||||||
controller: widget.verticalPager,
|
controller: widget.verticalPager,
|
||||||
|
|
|
@ -8,6 +8,7 @@ import 'package:aves/model/filters/tag.dart';
|
||||||
import 'package:aves/model/filters/type.dart';
|
import 'package:aves/model/filters/type.dart';
|
||||||
import 'package:aves/model/source/collection_lens.dart';
|
import 'package:aves/model/source/collection_lens.dart';
|
||||||
import 'package:aves/services/services.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/android_file_utils.dart';
|
||||||
import 'package:aves/utils/file_utils.dart';
|
import 'package:aves/utils/file_utils.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.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:collection/collection.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
|
|
||||||
class BasicSection extends StatelessWidget {
|
class BasicSection extends StatelessWidget {
|
||||||
final AvesEntry entry;
|
final AvesEntry entry;
|
||||||
|
@ -40,36 +40,41 @@ class BasicSection extends StatelessWidget {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
final infoUnknown = l10n.viewerInfoUnknown;
|
final infoUnknown = l10n.viewerInfoUnknown;
|
||||||
final date = entry.bestDate;
|
|
||||||
final locale = l10n.localeName;
|
final locale = l10n.localeName;
|
||||||
final dateText = date != null ? '${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
|
return AnimatedBuilder(
|
||||||
// inserting ZWSP (\u200B) between characters does help, but it messes with width and height computation (another Flutter issue)
|
animation: entry.metadataChangeNotifier,
|
||||||
final title = entry.bestTitle ?? infoUnknown;
|
builder: (context, child) {
|
||||||
final uri = entry.uri;
|
// TODO TLAD line break on all characters for the following fields when this is fixed: https://github.com/flutter/flutter/issues/61081
|
||||||
final path = entry.path;
|
// inserting ZWSP (\u200B) between characters does help, but it messes with width and height computation (another Flutter issue)
|
||||||
|
final title = entry.bestTitle ?? infoUnknown;
|
||||||
|
final date = entry.bestDate;
|
||||||
|
final dateText = date != null ? formatDateTime(date, locale) : infoUnknown;
|
||||||
|
final showResolution = !entry.isSvg && entry.isSized;
|
||||||
|
final sizeText = entry.sizeBytes != null ? formatFilesize(entry.sizeBytes!) : infoUnknown;
|
||||||
|
final path = entry.path;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
InfoRowGroup(
|
InfoRowGroup(
|
||||||
info: {
|
info: {
|
||||||
l10n.viewerInfoLabelTitle: title,
|
l10n.viewerInfoLabelTitle: title,
|
||||||
l10n.viewerInfoLabelDate: dateText,
|
l10n.viewerInfoLabelDate: dateText,
|
||||||
if (entry.isVideo) ..._buildVideoRows(context),
|
if (entry.isVideo) ..._buildVideoRows(context),
|
||||||
if (!entry.isSvg && entry.isSized) l10n.viewerInfoLabelResolution: rasterResolutionText,
|
if (showResolution) l10n.viewerInfoLabelResolution: rasterResolutionText,
|
||||||
l10n.viewerInfoLabelSize: entry.sizeBytes != null ? formatFilesize(entry.sizeBytes!) : infoUnknown,
|
l10n.viewerInfoLabelSize: sizeText,
|
||||||
l10n.viewerInfoLabelUri: uri,
|
l10n.viewerInfoLabelUri: entry.uri,
|
||||||
if (path != null) l10n.viewerInfoLabelPath: path,
|
if (path != null) l10n.viewerInfoLabelPath: path,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
OwnerProp(
|
OwnerProp(
|
||||||
entry: entry,
|
entry: entry,
|
||||||
),
|
),
|
||||||
_buildChips(context),
|
_buildChips(context),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildChips(BuildContext context) {
|
Widget _buildChips(BuildContext context) {
|
||||||
|
|
|
@ -1,12 +1,20 @@
|
||||||
|
import 'package:aves/model/actions/entry_info_actions.dart';
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
|
import 'package:aves/model/metadata/date_modifier.dart';
|
||||||
|
import 'package:aves/theme/durations.dart';
|
||||||
import 'package:aves/theme/icons.dart';
|
import 'package:aves/theme/icons.dart';
|
||||||
|
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
||||||
|
import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
|
||||||
import 'package:aves/widgets/common/app_bar_title.dart';
|
import 'package:aves/widgets/common/app_bar_title.dart';
|
||||||
|
import 'package:aves/widgets/common/basic/menu.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
|
import 'package:aves/widgets/dialogs/edit_entry_date_dialog.dart';
|
||||||
import 'package:aves/widgets/viewer/info/info_search.dart';
|
import 'package:aves/widgets/viewer/info/info_search.dart';
|
||||||
import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart';
|
import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/scheduler.dart';
|
||||||
|
|
||||||
class InfoAppBar extends StatelessWidget {
|
class InfoAppBar extends StatelessWidget with FeedbackMixin, PermissionAwareMixin {
|
||||||
final AvesEntry entry;
|
final AvesEntry entry;
|
||||||
final ValueNotifier<Map<String, MetadataDirectory>> metadataNotifier;
|
final ValueNotifier<Map<String, MetadataDirectory>> metadataNotifier;
|
||||||
final VoidCallback onBackPressed;
|
final VoidCallback onBackPressed;
|
||||||
|
@ -22,6 +30,7 @@ class InfoAppBar extends StatelessWidget {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SliverAppBar(
|
return SliverAppBar(
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
|
// key is expected by test driver
|
||||||
key: const Key('back-button'),
|
key: const Key('back-button'),
|
||||||
icon: const Icon(AIcons.goUp),
|
icon: const Icon(AIcons.goUp),
|
||||||
onPressed: onBackPressed,
|
onPressed: onBackPressed,
|
||||||
|
@ -37,6 +46,23 @@ class InfoAppBar extends StatelessWidget {
|
||||||
onPressed: () => _goToSearch(context),
|
onPressed: () => _goToSearch(context),
|
||||||
tooltip: MaterialLocalizations.of(context).searchFieldLabel,
|
tooltip: MaterialLocalizations.of(context).searchFieldLabel,
|
||||||
),
|
),
|
||||||
|
MenuIconTheme(
|
||||||
|
child: PopupMenuButton<EntryInfoAction>(
|
||||||
|
itemBuilder: (context) {
|
||||||
|
return [
|
||||||
|
PopupMenuItem(
|
||||||
|
value: EntryInfoAction.editDate,
|
||||||
|
enabled: entry.canEditExif,
|
||||||
|
child: MenuRow(text: context.l10n.entryInfoActionEditDate, icon: const Icon(AIcons.date)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
onSelected: (action) {
|
||||||
|
// wait for the popup menu to hide before proceeding with the action
|
||||||
|
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => _onActionSelected(context, action));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
titleSpacing: 0,
|
titleSpacing: 0,
|
||||||
floating: true,
|
floating: true,
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/metadata.dart';
|
import 'package:aves/model/metadata/overlay.dart';
|
||||||
import 'package:aves/model/multipage.dart';
|
import 'package:aves/model/multipage.dart';
|
||||||
import 'package:aves/model/settings/coordinate_format.dart';
|
import 'package:aves/model/settings/coordinate_format.dart';
|
||||||
import 'package:aves/model/settings/settings.dart';
|
import 'package:aves/model/settings/settings.dart';
|
||||||
import 'package:aves/services/services.dart';
|
import 'package:aves/services/services.dart';
|
||||||
import 'package:aves/theme/durations.dart';
|
import 'package:aves/theme/durations.dart';
|
||||||
|
import 'package:aves/theme/format.dart';
|
||||||
import 'package:aves/theme/icons.dart';
|
import 'package:aves/theme/icons.dart';
|
||||||
import 'package:aves/utils/constants.dart';
|
import 'package:aves/utils/constants.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.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:decorated_icon/decorated_icon.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:tuple/tuple.dart';
|
import 'package:tuple/tuple.dart';
|
||||||
|
|
||||||
|
@ -387,7 +387,7 @@ class _DateRow extends StatelessWidget {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final locale = context.l10n.localeName;
|
final locale = context.l10n.localeName;
|
||||||
final date = entry.bestDate;
|
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
|
final resolutionText = entry.isSvg
|
||||||
? entry.aspectRatioText
|
? entry.aspectRatioText
|
||||||
: entry.isSized
|
: entry.isSized
|
||||||
|
|
|
@ -5,9 +5,9 @@ import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/settings/settings.dart';
|
import 'package:aves/model/settings/settings.dart';
|
||||||
import 'package:aves/services/android_app_service.dart';
|
import 'package:aves/services/android_app_service.dart';
|
||||||
import 'package:aves/theme/durations.dart';
|
import 'package:aves/theme/durations.dart';
|
||||||
|
import 'package:aves/theme/format.dart';
|
||||||
import 'package:aves/theme/icons.dart';
|
import 'package:aves/theme/icons.dart';
|
||||||
import 'package:aves/utils/constants.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/basic/menu.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:aves/widgets/common/fx/blurred.dart';
|
import 'package:aves/widgets/common/fx/blurred.dart';
|
||||||
|
|
|
@ -109,6 +109,7 @@ class _WelcomePageState extends State<WelcomePage> {
|
||||||
text: context.l10n.welcomeCrashReportToggle,
|
text: context.l10n.welcomeCrashReportToggle,
|
||||||
),
|
),
|
||||||
LabeledCheckbox(
|
LabeledCheckbox(
|
||||||
|
// key is expected by test driver
|
||||||
key: const Key('agree-checkbox'),
|
key: const Key('agree-checkbox'),
|
||||||
value: _hasAcceptedTerms,
|
value: _hasAcceptedTerms,
|
||||||
onChanged: (v) {
|
onChanged: (v) {
|
||||||
|
@ -120,6 +121,7 @@ class _WelcomePageState extends State<WelcomePage> {
|
||||||
);
|
);
|
||||||
|
|
||||||
final button = ElevatedButton(
|
final button = ElevatedButton(
|
||||||
|
// key is expected by test driver
|
||||||
key: const Key('continue-button'),
|
key: const Key('continue-button'),
|
||||||
onPressed: _hasAcceptedTerms
|
onPressed: _hasAcceptedTerms
|
||||||
? () {
|
? () {
|
||||||
|
@ -165,6 +167,7 @@ class _WelcomePageState extends State<WelcomePage> {
|
||||||
child: Theme(
|
child: Theme(
|
||||||
data: Theme.of(context).copyWith(
|
data: Theme.of(context).copyWith(
|
||||||
scrollbarTheme: const ScrollbarThemeData(
|
scrollbarTheme: const ScrollbarThemeData(
|
||||||
|
isAlwaysShown: true,
|
||||||
radius: Radius.circular(16),
|
radius: Radius.circular(16),
|
||||||
crossAxisMargin: 6,
|
crossAxisMargin: 6,
|
||||||
mainAxisMargin: 16,
|
mainAxisMargin: 16,
|
||||||
|
|
59
pubspec.lock
59
pubspec.lock
|
@ -28,7 +28,7 @@ packages:
|
||||||
name: args
|
name: args
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.1"
|
version: "2.2.0"
|
||||||
async:
|
async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -105,14 +105,14 @@ packages:
|
||||||
name: connectivity_plus
|
name: connectivity_plus
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.6"
|
version: "1.0.7"
|
||||||
connectivity_plus_linux:
|
connectivity_plus_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: connectivity_plus_linux
|
name: connectivity_plus_linux
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.3"
|
version: "1.1.0"
|
||||||
connectivity_plus_macos:
|
connectivity_plus_macos:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -126,14 +126,14 @@ packages:
|
||||||
name: connectivity_plus_platform_interface
|
name: connectivity_plus_platform_interface
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.2"
|
version: "1.1.0"
|
||||||
connectivity_plus_web:
|
connectivity_plus_web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: connectivity_plus_web
|
name: connectivity_plus_web
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.2"
|
version: "1.1.0"
|
||||||
connectivity_plus_windows:
|
connectivity_plus_windows:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -182,7 +182,7 @@ packages:
|
||||||
name: dbus
|
name: dbus
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.5.4"
|
version: "0.5.6"
|
||||||
decorated_icon:
|
decorated_icon:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
@ -249,7 +249,7 @@ packages:
|
||||||
name: firebase_core
|
name: firebase_core
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.5.0"
|
||||||
firebase_core_platform_interface:
|
firebase_core_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -270,14 +270,14 @@ packages:
|
||||||
name: firebase_crashlytics
|
name: firebase_crashlytics
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.1"
|
version: "2.2.0"
|
||||||
firebase_crashlytics_platform_interface:
|
firebase_crashlytics_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: firebase_crashlytics_platform_interface
|
name: firebase_crashlytics_platform_interface
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.0"
|
version: "3.1.1"
|
||||||
flex_color_picker:
|
flex_color_picker:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
@ -341,7 +341,7 @@ packages:
|
||||||
name: flutter_markdown
|
name: flutter_markdown
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.2"
|
version: "0.6.4"
|
||||||
flutter_plugin_android_lifecycle:
|
flutter_plugin_android_lifecycle:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -412,7 +412,7 @@ packages:
|
||||||
name: google_maps_flutter_platform_interface
|
name: google_maps_flutter_platform_interface
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.0"
|
version: "2.1.1"
|
||||||
highlight:
|
highlight:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -560,6 +560,13 @@ packages:
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
version: "1.0.0"
|
||||||
|
nm:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: nm
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "0.3.0"
|
||||||
node_preamble:
|
node_preamble:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -587,7 +594,7 @@ packages:
|
||||||
name: package_info_plus
|
name: package_info_plus
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.4"
|
version: "1.0.5"
|
||||||
package_info_plus_linux:
|
package_info_plus_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -657,7 +664,7 @@ packages:
|
||||||
name: path_provider_linux
|
name: path_provider_linux
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.0.2"
|
||||||
path_provider_platform_interface:
|
path_provider_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -671,7 +678,7 @@ packages:
|
||||||
name: path_provider_windows
|
name: path_provider_windows
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.1"
|
version: "2.0.3"
|
||||||
pdf:
|
pdf:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
@ -699,14 +706,14 @@ packages:
|
||||||
name: permission_handler
|
name: permission_handler
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.1.2"
|
version: "8.1.4+2"
|
||||||
permission_handler_platform_interface:
|
permission_handler_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: permission_handler_platform_interface
|
name: permission_handler_platform_interface
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.6.0"
|
version: "3.6.1"
|
||||||
petitparser:
|
petitparser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -748,7 +755,7 @@ packages:
|
||||||
name: printing
|
name: printing
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.4.3"
|
version: "5.5.0"
|
||||||
process:
|
process:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -769,7 +776,7 @@ packages:
|
||||||
name: provider
|
name: provider
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.0.0"
|
version: "6.0.0"
|
||||||
pub_semver:
|
pub_semver:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -804,14 +811,14 @@ packages:
|
||||||
name: shared_preferences_linux
|
name: shared_preferences_linux
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.0.2"
|
||||||
shared_preferences_macos:
|
shared_preferences_macos:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: shared_preferences_macos
|
name: shared_preferences_macos
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.0.2"
|
||||||
shared_preferences_platform_interface:
|
shared_preferences_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -825,14 +832,14 @@ packages:
|
||||||
name: shared_preferences_web
|
name: shared_preferences_web
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.0.1"
|
||||||
shared_preferences_windows:
|
shared_preferences_windows:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: shared_preferences_windows
|
name: shared_preferences_windows
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.0.2"
|
||||||
shelf:
|
shelf:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -1021,14 +1028,14 @@ packages:
|
||||||
name: url_launcher_linux
|
name: url_launcher_linux
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.0.1"
|
||||||
url_launcher_macos:
|
url_launcher_macos:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_macos
|
name: url_launcher_macos
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.0.1"
|
||||||
url_launcher_platform_interface:
|
url_launcher_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -1042,14 +1049,14 @@ packages:
|
||||||
name: url_launcher_web
|
name: url_launcher_web
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.1"
|
version: "2.0.2"
|
||||||
url_launcher_windows:
|
url_launcher_windows:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_windows
|
name: url_launcher_windows
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.0.2"
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
name: aves
|
name: aves
|
||||||
description: A visual media gallery and metadata explorer app.
|
description: A visual media gallery and metadata explorer app.
|
||||||
repository: https://github.com/deckerst/aves
|
repository: https://github.com/deckerst/aves
|
||||||
version: 1.4.9+53
|
version: 1.5.0+54
|
||||||
publish_to: none
|
publish_to: none
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
|
|
File diff suppressed because one or more lines are too long
|
@ -2,7 +2,8 @@ import 'package:aves/model/covers.dart';
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/favourites.dart';
|
import 'package:aves/model/favourites.dart';
|
||||||
import 'package:aves/model/filters/filters.dart';
|
import 'package:aves/model/filters/filters.dart';
|
||||||
import 'package:aves/model/metadata.dart';
|
import 'package:aves/model/metadata/address.dart';
|
||||||
|
import 'package:aves/model/metadata/catalog.dart';
|
||||||
import 'package:aves/model/metadata_db.dart';
|
import 'package:aves/model/metadata_db.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
@ -24,7 +25,7 @@ class FakeMetadataDb extends Fake implements MetadataDb {
|
||||||
Future<void> updateEntryId(int oldId, AvesEntry entry) => SynchronousFuture(null);
|
Future<void> updateEntryId(int oldId, AvesEntry entry) => SynchronousFuture(null);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<DateMetadata>> loadDates() => SynchronousFuture([]);
|
Future<Map<int?, int?>> loadDates() => SynchronousFuture({});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<CatalogMetadata>> loadMetadataEntries() => SynchronousFuture([]);
|
Future<List<CatalogMetadata>> loadMetadataEntries() => SynchronousFuture([]);
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/metadata.dart';
|
import 'package:aves/model/metadata/catalog.dart';
|
||||||
import 'package:aves/services/metadata_service.dart';
|
import 'package:aves/services/metadata_service.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
|
@ -129,10 +129,11 @@ void selectFirstAlbum() {
|
||||||
// wait for collection loading
|
// wait for collection loading
|
||||||
await driver.waitForCondition(const NoPendingPlatformMessages());
|
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(
|
await driver.tap(find.descendant(
|
||||||
of: find.byValueKey('filter-grid-page'),
|
of: find.byValueKey('filter-grid'),
|
||||||
matching: find.byType('CoveredFilterChip'),
|
matching: find.byType('MetaData'),
|
||||||
firstMatchOnly: true,
|
firstMatchOnly: true,
|
||||||
));
|
));
|
||||||
await driver.waitUntilNoTransientCallbacks();
|
await driver.waitUntilNoTransientCallbacks();
|
||||||
|
@ -158,9 +159,11 @@ void searchAlbum() {
|
||||||
|
|
||||||
void showViewer() {
|
void showViewer() {
|
||||||
test('[collection] show viewer', () async {
|
test('[collection] show viewer', () async {
|
||||||
|
// delay to avoid flaky descendant resolution
|
||||||
|
await Future.delayed(const Duration(seconds: 2));
|
||||||
await driver.tap(find.descendant(
|
await driver.tap(find.descendant(
|
||||||
of: find.byValueKey('collection-grid'),
|
of: find.byValueKey('collection-grid'),
|
||||||
matching: find.byType('DecoratedThumbnail'),
|
matching: find.byType('MetaData'),
|
||||||
firstMatchOnly: true,
|
firstMatchOnly: true,
|
||||||
));
|
));
|
||||||
await driver.waitUntilNoTransientCallbacks();
|
await driver.waitUntilNoTransientCallbacks();
|
||||||
|
|
|
@ -3,10 +3,13 @@ import 'dart:io';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
String get adb {
|
String get adb {
|
||||||
final env = Platform.environment;
|
if (Platform.isWindows) {
|
||||||
// e.g. C:\Users\<username>\AppData\Local\Android\Sdk
|
final env = Platform.environment;
|
||||||
final sdkDir = env['ANDROID_SDK_ROOT'] ?? env['ANDROID_SDK']!;
|
// e.g. C:\Users\<username>\AppData\Local\Android\Sdk
|
||||||
return p.join(sdkDir, 'platform-tools', Platform.isWindows ? 'adb.exe' : 'adb');
|
final sdkDir = env['ANDROID_SDK_ROOT'] ?? env['ANDROID_SDK']!;
|
||||||
|
return p.join(sdkDir, 'platform-tools', 'adb.exe');
|
||||||
|
}
|
||||||
|
return 'adb';
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
Thanks for using Aves!
|
Thanks for using Aves!
|
||||||
v1.4.9:
|
v1.5.0:
|
||||||
- open the map or get stats for selected items
|
- faster launch
|
||||||
- browse and navigate to items on the map
|
- edit Exif dates
|
||||||
- customize the navigation menu
|
- customize quick actions when selecting pictures
|
||||||
- create shortcuts on Android Nougat and older
|
Full changelog available on GitHub
|
||||||
Full changelog available on Github
|
|
Loading…
Reference in a new issue