Merge branch 'develop'
This commit is contained in:
commit
440705ba25
34 changed files with 888 additions and 291 deletions
10
CHANGELOG.md
10
CHANGELOG.md
|
@ -3,6 +3,16 @@ All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [v1.3.1] - 2021-01-04
|
||||||
|
### Added
|
||||||
|
- Collection: long press and move to select/deselect multiple entries
|
||||||
|
- Info: show Spherical Video V1 metadata
|
||||||
|
- Info: metadata search
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Viewer: fixed panning inertia following double-tap scaling
|
||||||
|
- Collection: fixed crash when loading TIFF files on Android 11
|
||||||
|
|
||||||
## [v1.3.0] - 2020-12-26
|
## [v1.3.0] - 2020-12-26
|
||||||
### Added
|
### Added
|
||||||
- Viewer: quick scale (aka one finger zoom)
|
- Viewer: quick scale (aka one finger zoom)
|
||||||
|
|
|
@ -240,26 +240,29 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val metadataMap = HashMap<String, FieldMap>()
|
val metadataMap = HashMap<String, FieldMap>()
|
||||||
var dirCount: Int? = null
|
var fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd()
|
||||||
context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
|
if (fd == null) {
|
||||||
val options = TiffBitmapFactory.Options().apply {
|
result.error("getTiffStructure-fd", "failed to get file descriptor", null)
|
||||||
inJustDecodeBounds = true
|
return
|
||||||
}
|
|
||||||
TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
|
|
||||||
metadataMap["0"] = tiffOptionsToMap(options)
|
|
||||||
dirCount = options.outDirectoryCount
|
|
||||||
}
|
}
|
||||||
if (dirCount != null) {
|
var options = TiffBitmapFactory.Options().apply {
|
||||||
for (i in 1 until dirCount!!) {
|
inJustDecodeBounds = true
|
||||||
context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
|
}
|
||||||
val options = TiffBitmapFactory.Options().apply {
|
TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
||||||
inJustDecodeBounds = true
|
metadataMap["0"] = tiffOptionsToMap(options)
|
||||||
inDirectoryNumber = i
|
val dirCount = options.outDirectoryCount
|
||||||
}
|
for (i in 1 until dirCount) {
|
||||||
TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
|
fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd()
|
||||||
metadataMap["$i"] = tiffOptionsToMap(options)
|
if (fd == null) {
|
||||||
}
|
result.error("getTiffStructure-fd", "failed to get file descriptor", null)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
options = TiffBitmapFactory.Options().apply {
|
||||||
|
inJustDecodeBounds = true
|
||||||
|
inDirectoryNumber = i
|
||||||
|
}
|
||||||
|
TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
||||||
|
metadataMap["$i"] = tiffOptionsToMap(options)
|
||||||
}
|
}
|
||||||
result.success(metadataMap)
|
result.success(metadataMap)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|
|
@ -21,17 +21,15 @@ import com.drew.metadata.iptc.IptcDirectory
|
||||||
import com.drew.metadata.mp4.media.Mp4UuidBoxDirectory
|
import com.drew.metadata.mp4.media.Mp4UuidBoxDirectory
|
||||||
import com.drew.metadata.webp.WebpDirectory
|
import com.drew.metadata.webp.WebpDirectory
|
||||||
import com.drew.metadata.xmp.XmpDirectory
|
import com.drew.metadata.xmp.XmpDirectory
|
||||||
|
import deckers.thibault.aves.metadata.*
|
||||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper.describeAll
|
import deckers.thibault.aves.metadata.ExifInterfaceHelper.describeAll
|
||||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
|
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
|
||||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDouble
|
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDouble
|
||||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeInt
|
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeInt
|
||||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeRational
|
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeRational
|
||||||
import deckers.thibault.aves.metadata.Geotiff
|
|
||||||
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper
|
|
||||||
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDateMillis
|
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDateMillis
|
||||||
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDescription
|
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDescription
|
||||||
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeInt
|
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeInt
|
||||||
import deckers.thibault.aves.metadata.Metadata
|
|
||||||
import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode
|
import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode
|
||||||
import deckers.thibault.aves.metadata.Metadata.isFlippedForExifCode
|
import deckers.thibault.aves.metadata.Metadata.isFlippedForExifCode
|
||||||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeBoolean
|
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeBoolean
|
||||||
|
@ -40,7 +38,6 @@ import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt
|
||||||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeRational
|
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeRational
|
||||||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString
|
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString
|
||||||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.isGeoTiff
|
import deckers.thibault.aves.metadata.MetadataExtractorHelper.isGeoTiff
|
||||||
import deckers.thibault.aves.metadata.XMP
|
|
||||||
import deckers.thibault.aves.metadata.XMP.getSafeDateMillis
|
import deckers.thibault.aves.metadata.XMP.getSafeDateMillis
|
||||||
import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText
|
import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText
|
||||||
import deckers.thibault.aves.metadata.XMP.isPanorama
|
import deckers.thibault.aves.metadata.XMP.isPanorama
|
||||||
|
@ -142,6 +139,14 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
// remove this stat as it is not actual XMP data
|
// remove this stat as it is not actual XMP data
|
||||||
dirMap.remove(dir.getTagName(XmpDirectory.TAG_XMP_VALUE_COUNT))
|
dirMap.remove(dir.getTagName(XmpDirectory.TAG_XMP_VALUE_COUNT))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dir is Mp4UuidBoxDirectory) {
|
||||||
|
if (dir.getString(Mp4UuidBoxDirectory.TAG_UUID) == GSpherical.SPHERICAL_VIDEO_V1_UUID) {
|
||||||
|
val bytes = dir.getByteArray(Mp4UuidBoxDirectory.TAG_USER_DATA)
|
||||||
|
metadataMap["Spherical Video"] = HashMap(GSpherical(bytes).describe())
|
||||||
|
metadataMap.remove(dirName)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
@ -348,7 +353,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
// identification of spherical video (aka 360° video)
|
// identification of spherical video (aka 360° video)
|
||||||
if (metadata.getDirectoriesOfType(Mp4UuidBoxDirectory::class.java).any {
|
if (metadata.getDirectoriesOfType(Mp4UuidBoxDirectory::class.java).any {
|
||||||
it.getString(Mp4UuidBoxDirectory.TAG_UUID) == Metadata.SPHERICAL_VIDEO_V1_UUID
|
it.getString(Mp4UuidBoxDirectory.TAG_UUID) == GSpherical.SPHERICAL_VIDEO_V1_UUID
|
||||||
}) {
|
}) {
|
||||||
flags = flags or MASK_IS_360
|
flags = flags or MASK_IS_360
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,20 +18,22 @@ class TiffRegionFetcher internal constructor(
|
||||||
page: Int = 0,
|
page: Int = 0,
|
||||||
result: MethodChannel.Result,
|
result: MethodChannel.Result,
|
||||||
) {
|
) {
|
||||||
val resolver = context.contentResolver
|
|
||||||
try {
|
try {
|
||||||
resolver.openFileDescriptor(uri, "r")?.use { descriptor ->
|
val fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd()
|
||||||
val options = TiffBitmapFactory.Options().apply {
|
if (fd == null) {
|
||||||
inDirectoryNumber = page
|
result.error("getRegion-tiff-fd", "failed to get file descriptor for uri=$uri", null)
|
||||||
inSampleSize = sampleSize
|
return
|
||||||
inDecodeArea = DecodeArea(regionRect.left, regionRect.top, regionRect.width(), regionRect.height())
|
}
|
||||||
}
|
val options = TiffBitmapFactory.Options().apply {
|
||||||
val bitmap = TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
|
inDirectoryNumber = page
|
||||||
if (bitmap != null) {
|
inSampleSize = sampleSize
|
||||||
result.success(bitmap.getBytes(canHaveAlpha = true, recycle = true))
|
inDecodeArea = DecodeArea(regionRect.left, regionRect.top, regionRect.width(), regionRect.height())
|
||||||
} else {
|
}
|
||||||
result.error("getRegion-tiff-null", "failed to decode region for uri=$uri page=$page regionRect=$regionRect", null)
|
val bitmap = TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
||||||
}
|
if (bitmap != null) {
|
||||||
|
result.success(bitmap.getBytes(canHaveAlpha = true, recycle = true))
|
||||||
|
} else {
|
||||||
|
result.error("getRegion-tiff-null", "failed to decode region for uri=$uri page=$page regionRect=$regionRect", null)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
result.error("getRegion-tiff-read-exception", "failed to read from uri=$uri page=$page regionRect=$regionRect", e.message)
|
result.error("getRegion-tiff-read-exception", "failed to read from uri=$uri page=$page regionRect=$regionRect", e.message)
|
||||||
|
|
|
@ -139,28 +139,33 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
||||||
private fun streamTiffImage(uri: Uri, page: Int = 0) {
|
private fun streamTiffImage(uri: Uri, page: Int = 0) {
|
||||||
val resolver = activity.contentResolver
|
val resolver = activity.contentResolver
|
||||||
try {
|
try {
|
||||||
var dirCount = 0
|
var fd = resolver.openFileDescriptor(uri, "r")?.detachFd()
|
||||||
resolver.openFileDescriptor(uri, "r")?.use { descriptor ->
|
if (fd == null) {
|
||||||
val options = TiffBitmapFactory.Options().apply {
|
error("streamImage-tiff-fd", "failed to get file descriptor for uri=$uri", null)
|
||||||
inJustDecodeBounds = true
|
return
|
||||||
}
|
|
||||||
TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
|
|
||||||
dirCount = options.outDirectoryCount
|
|
||||||
}
|
}
|
||||||
|
var options = TiffBitmapFactory.Options().apply {
|
||||||
|
inJustDecodeBounds = true
|
||||||
|
}
|
||||||
|
TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
||||||
|
val dirCount = options.outDirectoryCount
|
||||||
|
|
||||||
// TODO TLAD handle multipage TIFF
|
// TODO TLAD handle multipage TIFF
|
||||||
if (dirCount > page) {
|
if (dirCount > page) {
|
||||||
resolver.openFileDescriptor(uri, "r")?.use { descriptor ->
|
fd = resolver.openFileDescriptor(uri, "r")?.detachFd()
|
||||||
val options = TiffBitmapFactory.Options().apply {
|
if (fd == null) {
|
||||||
inJustDecodeBounds = false
|
error("streamImage-tiff-fd", "failed to get file descriptor for uri=$uri", null)
|
||||||
inDirectoryNumber = page
|
return
|
||||||
}
|
}
|
||||||
val bitmap = TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
|
options = TiffBitmapFactory.Options().apply {
|
||||||
if (bitmap != null) {
|
inJustDecodeBounds = false
|
||||||
success(bitmap.getBytes(canHaveAlpha = true, recycle = true))
|
inDirectoryNumber = page
|
||||||
} else {
|
}
|
||||||
error("streamImage-tiff-null", "failed to get tiff image (dir=$page) from uri=$uri", null)
|
val bitmap = TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
||||||
}
|
if (bitmap != null) {
|
||||||
|
success(bitmap.getBytes(canHaveAlpha = true, recycle = true))
|
||||||
|
} else {
|
||||||
|
error("streamImage-tiff-null", "failed to get tiff image (dir=$page) from uri=$uri", null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|
|
@ -48,30 +48,35 @@ internal class TiffThumbnailFetcher(val model: TiffThumbnail, val width: Int, va
|
||||||
val uri = model.uri
|
val uri = model.uri
|
||||||
|
|
||||||
// determine sample size
|
// determine sample size
|
||||||
|
var fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd()
|
||||||
|
if (fd == null) {
|
||||||
|
callback.onLoadFailed(Exception("null file descriptor"))
|
||||||
|
return
|
||||||
|
}
|
||||||
var sampleSize = 1
|
var sampleSize = 1
|
||||||
context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
|
var options = TiffBitmapFactory.Options().apply {
|
||||||
val options = TiffBitmapFactory.Options().apply {
|
inJustDecodeBounds = true
|
||||||
inJustDecodeBounds = true
|
}
|
||||||
}
|
TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
||||||
TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
|
val imageWidth = options.outWidth
|
||||||
val imageWidth = options.outWidth
|
val imageHeight = options.outHeight
|
||||||
val imageHeight = options.outHeight
|
if (imageHeight > height || imageWidth > width) {
|
||||||
if (imageHeight > height || imageWidth > width) {
|
while (imageHeight / (sampleSize * 2) > height && imageWidth / (sampleSize * 2) > width) {
|
||||||
while (imageHeight / (sampleSize * 2) > height && imageWidth / (sampleSize * 2) > width) {
|
sampleSize *= 2
|
||||||
sampleSize *= 2
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// decode
|
// decode
|
||||||
val bitmap = context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
|
fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd()
|
||||||
val options = TiffBitmapFactory.Options().apply {
|
if (fd == null) {
|
||||||
inJustDecodeBounds = false
|
callback.onLoadFailed(Exception("null file descriptor"))
|
||||||
inSampleSize = sampleSize
|
return
|
||||||
}
|
|
||||||
TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
|
|
||||||
}
|
}
|
||||||
|
options = TiffBitmapFactory.Options().apply {
|
||||||
|
inJustDecodeBounds = false
|
||||||
|
inSampleSize = sampleSize
|
||||||
|
}
|
||||||
|
val bitmap = TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
||||||
if (bitmap == null) {
|
if (bitmap == null) {
|
||||||
callback.onLoadFailed(Exception("null bitmap"))
|
callback.onLoadFailed(Exception("null bitmap"))
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -19,9 +19,6 @@ object Metadata {
|
||||||
// "+51.3328-000.7053+113.474/" (Apple)
|
// "+51.3328-000.7053+113.474/" (Apple)
|
||||||
val VIDEO_LOCATION_PATTERN: Pattern = Pattern.compile("([+-][.0-9]+)([+-][.0-9]+).*")
|
val VIDEO_LOCATION_PATTERN: Pattern = Pattern.compile("([+-][.0-9]+)([+-][.0-9]+).*")
|
||||||
|
|
||||||
// cf https://github.com/google/spatial-media
|
|
||||||
const val SPHERICAL_VIDEO_V1_UUID = "ffcc8263-f855-4a93-8814-587a02521fdd"
|
|
||||||
|
|
||||||
// directory names, as shown when listing all metadata
|
// directory names, as shown when listing all metadata
|
||||||
const val DIR_GPS = "GPS" // from metadata-extractor
|
const val DIR_GPS = "GPS" // from metadata-extractor
|
||||||
const val DIR_XMP = "XMP" // from metadata-extractor
|
const val DIR_XMP = "XMP" // from metadata-extractor
|
||||||
|
|
|
@ -0,0 +1,109 @@
|
||||||
|
package deckers.thibault.aves.metadata
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import android.util.Xml
|
||||||
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
|
import org.xmlpull.v1.XmlPullParser
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
|
||||||
|
// `xmlBytes`: bytes representing the XML embedded in a MP4 `uuid` box, according to Spherical Video V1 spec
|
||||||
|
class GSpherical(xmlBytes: ByteArray) {
|
||||||
|
var spherical: Boolean = false
|
||||||
|
var stitched: Boolean = false
|
||||||
|
var stitchingSoftware: String = ""
|
||||||
|
var projectionType: String = ""
|
||||||
|
var stereoMode: String? = null
|
||||||
|
var sourceCount: Int? = null
|
||||||
|
var initialViewHeadingDegrees: Int? = null
|
||||||
|
var initialViewPitchDegrees: Int? = null
|
||||||
|
var initialViewRollDegrees: Int? = null
|
||||||
|
var timestamp: Int? = null
|
||||||
|
var fullPanoWidthPixels: Int? = null
|
||||||
|
var fullPanoHeightPixels: Int? = null
|
||||||
|
var croppedAreaImageWidthPixels: Int? = null
|
||||||
|
var croppedAreaImageHeightPixels: Int? = null
|
||||||
|
var croppedAreaLeftPixels: Int? = null
|
||||||
|
var croppedAreaTopPixels: Int? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
try {
|
||||||
|
ByteArrayInputStream(xmlBytes).use {
|
||||||
|
val parser = Xml.newPullParser().apply {
|
||||||
|
setInput(it, null)
|
||||||
|
nextTag()
|
||||||
|
require(XmlPullParser.START_TAG, RDF_NS, "SphericalVideo")
|
||||||
|
}
|
||||||
|
while (parser.next() != XmlPullParser.END_TAG) {
|
||||||
|
if (parser.eventType != XmlPullParser.START_TAG) continue
|
||||||
|
if (parser.namespace == GSPHERICAL_NS) {
|
||||||
|
when (val tag = parser.name) {
|
||||||
|
"Spherical" -> spherical = readTag(parser, tag) == "true"
|
||||||
|
"Stitched" -> stitched = readTag(parser, tag) == "true"
|
||||||
|
"StitchingSoftware" -> stitchingSoftware = readTag(parser, tag)
|
||||||
|
"ProjectionType" -> projectionType = readTag(parser, tag)
|
||||||
|
"StereoMode" -> stereoMode = readTag(parser, tag)
|
||||||
|
"SourceCount" -> sourceCount = Integer.parseInt(readTag(parser, tag))
|
||||||
|
"InitialViewHeadingDegrees" -> initialViewHeadingDegrees = Integer.parseInt(readTag(parser, tag))
|
||||||
|
"InitialViewPitchDegrees" -> initialViewPitchDegrees = Integer.parseInt(readTag(parser, tag))
|
||||||
|
"InitialViewRollDegrees" -> initialViewRollDegrees = Integer.parseInt(readTag(parser, tag))
|
||||||
|
"Timestamp" -> timestamp = Integer.parseInt(readTag(parser, tag))
|
||||||
|
"FullPanoWidthPixels" -> fullPanoWidthPixels = Integer.parseInt(readTag(parser, tag))
|
||||||
|
"FullPanoHeightPixels" -> fullPanoHeightPixels = Integer.parseInt(readTag(parser, tag))
|
||||||
|
"CroppedAreaImageWidthPixels" -> croppedAreaImageWidthPixels = Integer.parseInt(readTag(parser, tag))
|
||||||
|
"CroppedAreaImageHeightPixels" -> croppedAreaImageHeightPixels = Integer.parseInt(readTag(parser, tag))
|
||||||
|
"CroppedAreaLeftPixels" -> croppedAreaLeftPixels = Integer.parseInt(readTag(parser, tag))
|
||||||
|
"CroppedAreaTopPixels" -> croppedAreaTopPixels = Integer.parseInt(readTag(parser, tag))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(LOG_TAG, "failed to parse XML", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun describe(): Map<String, String?> = hashMapOf(
|
||||||
|
"Spherical" to spherical.toString(),
|
||||||
|
"Stitched" to stitched.toString(),
|
||||||
|
"Stitching Software" to stitchingSoftware,
|
||||||
|
"Projection Type" to projectionType,
|
||||||
|
"Stereo Mode" to stereoMode,
|
||||||
|
"Source Count" to sourceCount?.toString(),
|
||||||
|
"Initial View Heading Degrees" to initialViewHeadingDegrees?.toString(),
|
||||||
|
"Initial View Pitch Degrees" to initialViewPitchDegrees?.toString(),
|
||||||
|
"Initial View Roll Degrees" to initialViewRollDegrees?.toString(),
|
||||||
|
"Timestamp" to timestamp?.toString(),
|
||||||
|
"Full Panorama Width Pixels" to fullPanoWidthPixels?.toString(),
|
||||||
|
"Full Panorama Height Pixels" to fullPanoHeightPixels?.toString(),
|
||||||
|
"Cropped Area Image Width Pixels" to croppedAreaImageWidthPixels?.toString(),
|
||||||
|
"Cropped Area Image Height Pixels" to croppedAreaImageHeightPixels?.toString(),
|
||||||
|
"Cropped Area Left Pixels" to croppedAreaLeftPixels?.toString(),
|
||||||
|
"Cropped Area Top Pixels" to croppedAreaTopPixels?.toString(),
|
||||||
|
).filterValues { it != null }
|
||||||
|
|
||||||
|
companion object SphericalVideo {
|
||||||
|
private val LOG_TAG = LogUtils.createTag(SphericalVideo::class.java)
|
||||||
|
|
||||||
|
// cf https://github.com/google/spatial-media
|
||||||
|
const val SPHERICAL_VIDEO_V1_UUID = "ffcc8263-f855-4a93-8814-587a02521fdd"
|
||||||
|
|
||||||
|
const val RDF_NS = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
const val GSPHERICAL_NS = "http://ns.google.com/videos/1.0/spherical/"
|
||||||
|
|
||||||
|
private fun readText(parser: XmlPullParser): String {
|
||||||
|
var text = ""
|
||||||
|
if (parser.next() == XmlPullParser.TEXT) {
|
||||||
|
text = parser.text
|
||||||
|
parser.nextTag()
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readTag(parser: XmlPullParser, tag: String): String {
|
||||||
|
parser.require(XmlPullParser.START_TAG, GSPHERICAL_NS, tag)
|
||||||
|
val text = readText(parser)
|
||||||
|
parser.require(XmlPullParser.END_TAG, GSPHERICAL_NS, tag)
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -249,14 +249,13 @@ class SourceImageEntry {
|
||||||
|
|
||||||
private fun fillByTiffDecode(context: Context) {
|
private fun fillByTiffDecode(context: Context) {
|
||||||
try {
|
try {
|
||||||
context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
|
val fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd() ?: return
|
||||||
val options = TiffBitmapFactory.Options().apply {
|
val options = TiffBitmapFactory.Options().apply {
|
||||||
inJustDecodeBounds = true
|
inJustDecodeBounds = true
|
||||||
}
|
|
||||||
TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
|
|
||||||
width = options.outWidth
|
|
||||||
height = options.outHeight
|
|
||||||
}
|
}
|
||||||
|
TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
||||||
|
width = options.outWidth
|
||||||
|
height = options.outHeight
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
|
@ -172,7 +172,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
||||||
List<Widget> _buildActions() {
|
List<Widget> _buildActions() {
|
||||||
return [
|
return [
|
||||||
if (collection.isBrowsing)
|
if (collection.isBrowsing)
|
||||||
SearchButton(
|
CollectionSearchButton(
|
||||||
source,
|
source,
|
||||||
parentCollection: collection,
|
parentCollection: collection,
|
||||||
),
|
),
|
||||||
|
@ -361,7 +361,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
SearchPageRoute(
|
SearchPageRoute(
|
||||||
delegate: ImageSearchDelegate(
|
delegate: CollectionSearchDelegate(
|
||||||
source: collection.source,
|
source: collection.source,
|
||||||
parentCollection: collection,
|
parentCollection: collection,
|
||||||
),
|
),
|
||||||
|
|
|
@ -3,6 +3,7 @@ import 'dart:math';
|
||||||
import 'package:aves/model/image_entry.dart';
|
import 'package:aves/model/image_entry.dart';
|
||||||
import 'package:aves/model/source/collection_lens.dart';
|
import 'package:aves/model/source/collection_lens.dart';
|
||||||
import 'package:aves/widgets/collection/grid/header_generic.dart';
|
import 'package:aves/widgets/collection/grid/header_generic.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
@ -143,6 +144,25 @@ class SectionedListLayout {
|
||||||
final top = sectionLayout.indexToLayoutOffset(listIndex);
|
final top = sectionLayout.indexToLayoutOffset(listIndex);
|
||||||
return Rect.fromLTWH(left, top, tileExtent, tileExtent);
|
return Rect.fromLTWH(left, top, tileExtent, tileExtent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ImageEntry getEntryAt(Offset position) {
|
||||||
|
var dy = position.dy;
|
||||||
|
final sectionLayout = sectionLayouts.firstWhere((sl) => dy < sl.maxOffset, orElse: () => null);
|
||||||
|
if (sectionLayout == null) return null;
|
||||||
|
|
||||||
|
final section = collection.sections[sectionLayout.sectionKey];
|
||||||
|
if (section == null) return null;
|
||||||
|
|
||||||
|
dy -= sectionLayout.minOffset + sectionLayout.headerExtent;
|
||||||
|
if (dy < 0) return null;
|
||||||
|
|
||||||
|
final row = dy ~/ tileExtent;
|
||||||
|
final column = position.dx ~/ tileExtent;
|
||||||
|
final index = row * columnCount + column;
|
||||||
|
if (index >= section.length) return null;
|
||||||
|
|
||||||
|
return section[index];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class SectionLayout {
|
class SectionLayout {
|
||||||
|
@ -184,4 +204,7 @@ class SectionLayout {
|
||||||
scrollOffset -= minOffset + headerExtent;
|
scrollOffset -= minOffset + headerExtent;
|
||||||
return firstIndex + (scrollOffset < 0 ? 0 : (scrollOffset / tileExtent).ceil() - 1);
|
return firstIndex + (scrollOffset < 0 ? 0 : (scrollOffset / tileExtent).ceil() - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => '$runtimeType#${shortHash(this)}{sectionKey=$sectionKey, firstIndex=$firstIndex, lastIndex=$lastIndex, minOffset=$minOffset, maxOffset=$maxOffset, headerExtent=$headerExtent}';
|
||||||
}
|
}
|
||||||
|
|
|
@ -65,11 +65,6 @@ class GridThumbnail extends StatelessWidget {
|
||||||
ViewerService.pick(entry.uri);
|
ViewerService.pick(entry.uri);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onLongPress: () {
|
|
||||||
if (AvesApp.mode == AppMode.main) {
|
|
||||||
collection.toggleSelection(entry);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: MetaData(
|
child: MetaData(
|
||||||
metaData: ScalerMetadata(entry),
|
metaData: ScalerMetadata(entry),
|
||||||
child: DecoratedThumbnail(
|
child: DecoratedThumbnail(
|
||||||
|
|
169
lib/widgets/collection/grid/selector.dart
Normal file
169
lib/widgets/collection/grid/selector.dart
Normal file
|
@ -0,0 +1,169 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:aves/model/image_entry.dart';
|
||||||
|
import 'package:aves/model/source/collection_lens.dart';
|
||||||
|
import 'package:aves/utils/math_utils.dart';
|
||||||
|
import 'package:aves/widgets/collection/grid/list_section_layout.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
class GridSelectionGestureDetector extends StatefulWidget {
|
||||||
|
final bool selectable;
|
||||||
|
final CollectionLens collection;
|
||||||
|
final ScrollController scrollController;
|
||||||
|
final ValueNotifier<double> appBarHeightNotifier;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const GridSelectionGestureDetector({
|
||||||
|
this.selectable = true,
|
||||||
|
@required this.collection,
|
||||||
|
@required this.scrollController,
|
||||||
|
@required this.appBarHeightNotifier,
|
||||||
|
@required this.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
_GridSelectionGestureDetectorState createState() => _GridSelectionGestureDetectorState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetector> {
|
||||||
|
bool _pressing, _selecting;
|
||||||
|
int _fromIndex, _lastToIndex;
|
||||||
|
Offset _localPosition;
|
||||||
|
EdgeInsets _scrollableInsets;
|
||||||
|
double _scrollSpeedFactor;
|
||||||
|
Timer _updateTimer;
|
||||||
|
|
||||||
|
CollectionLens get collection => widget.collection;
|
||||||
|
|
||||||
|
List<ImageEntry> get entries => collection.sortedEntries;
|
||||||
|
|
||||||
|
ScrollController get scrollController => widget.scrollController;
|
||||||
|
|
||||||
|
double get appBarHeight => widget.appBarHeightNotifier.value;
|
||||||
|
|
||||||
|
static const double scrollEdgeRatio = .15;
|
||||||
|
static const double scrollMaxPixelPerSecond = 600.0;
|
||||||
|
static const Duration scrollUpdateInterval = Duration(milliseconds: 100);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onLongPressStart: widget.selectable
|
||||||
|
? (details) {
|
||||||
|
final fromEntry = _getEntryAt(details.localPosition);
|
||||||
|
if (fromEntry == null) return;
|
||||||
|
|
||||||
|
collection.toggleSelection(fromEntry);
|
||||||
|
_selecting = collection.isSelected([fromEntry]);
|
||||||
|
_fromIndex = entries.indexOf(fromEntry);
|
||||||
|
_lastToIndex = _fromIndex;
|
||||||
|
_scrollableInsets = EdgeInsets.only(
|
||||||
|
top: appBarHeight,
|
||||||
|
bottom: context.read<MediaQueryData>().viewInsets.bottom,
|
||||||
|
);
|
||||||
|
_scrollSpeedFactor = 0;
|
||||||
|
_pressing = true;
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
onLongPressMoveUpdate: widget.selectable
|
||||||
|
? (details) {
|
||||||
|
if (!_pressing) return;
|
||||||
|
_localPosition = details.localPosition;
|
||||||
|
_onLongPressUpdate();
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
onLongPressEnd: widget.selectable
|
||||||
|
? (details) {
|
||||||
|
if (!_pressing) return;
|
||||||
|
_setScrollSpeed(0);
|
||||||
|
_pressing = false;
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
child: widget.child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onLongPressUpdate() {
|
||||||
|
final dy = _localPosition.dy;
|
||||||
|
|
||||||
|
final height = scrollController.position.viewportDimension;
|
||||||
|
final top = dy < height / 2;
|
||||||
|
|
||||||
|
final distanceToEdge = max(0, top ? dy - _scrollableInsets.top : height - dy - _scrollableInsets.bottom);
|
||||||
|
final threshold = height * scrollEdgeRatio;
|
||||||
|
if (distanceToEdge < threshold) {
|
||||||
|
_setScrollSpeed((top ? -1 : 1) * roundToPrecision((threshold - distanceToEdge) / threshold, decimals: 1));
|
||||||
|
} else {
|
||||||
|
_setScrollSpeed(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
final toEntry = _getEntryAt(_localPosition);
|
||||||
|
_toggleSelectionToIndex(entries.indexOf(toEntry));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setScrollSpeed(double speedFactor) {
|
||||||
|
if (speedFactor == _scrollSpeedFactor) return;
|
||||||
|
_scrollSpeedFactor = speedFactor;
|
||||||
|
_updateTimer?.cancel();
|
||||||
|
|
||||||
|
final current = scrollController.offset;
|
||||||
|
if (speedFactor == 0) {
|
||||||
|
scrollController.jumpTo(current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final target = speedFactor > 0 ? scrollController.position.maxScrollExtent : .0;
|
||||||
|
if (target != current) {
|
||||||
|
final distance = target - current;
|
||||||
|
final millis = distance * 1000 / scrollMaxPixelPerSecond / speedFactor;
|
||||||
|
scrollController.animateTo(
|
||||||
|
target,
|
||||||
|
duration: Duration(milliseconds: millis.round()),
|
||||||
|
curve: Curves.linear,
|
||||||
|
);
|
||||||
|
// use a timer to update the entry selection, because `onLongPressMoveUpdate`
|
||||||
|
// is not called when the pointer stays still while the view is scrolling
|
||||||
|
_updateTimer = Timer.periodic(scrollUpdateInterval, (_) => _onLongPressUpdate());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ImageEntry _getEntryAt(Offset localPosition) {
|
||||||
|
// as of Flutter v1.22.5, `hitTest` on the `ScrollView` render object works fine when it is static,
|
||||||
|
// but when it is scrolling (through controller animation), result is incomplete and children are missing,
|
||||||
|
// so we use custom layout computation instead to find the entry.
|
||||||
|
final offset = Offset(0, scrollController.offset - appBarHeight) + localPosition;
|
||||||
|
return context.read<SectionedListLayout>().getEntryAt(offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleSelectionToIndex(int toIndex) {
|
||||||
|
if (toIndex == -1) return;
|
||||||
|
|
||||||
|
if (_selecting) {
|
||||||
|
if (toIndex <= _fromIndex) {
|
||||||
|
if (toIndex < _lastToIndex) {
|
||||||
|
collection.addToSelection(entries.getRange(toIndex, min(_fromIndex, _lastToIndex)));
|
||||||
|
if (_fromIndex < _lastToIndex) {
|
||||||
|
collection.removeFromSelection(entries.getRange(_fromIndex + 1, _lastToIndex + 1));
|
||||||
|
}
|
||||||
|
} else if (_lastToIndex < toIndex) {
|
||||||
|
collection.removeFromSelection(entries.getRange(_lastToIndex, toIndex));
|
||||||
|
}
|
||||||
|
} else if (_fromIndex < toIndex) {
|
||||||
|
if (_lastToIndex < toIndex) {
|
||||||
|
collection.addToSelection(entries.getRange(max(_fromIndex, _lastToIndex), toIndex + 1));
|
||||||
|
if (_lastToIndex < _fromIndex) {
|
||||||
|
collection.removeFromSelection(entries.getRange(_lastToIndex, _fromIndex));
|
||||||
|
}
|
||||||
|
} else if (toIndex < _lastToIndex) {
|
||||||
|
collection.removeFromSelection(entries.getRange(toIndex + 1, _lastToIndex + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_lastToIndex = toIndex;
|
||||||
|
} else {
|
||||||
|
collection.removeFromSelection(entries.getRange(min(_fromIndex, toIndex), max(_fromIndex, toIndex) + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,6 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:aves/main.dart';
|
||||||
import 'package:aves/model/filters/favourite.dart';
|
import 'package:aves/model/filters/favourite.dart';
|
||||||
import 'package:aves/model/filters/mime.dart';
|
import 'package:aves/model/filters/mime.dart';
|
||||||
import 'package:aves/model/highlight.dart';
|
import 'package:aves/model/highlight.dart';
|
||||||
|
@ -13,6 +14,7 @@ import 'package:aves/widgets/collection/app_bar.dart';
|
||||||
import 'package:aves/widgets/collection/empty.dart';
|
import 'package:aves/widgets/collection/empty.dart';
|
||||||
import 'package:aves/widgets/collection/grid/list_section_layout.dart';
|
import 'package:aves/widgets/collection/grid/list_section_layout.dart';
|
||||||
import 'package:aves/widgets/collection/grid/list_sliver.dart';
|
import 'package:aves/widgets/collection/grid/list_sliver.dart';
|
||||||
|
import 'package:aves/widgets/collection/grid/selector.dart';
|
||||||
import 'package:aves/widgets/collection/thumbnail/decorated.dart';
|
import 'package:aves/widgets/collection/thumbnail/decorated.dart';
|
||||||
import 'package:aves/widgets/common/behaviour/routes.dart';
|
import 'package:aves/widgets/common/behaviour/routes.dart';
|
||||||
import 'package:aves/widgets/common/behaviour/sloppy_scroll_physics.dart';
|
import 'package:aves/widgets/common/behaviour/sloppy_scroll_physics.dart';
|
||||||
|
@ -53,6 +55,7 @@ class ThumbnailCollection extends StatelessWidget {
|
||||||
spacing: spacing,
|
spacing: spacing,
|
||||||
)..applyTileExtent(viewportSize: viewportSize);
|
)..applyTileExtent(viewportSize: viewportSize);
|
||||||
final cacheExtent = tileExtentManager.getEffectiveExtentMax(viewportSize) * 2;
|
final cacheExtent = tileExtentManager.getEffectiveExtentMax(viewportSize) * 2;
|
||||||
|
final scrollController = PrimaryScrollController.of(context);
|
||||||
|
|
||||||
// do not replace by Provider.of<CollectionLens>
|
// do not replace by Provider.of<CollectionLens>
|
||||||
// so that view updates on collection filter changes
|
// so that view updates on collection filter changes
|
||||||
|
@ -67,7 +70,7 @@ class ThumbnailCollection extends StatelessWidget {
|
||||||
),
|
),
|
||||||
appBarHeightNotifier: _appBarHeightNotifier,
|
appBarHeightNotifier: _appBarHeightNotifier,
|
||||||
isScrollingNotifier: _isScrollingNotifier,
|
isScrollingNotifier: _isScrollingNotifier,
|
||||||
scrollController: PrimaryScrollController.of(context),
|
scrollController: scrollController,
|
||||||
cacheExtent: cacheExtent,
|
cacheExtent: cacheExtent,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -102,6 +105,14 @@ class ThumbnailCollection extends StatelessWidget {
|
||||||
child: scrollView,
|
child: scrollView,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final selector = GridSelectionGestureDetector(
|
||||||
|
selectable: AvesApp.mode == AppMode.main,
|
||||||
|
collection: collection,
|
||||||
|
scrollController: scrollController,
|
||||||
|
appBarHeightNotifier: _appBarHeightNotifier,
|
||||||
|
child: scaler,
|
||||||
|
);
|
||||||
|
|
||||||
final sectionedListLayoutProvider = ValueListenableBuilder<double>(
|
final sectionedListLayoutProvider = ValueListenableBuilder<double>(
|
||||||
valueListenable: _tileExtentNotifier,
|
valueListenable: _tileExtentNotifier,
|
||||||
builder: (context, tileExtent, child) => SectionedListLayoutProvider(
|
builder: (context, tileExtent, child) => SectionedListLayoutProvider(
|
||||||
|
@ -116,7 +127,7 @@ class ThumbnailCollection extends StatelessWidget {
|
||||||
tileExtent: tileExtent,
|
tileExtent: tileExtent,
|
||||||
isScrollingNotifier: _isScrollingNotifier,
|
isScrollingNotifier: _isScrollingNotifier,
|
||||||
),
|
),
|
||||||
child: scaler,
|
child: selector,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return sectionedListLayoutProvider;
|
return sectionedListLayoutProvider;
|
||||||
|
|
79
lib/widgets/common/basic/query_bar.dart
Normal file
79
lib/widgets/common/basic/query_bar.dart
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
import 'package:aves/theme/durations.dart';
|
||||||
|
import 'package:aves/theme/icons.dart';
|
||||||
|
import 'package:aves/utils/debouncer.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
class QueryBar extends StatefulWidget {
|
||||||
|
final ValueNotifier<String> filterNotifier;
|
||||||
|
|
||||||
|
const QueryBar({@required this.filterNotifier});
|
||||||
|
|
||||||
|
@override
|
||||||
|
_QueryBarState createState() => _QueryBarState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QueryBarState extends State<QueryBar> {
|
||||||
|
final Debouncer _debouncer = Debouncer(delay: Durations.searchDebounceDelay);
|
||||||
|
TextEditingController _controller;
|
||||||
|
|
||||||
|
ValueNotifier<String> get filterNotifier => widget.filterNotifier;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = TextEditingController(text: filterNotifier.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final clearButton = IconButton(
|
||||||
|
icon: Icon(AIcons.clear),
|
||||||
|
onPressed: () {
|
||||||
|
_controller.clear();
|
||||||
|
filterNotifier.value = '';
|
||||||
|
},
|
||||||
|
tooltip: 'Clear',
|
||||||
|
);
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _controller,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
icon: Padding(
|
||||||
|
padding: EdgeInsetsDirectional.only(start: 16),
|
||||||
|
child: Icon(AIcons.search),
|
||||||
|
),
|
||||||
|
hintText: MaterialLocalizations.of(context).searchFieldLabel,
|
||||||
|
hintStyle: Theme.of(context).inputDecorationTheme.hintStyle,
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.search,
|
||||||
|
onChanged: (s) => _debouncer(() => filterNotifier.value = s),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(minWidth: 16),
|
||||||
|
child: ValueListenableBuilder<TextEditingValue>(
|
||||||
|
valueListenable: _controller,
|
||||||
|
builder: (context, value, child) => AnimatedSwitcher(
|
||||||
|
duration: Durations.appBarActionChangeAnimation,
|
||||||
|
transitionBuilder: (child, animation) => FadeTransition(
|
||||||
|
opacity: animation,
|
||||||
|
child: SizeTransition(
|
||||||
|
axis: Axis.horizontal,
|
||||||
|
sizeFactor: animation,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: value.text.isNotEmpty ? clearButton : SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,13 +5,15 @@ import 'package:flutter/material.dart';
|
||||||
class AvesExpansionTile extends StatelessWidget {
|
class AvesExpansionTile extends StatelessWidget {
|
||||||
final String title;
|
final String title;
|
||||||
final Color color;
|
final Color color;
|
||||||
final List<Widget> children;
|
|
||||||
final ValueNotifier<String> expandedNotifier;
|
final ValueNotifier<String> expandedNotifier;
|
||||||
|
final bool initiallyExpanded;
|
||||||
|
final List<Widget> children;
|
||||||
|
|
||||||
const AvesExpansionTile({
|
const AvesExpansionTile({
|
||||||
@required this.title,
|
@required this.title,
|
||||||
this.color,
|
this.color,
|
||||||
this.expandedNotifier,
|
this.expandedNotifier,
|
||||||
|
this.initiallyExpanded = false,
|
||||||
@required this.children,
|
@required this.children,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -33,6 +35,9 @@ class AvesExpansionTile extends StatelessWidget {
|
||||||
enabled: enabled,
|
enabled: enabled,
|
||||||
),
|
),
|
||||||
expandable: enabled,
|
expandable: enabled,
|
||||||
|
initiallyExpanded: initiallyExpanded,
|
||||||
|
baseColor: Colors.grey[900],
|
||||||
|
expandedColor: Colors.grey[850],
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
@ -41,8 +46,6 @@ class AvesExpansionTile extends StatelessWidget {
|
||||||
if (enabled) ...children,
|
if (enabled) ...children,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
baseColor: Colors.grey[900],
|
|
||||||
expandedColor: Colors.grey[850],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -168,6 +168,8 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
||||||
borderRadius: borderRadius,
|
borderRadius: borderRadius,
|
||||||
),
|
),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
|
// as of Flutter v1.22.5, `InkWell` does not have `onLongPressStart` like `GestureDetector`,
|
||||||
|
// so we get the long press details from the tap instead
|
||||||
onTapDown: (details) => _tapPosition = details.globalPosition,
|
onTapDown: (details) => _tapPosition = details.globalPosition,
|
||||||
onTap: widget.onTap != null
|
onTap: widget.onTap != null
|
||||||
? () {
|
? () {
|
||||||
|
|
|
@ -43,7 +43,7 @@ class MagnifierCoreState extends State<MagnifierCore> with TickerProviderStateMi
|
||||||
Offset _startFocalPoint, _lastViewportFocalPosition;
|
Offset _startFocalPoint, _lastViewportFocalPosition;
|
||||||
double _startScale, _quickScaleLastY, _quickScaleLastDistance;
|
double _startScale, _quickScaleLastY, _quickScaleLastDistance;
|
||||||
bool _doubleTap, _quickScaleMoved;
|
bool _doubleTap, _quickScaleMoved;
|
||||||
DateTime _lastScaleGestureDate;
|
DateTime _lastScaleGestureDate = DateTime.now();
|
||||||
|
|
||||||
AnimationController _scaleAnimationController;
|
AnimationController _scaleAnimationController;
|
||||||
Animation<double> _scaleAnimation;
|
Animation<double> _scaleAnimation;
|
||||||
|
|
|
@ -3,10 +3,9 @@ import 'package:aves/model/filters/album.dart';
|
||||||
import 'package:aves/model/settings/settings.dart';
|
import 'package:aves/model/settings/settings.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/theme/durations.dart';
|
|
||||||
import 'package:aves/theme/icons.dart';
|
import 'package:aves/theme/icons.dart';
|
||||||
import 'package:aves/utils/debouncer.dart';
|
|
||||||
import 'package:aves/widgets/collection/empty.dart';
|
import 'package:aves/widgets/collection/empty.dart';
|
||||||
|
import 'package:aves/widgets/common/basic/query_bar.dart';
|
||||||
import 'package:aves/widgets/dialogs/create_album_dialog.dart';
|
import 'package:aves/widgets/dialogs/create_album_dialog.dart';
|
||||||
import 'package:aves/widgets/filter_grids/albums_page.dart';
|
import 'package:aves/widgets/filter_grids/albums_page.dart';
|
||||||
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart';
|
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart';
|
||||||
|
@ -116,84 +115,25 @@ class AlbumPickAppBar extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class AlbumFilterBar extends StatefulWidget implements PreferredSizeWidget {
|
class AlbumFilterBar extends StatelessWidget implements PreferredSizeWidget {
|
||||||
final ValueNotifier<String> filterNotifier;
|
final ValueNotifier<String> filterNotifier;
|
||||||
|
|
||||||
static const preferredHeight = kToolbarHeight;
|
static const preferredHeight = kToolbarHeight;
|
||||||
|
|
||||||
const AlbumFilterBar({@required this.filterNotifier});
|
const AlbumFilterBar({
|
||||||
|
@required this.filterNotifier,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Size get preferredSize => Size.fromHeight(preferredHeight);
|
Size get preferredSize => Size.fromHeight(preferredHeight);
|
||||||
|
|
||||||
@override
|
|
||||||
_AlbumFilterBarState createState() => _AlbumFilterBarState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AlbumFilterBarState extends State<AlbumFilterBar> {
|
|
||||||
final Debouncer _debouncer = Debouncer(delay: Durations.searchDebounceDelay);
|
|
||||||
TextEditingController _controller;
|
|
||||||
|
|
||||||
ValueNotifier<String> get filterNotifier => widget.filterNotifier;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_controller = TextEditingController(text: filterNotifier.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final clearButton = IconButton(
|
|
||||||
icon: Icon(AIcons.clear),
|
|
||||||
onPressed: () {
|
|
||||||
_controller.clear();
|
|
||||||
filterNotifier.value = '';
|
|
||||||
},
|
|
||||||
tooltip: 'Clear',
|
|
||||||
);
|
|
||||||
return Container(
|
return Container(
|
||||||
height: AlbumFilterBar.preferredHeight,
|
height: AlbumFilterBar.preferredHeight,
|
||||||
alignment: Alignment.topCenter,
|
alignment: Alignment.topCenter,
|
||||||
child: Row(
|
child: QueryBar(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
filterNotifier: filterNotifier,
|
||||||
children: [
|
|
||||||
// Icon(AIcons.search),
|
|
||||||
Expanded(
|
|
||||||
child: TextField(
|
|
||||||
controller: _controller,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
icon: Padding(
|
|
||||||
padding: EdgeInsetsDirectional.only(start: 16),
|
|
||||||
child: Icon(AIcons.search),
|
|
||||||
),
|
|
||||||
// border: OutlineInputBorder(),
|
|
||||||
hintText: MaterialLocalizations.of(context).searchFieldLabel,
|
|
||||||
hintStyle: Theme.of(context).inputDecorationTheme.hintStyle,
|
|
||||||
),
|
|
||||||
textInputAction: TextInputAction.search,
|
|
||||||
onChanged: (s) => _debouncer(() => filterNotifier.value = s),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
ConstrainedBox(
|
|
||||||
constraints: BoxConstraints(minWidth: 16),
|
|
||||||
child: ValueListenableBuilder<TextEditingValue>(
|
|
||||||
valueListenable: _controller,
|
|
||||||
builder: (context, value, child) => AnimatedSwitcher(
|
|
||||||
duration: Durations.appBarActionChangeAnimation,
|
|
||||||
transitionBuilder: (child, animation) => FadeTransition(
|
|
||||||
opacity: animation,
|
|
||||||
child: SizeTransition(
|
|
||||||
axis: Axis.horizontal,
|
|
||||||
sizeFactor: animation,
|
|
||||||
child: child,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: value.text.isNotEmpty ? clearButton : SizedBox.shrink(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -85,6 +85,7 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
|
||||||
Future<void> _showMenu(BuildContext context, T filter, Offset tapPosition) async {
|
Future<void> _showMenu(BuildContext context, T filter, Offset tapPosition) async {
|
||||||
final RenderBox overlay = Overlay.of(context).context.findRenderObject();
|
final RenderBox overlay = Overlay.of(context).context.findRenderObject();
|
||||||
final touchArea = Size(40, 40);
|
final touchArea = Size(40, 40);
|
||||||
|
// TODO TLAD show menu within safe area
|
||||||
final selectedAction = await showMenu<ChipAction>(
|
final selectedAction = await showMenu<ChipAction>(
|
||||||
context: context,
|
context: context,
|
||||||
position: RelativeRect.fromRect(tapPosition & touchArea, Offset.zero & overlay.size),
|
position: RelativeRect.fromRect(tapPosition & touchArea, Offset.zero & overlay.size),
|
||||||
|
@ -103,7 +104,7 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
|
||||||
|
|
||||||
List<Widget> _buildActions(BuildContext context) {
|
List<Widget> _buildActions(BuildContext context) {
|
||||||
return [
|
return [
|
||||||
SearchButton(source),
|
CollectionSearchButton(source),
|
||||||
PopupMenuButton<ChipSetAction>(
|
PopupMenuButton<ChipSetAction>(
|
||||||
key: Key('appbar-menu-button'),
|
key: Key('appbar-menu-button'),
|
||||||
itemBuilder: (context) {
|
itemBuilder: (context) {
|
||||||
|
@ -136,7 +137,7 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
SearchPageRoute(
|
SearchPageRoute(
|
||||||
delegate: ImageSearchDelegate(
|
delegate: CollectionSearchDelegate(
|
||||||
source: source,
|
source: source,
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
|
|
53
lib/widgets/fullscreen/info/info_app_bar.dart
Normal file
53
lib/widgets/fullscreen/info/info_app_bar.dart
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
import 'package:aves/model/image_entry.dart';
|
||||||
|
import 'package:aves/theme/icons.dart';
|
||||||
|
import 'package:aves/widgets/common/app_bar_title.dart';
|
||||||
|
import 'package:aves/widgets/fullscreen/info/info_search.dart';
|
||||||
|
import 'package:aves/widgets/fullscreen/info/metadata/metadata_section.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class InfoAppBar extends StatelessWidget {
|
||||||
|
final ImageEntry entry;
|
||||||
|
final ValueNotifier<Map<String, MetadataDirectory>> metadataNotifier;
|
||||||
|
final VoidCallback onBackPressed;
|
||||||
|
|
||||||
|
const InfoAppBar({
|
||||||
|
@required this.entry,
|
||||||
|
@required this.metadataNotifier,
|
||||||
|
@required this.onBackPressed,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SliverAppBar(
|
||||||
|
leading: IconButton(
|
||||||
|
key: Key('back-button'),
|
||||||
|
icon: Icon(AIcons.goUp),
|
||||||
|
onPressed: onBackPressed,
|
||||||
|
tooltip: 'Back to viewer',
|
||||||
|
),
|
||||||
|
title: TappableAppBarTitle(
|
||||||
|
onTap: () => _goToSearch(context),
|
||||||
|
child: Text('Info'),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(AIcons.search),
|
||||||
|
onPressed: () => _goToSearch(context),
|
||||||
|
tooltip: 'Search',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
titleSpacing: 0,
|
||||||
|
floating: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _goToSearch(BuildContext context) {
|
||||||
|
showSearch(
|
||||||
|
context: context,
|
||||||
|
delegate: InfoSearchDelegate(
|
||||||
|
entry: entry,
|
||||||
|
metadataNotifier: metadataNotifier,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,9 +2,9 @@ import 'package:aves/model/filters/filters.dart';
|
||||||
import 'package:aves/model/image_entry.dart';
|
import 'package:aves/model/image_entry.dart';
|
||||||
import 'package:aves/model/source/collection_lens.dart';
|
import 'package:aves/model/source/collection_lens.dart';
|
||||||
import 'package:aves/theme/durations.dart';
|
import 'package:aves/theme/durations.dart';
|
||||||
import 'package:aves/theme/icons.dart';
|
|
||||||
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||||
import 'package:aves/widgets/fullscreen/info/basic_section.dart';
|
import 'package:aves/widgets/fullscreen/info/basic_section.dart';
|
||||||
|
import 'package:aves/widgets/fullscreen/info/info_app_bar.dart';
|
||||||
import 'package:aves/widgets/fullscreen/info/location_section.dart';
|
import 'package:aves/widgets/fullscreen/info/location_section.dart';
|
||||||
import 'package:aves/widgets/fullscreen/info/metadata/metadata_section.dart';
|
import 'package:aves/widgets/fullscreen/info/metadata/metadata_section.dart';
|
||||||
import 'package:aves/widgets/fullscreen/info/notifications.dart';
|
import 'package:aves/widgets/fullscreen/info/notifications.dart';
|
||||||
|
@ -38,17 +38,6 @@ class InfoPageState extends State<InfoPage> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final appBar = SliverAppBar(
|
|
||||||
leading: IconButton(
|
|
||||||
key: Key('back-button'),
|
|
||||||
icon: Icon(AIcons.goUp),
|
|
||||||
onPressed: _goToImage,
|
|
||||||
tooltip: 'Back to viewer',
|
|
||||||
),
|
|
||||||
title: Text('Info'),
|
|
||||||
floating: true,
|
|
||||||
);
|
|
||||||
|
|
||||||
return MediaQueryDataProvider(
|
return MediaQueryDataProvider(
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
|
@ -68,9 +57,9 @@ class InfoPageState extends State<InfoPage> {
|
||||||
entry: entry,
|
entry: entry,
|
||||||
visibleNotifier: widget.visibleNotifier,
|
visibleNotifier: widget.visibleNotifier,
|
||||||
scrollController: _scrollController,
|
scrollController: _scrollController,
|
||||||
appBar: appBar,
|
|
||||||
split: mqWidth > 400,
|
split: mqWidth > 400,
|
||||||
mqViewInsetsBottom: mqViewInsetsBottom,
|
mqViewInsetsBottom: mqViewInsetsBottom,
|
||||||
|
goToViewer: _goToViewer,
|
||||||
)
|
)
|
||||||
: SizedBox.shrink();
|
: SizedBox.shrink();
|
||||||
},
|
},
|
||||||
|
@ -97,7 +86,7 @@ class InfoPageState extends State<InfoPage> {
|
||||||
_scrollStartFromTop = false;
|
_scrollStartFromTop = false;
|
||||||
} else if (notification is OverscrollNotification) {
|
} else if (notification is OverscrollNotification) {
|
||||||
if (notification.overscroll < 0) {
|
if (notification.overscroll < 0) {
|
||||||
_goToImage();
|
_goToViewer();
|
||||||
_scrollStartFromTop = false;
|
_scrollStartFromTop = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -106,7 +95,7 @@ class InfoPageState extends State<InfoPage> {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _goToImage() {
|
void _goToViewer() {
|
||||||
BackUpNotification().dispatch(context);
|
BackUpNotification().dispatch(context);
|
||||||
_scrollController.animateTo(
|
_scrollController.animateTo(
|
||||||
0,
|
0,
|
||||||
|
@ -121,9 +110,9 @@ class _InfoPageContent extends StatefulWidget {
|
||||||
final ImageEntry entry;
|
final ImageEntry entry;
|
||||||
final ValueNotifier<bool> visibleNotifier;
|
final ValueNotifier<bool> visibleNotifier;
|
||||||
final ScrollController scrollController;
|
final ScrollController scrollController;
|
||||||
final SliverAppBar appBar;
|
|
||||||
final bool split;
|
final bool split;
|
||||||
final double mqViewInsetsBottom;
|
final double mqViewInsetsBottom;
|
||||||
|
final VoidCallback goToViewer;
|
||||||
|
|
||||||
const _InfoPageContent({
|
const _InfoPageContent({
|
||||||
Key key,
|
Key key,
|
||||||
|
@ -131,9 +120,9 @@ class _InfoPageContent extends StatefulWidget {
|
||||||
@required this.entry,
|
@required this.entry,
|
||||||
@required this.visibleNotifier,
|
@required this.visibleNotifier,
|
||||||
@required this.scrollController,
|
@required this.scrollController,
|
||||||
@required this.appBar,
|
|
||||||
@required this.split,
|
@required this.split,
|
||||||
@required this.mqViewInsetsBottom,
|
@required this.mqViewInsetsBottom,
|
||||||
|
@required this.goToViewer,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -143,6 +132,8 @@ class _InfoPageContent extends StatefulWidget {
|
||||||
class _InfoPageContentState extends State<_InfoPageContent> {
|
class _InfoPageContentState extends State<_InfoPageContent> {
|
||||||
static const horizontalPadding = EdgeInsets.symmetric(horizontal: 8);
|
static const horizontalPadding = EdgeInsets.symmetric(horizontal: 8);
|
||||||
|
|
||||||
|
final ValueNotifier<Map<String, MetadataDirectory>> _metadataNotifier = ValueNotifier({});
|
||||||
|
|
||||||
CollectionLens get collection => widget.collection;
|
CollectionLens get collection => widget.collection;
|
||||||
|
|
||||||
ImageEntry get entry => widget.entry;
|
ImageEntry get entry => widget.entry;
|
||||||
|
@ -178,13 +169,18 @@ class _InfoPageContentState extends State<_InfoPageContent> {
|
||||||
);
|
);
|
||||||
final metadataSliver = MetadataSectionSliver(
|
final metadataSliver = MetadataSectionSliver(
|
||||||
entry: entry,
|
entry: entry,
|
||||||
|
metadataNotifier: _metadataNotifier,
|
||||||
visibleNotifier: widget.visibleNotifier,
|
visibleNotifier: widget.visibleNotifier,
|
||||||
);
|
);
|
||||||
|
|
||||||
return CustomScrollView(
|
return CustomScrollView(
|
||||||
controller: widget.scrollController,
|
controller: widget.scrollController,
|
||||||
slivers: [
|
slivers: [
|
||||||
widget.appBar,
|
InfoAppBar(
|
||||||
|
entry: entry,
|
||||||
|
metadataNotifier: _metadataNotifier,
|
||||||
|
onBackPressed: widget.goToViewer,
|
||||||
|
),
|
||||||
SliverPadding(
|
SliverPadding(
|
||||||
padding: horizontalPadding + EdgeInsets.only(top: 8),
|
padding: horizontalPadding + EdgeInsets.only(top: 8),
|
||||||
sliver: basicAndLocationSliver,
|
sliver: basicAndLocationSliver,
|
||||||
|
|
118
lib/widgets/fullscreen/info/info_search.dart
Normal file
118
lib/widgets/fullscreen/info/info_search.dart
Normal file
|
@ -0,0 +1,118 @@
|
||||||
|
import 'package:aves/model/image_entry.dart';
|
||||||
|
import 'package:aves/theme/icons.dart';
|
||||||
|
import 'package:aves/widgets/collection/empty.dart';
|
||||||
|
import 'package:aves/widgets/fullscreen/info/metadata/metadata_dir_tile.dart';
|
||||||
|
import 'package:aves/widgets/fullscreen/info/metadata/metadata_section.dart';
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class InfoSearchDelegate extends SearchDelegate {
|
||||||
|
final ImageEntry entry;
|
||||||
|
final ValueNotifier<Map<String, MetadataDirectory>> metadataNotifier;
|
||||||
|
|
||||||
|
Map<String, MetadataDirectory> get metadata => metadataNotifier.value;
|
||||||
|
|
||||||
|
static const suggestions = {
|
||||||
|
'Date & time': 'date or time or when -timer -uptime -exposure -timeline',
|
||||||
|
'Description': 'abstract or description or comment',
|
||||||
|
'Dimensions': 'width or height or dimension or framesize or imagelength',
|
||||||
|
'Resolution': 'resolution',
|
||||||
|
'Rights': 'rights or copyright or artist or creator or by-line or credit -tool',
|
||||||
|
};
|
||||||
|
|
||||||
|
InfoSearchDelegate({
|
||||||
|
@required this.entry,
|
||||||
|
@required this.metadataNotifier,
|
||||||
|
}) : super(
|
||||||
|
searchFieldLabel: 'Search metadata',
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
ThemeData appBarTheme(BuildContext context) {
|
||||||
|
return Theme.of(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildLeading(BuildContext context) {
|
||||||
|
return IconButton(
|
||||||
|
icon: AnimatedIcon(
|
||||||
|
icon: AnimatedIcons.menu_arrow,
|
||||||
|
progress: transitionAnimation,
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Widget> buildActions(BuildContext context) {
|
||||||
|
return [
|
||||||
|
if (query.isNotEmpty)
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(AIcons.clear),
|
||||||
|
onPressed: () {
|
||||||
|
query = '';
|
||||||
|
showSuggestions(context);
|
||||||
|
},
|
||||||
|
tooltip: 'Clear',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildSuggestions(BuildContext context) => ListView(
|
||||||
|
children: suggestions.entries
|
||||||
|
.map((kv) => ListTile(
|
||||||
|
title: Text(kv.key),
|
||||||
|
onTap: () {
|
||||||
|
query = kv.value;
|
||||||
|
showResults(context);
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildResults(BuildContext context) {
|
||||||
|
if (query.isEmpty) {
|
||||||
|
showSuggestions(context);
|
||||||
|
return SizedBox();
|
||||||
|
}
|
||||||
|
|
||||||
|
final queryParts = query.toUpperCase().split(' ')..removeWhere((s) => s.isEmpty);
|
||||||
|
final queryExcludeIncludeGroups = groupBy<String, bool>(queryParts, (s) => s.startsWith('-'));
|
||||||
|
final queryExcludeAll = (queryExcludeIncludeGroups[true] ?? []).map((s) => s.substring(1));
|
||||||
|
final queryIncludeAny = (queryExcludeIncludeGroups[false] ?? []).join(' ').split(' OR ');
|
||||||
|
|
||||||
|
bool testKey(String key) {
|
||||||
|
key = key.toUpperCase();
|
||||||
|
return queryIncludeAny.any(key.contains) && queryExcludeAll.every((q) => !key.contains(q));
|
||||||
|
}
|
||||||
|
|
||||||
|
final filteredMetadata = Map.fromEntries(metadata.entries.map((kv) {
|
||||||
|
final filteredDir = kv.value.filterKeys(testKey);
|
||||||
|
return MapEntry(kv.key, filteredDir);
|
||||||
|
}));
|
||||||
|
|
||||||
|
final tiles = filteredMetadata.entries
|
||||||
|
.where((kv) => kv.value.tags.isNotEmpty)
|
||||||
|
.map((kv) => MetadataDirTile(
|
||||||
|
entry: entry,
|
||||||
|
title: kv.key,
|
||||||
|
dir: kv.value,
|
||||||
|
initiallyExpanded: true,
|
||||||
|
showPrefixChildren: false,
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
return tiles.isEmpty
|
||||||
|
? EmptyContent(
|
||||||
|
icon: AIcons.info,
|
||||||
|
text: 'No matching keys',
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
padding: EdgeInsets.all(8),
|
||||||
|
itemBuilder: (context, index) => tiles[index],
|
||||||
|
itemCount: tiles.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
113
lib/widgets/fullscreen/info/metadata/metadata_dir_tile.dart
Normal file
113
lib/widgets/fullscreen/info/metadata/metadata_dir_tile.dart
Normal file
|
@ -0,0 +1,113 @@
|
||||||
|
import 'dart:collection';
|
||||||
|
|
||||||
|
import 'package:aves/model/image_entry.dart';
|
||||||
|
import 'package:aves/ref/brand_colors.dart';
|
||||||
|
import 'package:aves/services/svg_metadata_service.dart';
|
||||||
|
import 'package:aves/theme/icons.dart';
|
||||||
|
import 'package:aves/utils/color_utils.dart';
|
||||||
|
import 'package:aves/utils/constants.dart';
|
||||||
|
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
|
||||||
|
import 'package:aves/widgets/fullscreen/info/common.dart';
|
||||||
|
import 'package:aves/widgets/fullscreen/info/metadata/metadata_section.dart';
|
||||||
|
import 'package:aves/widgets/fullscreen/info/metadata/metadata_thumbnail.dart';
|
||||||
|
import 'package:aves/widgets/fullscreen/info/metadata/xmp_tile.dart';
|
||||||
|
import 'package:aves/widgets/fullscreen/source_viewer_page.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class MetadataDirTile extends StatelessWidget {
|
||||||
|
final ImageEntry entry;
|
||||||
|
final String title;
|
||||||
|
final MetadataDirectory dir;
|
||||||
|
final ValueNotifier<String> expandedDirectoryNotifier;
|
||||||
|
final bool initiallyExpanded, showPrefixChildren;
|
||||||
|
|
||||||
|
const MetadataDirTile({
|
||||||
|
@required this.entry,
|
||||||
|
@required this.title,
|
||||||
|
@required this.dir,
|
||||||
|
this.expandedDirectoryNotifier,
|
||||||
|
this.initiallyExpanded = false,
|
||||||
|
this.showPrefixChildren = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final tags = dir.tags;
|
||||||
|
if (tags.isEmpty) return SizedBox.shrink();
|
||||||
|
|
||||||
|
final dirName = dir.name;
|
||||||
|
if (dirName == MetadataDirectory.xmpDirectory) {
|
||||||
|
return XmpDirTile(
|
||||||
|
entry: entry,
|
||||||
|
tags: tags,
|
||||||
|
expandedNotifier: expandedDirectoryNotifier,
|
||||||
|
initiallyExpanded: initiallyExpanded,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget thumbnail;
|
||||||
|
final prefixChildren = <Widget>[];
|
||||||
|
if (showPrefixChildren) {
|
||||||
|
switch (dirName) {
|
||||||
|
case MetadataDirectory.exifThumbnailDirectory:
|
||||||
|
thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.exif, entry: entry);
|
||||||
|
break;
|
||||||
|
case MetadataDirectory.mediaDirectory:
|
||||||
|
thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.embedded, entry: entry);
|
||||||
|
Widget builder(IconData data) => Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
||||||
|
child: Icon(data),
|
||||||
|
);
|
||||||
|
if (tags['Has Video'] == 'yes') prefixChildren.add(builder(AIcons.video));
|
||||||
|
if (tags['Has Audio'] == 'yes') prefixChildren.add(builder(AIcons.audio));
|
||||||
|
if (tags['Has Image'] == 'yes') {
|
||||||
|
int count;
|
||||||
|
if (tags.containsKey('Image Count')) {
|
||||||
|
count = int.tryParse(tags['Image Count']);
|
||||||
|
}
|
||||||
|
prefixChildren.addAll(List.generate(count ?? 1, (i) => builder(AIcons.image)));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return AvesExpansionTile(
|
||||||
|
title: title,
|
||||||
|
color: BrandColors.get(dirName) ?? stringToColor(dirName),
|
||||||
|
expandedNotifier: expandedDirectoryNotifier,
|
||||||
|
initiallyExpanded: initiallyExpanded,
|
||||||
|
children: [
|
||||||
|
if (prefixChildren.isNotEmpty) Wrap(children: prefixChildren),
|
||||||
|
if (thumbnail != null) thumbnail,
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
||||||
|
child: InfoRowGroup(
|
||||||
|
tags,
|
||||||
|
maxValueLength: Constants.infoGroupMaxValueLength,
|
||||||
|
linkHandlers: dirName == SvgMetadataService.metadataDirectory ? getSvgLinkHandlers(tags) : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, InfoLinkHandler> getSvgLinkHandlers(SplayTreeMap<String, String> tags) {
|
||||||
|
return {
|
||||||
|
'Metadata': InfoLinkHandler(
|
||||||
|
linkText: 'View XML',
|
||||||
|
onTap: (context) {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
settings: RouteSettings(name: SourceViewerPage.routeName),
|
||||||
|
builder: (context) => SourceViewerPage(
|
||||||
|
loader: () => SynchronousFuture(tags['Metadata']),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,18 +1,12 @@
|
||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
|
|
||||||
import 'package:aves/model/image_entry.dart';
|
import 'package:aves/model/image_entry.dart';
|
||||||
import 'package:aves/ref/brand_colors.dart';
|
|
||||||
import 'package:aves/services/metadata_service.dart';
|
import 'package:aves/services/metadata_service.dart';
|
||||||
import 'package:aves/services/svg_metadata_service.dart';
|
import 'package:aves/services/svg_metadata_service.dart';
|
||||||
import 'package:aves/theme/durations.dart';
|
import 'package:aves/theme/durations.dart';
|
||||||
import 'package:aves/theme/icons.dart';
|
import 'package:aves/theme/icons.dart';
|
||||||
import 'package:aves/utils/color_utils.dart';
|
|
||||||
import 'package:aves/utils/constants.dart';
|
|
||||||
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
|
|
||||||
import 'package:aves/widgets/fullscreen/info/common.dart';
|
import 'package:aves/widgets/fullscreen/info/common.dart';
|
||||||
import 'package:aves/widgets/fullscreen/info/metadata/metadata_thumbnail.dart';
|
import 'package:aves/widgets/fullscreen/info/metadata/metadata_dir_tile.dart';
|
||||||
import 'package:aves/widgets/fullscreen/info/metadata/xmp_tile.dart';
|
|
||||||
import 'package:aves/widgets/fullscreen/source_viewer_page.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';
|
||||||
|
@ -21,10 +15,12 @@ import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
|
||||||
class MetadataSectionSliver extends StatefulWidget {
|
class MetadataSectionSliver extends StatefulWidget {
|
||||||
final ImageEntry entry;
|
final ImageEntry entry;
|
||||||
final ValueNotifier<bool> visibleNotifier;
|
final ValueNotifier<bool> visibleNotifier;
|
||||||
|
final ValueNotifier<Map<String, MetadataDirectory>> metadataNotifier;
|
||||||
|
|
||||||
const MetadataSectionSliver({
|
const MetadataSectionSliver({
|
||||||
@required this.entry,
|
@required this.entry,
|
||||||
@required this.visibleNotifier,
|
@required this.visibleNotifier,
|
||||||
|
@required this.metadataNotifier,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -32,7 +28,6 @@ class MetadataSectionSliver extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MetadataSectionSliverState extends State<MetadataSectionSliver> with AutomaticKeepAliveClientMixin {
|
class _MetadataSectionSliverState extends State<MetadataSectionSliver> with AutomaticKeepAliveClientMixin {
|
||||||
Map<String, _MetadataDirectory> _metadata = {};
|
|
||||||
final ValueNotifier<String> _loadedMetadataUri = ValueNotifier(null);
|
final ValueNotifier<String> _loadedMetadataUri = ValueNotifier(null);
|
||||||
final ValueNotifier<String> _expandedDirectoryNotifier = ValueNotifier(null);
|
final ValueNotifier<String> _expandedDirectoryNotifier = ValueNotifier(null);
|
||||||
|
|
||||||
|
@ -40,10 +35,9 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
||||||
|
|
||||||
bool get isVisible => widget.visibleNotifier.value;
|
bool get isVisible => widget.visibleNotifier.value;
|
||||||
|
|
||||||
// special directory names
|
ValueNotifier<Map<String, MetadataDirectory>> get metadataNotifier => widget.metadataNotifier;
|
||||||
static const exifThumbnailDirectory = 'Exif Thumbnail'; // from metadata-extractor
|
|
||||||
static const xmpDirectory = 'XMP'; // from metadata-extractor
|
Map<String, MetadataDirectory> get metadata => metadataNotifier.value;
|
||||||
static const mediaDirectory = 'Media'; // additional media (video/audio/images) directory
|
|
||||||
|
|
||||||
// directory names may contain the name of their parent directory
|
// directory names may contain the name of their parent directory
|
||||||
// if so, they are separated by this character
|
// if so, they are separated by this character
|
||||||
|
@ -53,6 +47,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_registerWidget(widget);
|
_registerWidget(widget);
|
||||||
|
metadataNotifier.value = {};
|
||||||
_getMetadata();
|
_getMetadata();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -96,7 +91,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
||||||
valueListenable: _loadedMetadataUri,
|
valueListenable: _loadedMetadataUri,
|
||||||
builder: (context, uri, child) {
|
builder: (context, uri, child) {
|
||||||
Widget content;
|
Widget content;
|
||||||
if (_metadata.isEmpty) {
|
if (metadata.isEmpty) {
|
||||||
content = SizedBox.shrink();
|
content = SizedBox.shrink();
|
||||||
} else {
|
} else {
|
||||||
content = Column(
|
content = Column(
|
||||||
|
@ -111,7 +106,12 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
SectionRow(AIcons.info),
|
SectionRow(AIcons.info),
|
||||||
..._metadata.entries.map((kv) => _buildDirTile(kv.key, kv.value)),
|
...metadata.entries.map((kv) => MetadataDirTile(
|
||||||
|
entry: entry,
|
||||||
|
title: kv.key,
|
||||||
|
dir: kv.value,
|
||||||
|
expandedDirectoryNotifier: _expandedDirectoryNotifier,
|
||||||
|
)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -128,64 +128,9 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDirTile(String title, _MetadataDirectory dir) {
|
|
||||||
if (dir.tags.isEmpty) return SizedBox.shrink();
|
|
||||||
|
|
||||||
final dirName = dir.name;
|
|
||||||
if (dirName == xmpDirectory) {
|
|
||||||
return XmpDirTile(
|
|
||||||
entry: entry,
|
|
||||||
tags: dir.tags,
|
|
||||||
expandedNotifier: _expandedDirectoryNotifier,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget thumbnail;
|
|
||||||
final prefixChildren = <Widget>[];
|
|
||||||
switch (dirName) {
|
|
||||||
case exifThumbnailDirectory:
|
|
||||||
thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.exif, entry: entry);
|
|
||||||
break;
|
|
||||||
case mediaDirectory:
|
|
||||||
thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.embedded, entry: entry);
|
|
||||||
Widget builder(IconData data) => Padding(
|
|
||||||
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
|
||||||
child: Icon(data),
|
|
||||||
);
|
|
||||||
if (dir.tags['Has Video'] == 'yes') prefixChildren.add(builder(AIcons.video));
|
|
||||||
if (dir.tags['Has Audio'] == 'yes') prefixChildren.add(builder(AIcons.audio));
|
|
||||||
if (dir.tags['Has Image'] == 'yes') {
|
|
||||||
int count;
|
|
||||||
if (dir.tags.containsKey('Image Count')) {
|
|
||||||
count = int.tryParse(dir.tags['Image Count']);
|
|
||||||
}
|
|
||||||
prefixChildren.addAll(List.generate(count ?? 1, (i) => builder(AIcons.image)));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return AvesExpansionTile(
|
|
||||||
title: title,
|
|
||||||
color: BrandColors.get(dirName) ?? stringToColor(dirName),
|
|
||||||
expandedNotifier: _expandedDirectoryNotifier,
|
|
||||||
children: [
|
|
||||||
if (prefixChildren.isNotEmpty) Wrap(children: prefixChildren),
|
|
||||||
if (thumbnail != null) thumbnail,
|
|
||||||
Padding(
|
|
||||||
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
|
||||||
child: InfoRowGroup(
|
|
||||||
dir.tags,
|
|
||||||
maxValueLength: Constants.infoGroupMaxValueLength,
|
|
||||||
linkHandlers: dirName == SvgMetadataService.metadataDirectory ? getSvgLinkHandlers(dir.tags) : null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onMetadataChanged() {
|
void _onMetadataChanged() {
|
||||||
_loadedMetadataUri.value = null;
|
_loadedMetadataUri.value = null;
|
||||||
_metadata = {};
|
metadataNotifier.value = {};
|
||||||
_getMetadata();
|
_getMetadata();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -211,7 +156,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
||||||
final tagName = tagKV.key as String ?? '';
|
final tagName = tagKV.key as String ?? '';
|
||||||
return MapEntry(tagName, value);
|
return MapEntry(tagName, value);
|
||||||
}).where((kv) => kv != null)));
|
}).where((kv) => kv != null)));
|
||||||
return _MetadataDirectory(directoryName, parent, tags);
|
return MetadataDirectory(directoryName, parent, tags);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
final titledDirectories = directories.map((dir) {
|
final titledDirectories = directories.map((dir) {
|
||||||
|
@ -222,42 +167,36 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
||||||
return MapEntry(title, dir);
|
return MapEntry(title, dir);
|
||||||
}).toList()
|
}).toList()
|
||||||
..sort((a, b) => compareAsciiUpperCase(a.key, b.key));
|
..sort((a, b) => compareAsciiUpperCase(a.key, b.key));
|
||||||
_metadata = Map.fromEntries(titledDirectories);
|
metadataNotifier.value = Map.fromEntries(titledDirectories);
|
||||||
_loadedMetadataUri.value = entry.uri;
|
_loadedMetadataUri.value = entry.uri;
|
||||||
} else {
|
} else {
|
||||||
_metadata = {};
|
metadataNotifier.value = {};
|
||||||
_loadedMetadataUri.value = null;
|
_loadedMetadataUri.value = null;
|
||||||
}
|
}
|
||||||
_expandedDirectoryNotifier.value = null;
|
_expandedDirectoryNotifier.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Map<String, InfoLinkHandler> getSvgLinkHandlers(SplayTreeMap<String, String> tags) {
|
|
||||||
return {
|
|
||||||
'Metadata': InfoLinkHandler(
|
|
||||||
linkText: 'View XML',
|
|
||||||
onTap: (context) {
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
settings: RouteSettings(name: SourceViewerPage.routeName),
|
|
||||||
builder: (context) => SourceViewerPage(
|
|
||||||
loader: () => SynchronousFuture(tags['Metadata']),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool get wantKeepAlive => true;
|
bool get wantKeepAlive => true;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MetadataDirectory {
|
class MetadataDirectory {
|
||||||
final String name;
|
final String name;
|
||||||
final String parent;
|
final String parent;
|
||||||
|
final SplayTreeMap<String, String> allTags;
|
||||||
final SplayTreeMap<String, String> tags;
|
final SplayTreeMap<String, String> tags;
|
||||||
|
|
||||||
const _MetadataDirectory(this.name, this.parent, this.tags);
|
// special directory names
|
||||||
|
static const exifThumbnailDirectory = 'Exif Thumbnail'; // from metadata-extractor
|
||||||
|
static const xmpDirectory = 'XMP'; // from metadata-extractor
|
||||||
|
static const mediaDirectory = 'Media'; // additional media (video/audio/images) directory
|
||||||
|
|
||||||
|
const MetadataDirectory(this.name, this.parent, SplayTreeMap<String, String> allTags, {SplayTreeMap<String, String> tags})
|
||||||
|
: allTags = allTags,
|
||||||
|
tags = tags ?? allTags;
|
||||||
|
|
||||||
|
MetadataDirectory filterKeys(bool Function(String key) testKey) {
|
||||||
|
final filteredTags = SplayTreeMap.of(Map.fromEntries(allTags.entries.where((kv) => testKey(kv.key))));
|
||||||
|
return MetadataDirectory(name, parent, tags, tags: filteredTags);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -51,9 +51,13 @@ class XmpMMNamespace extends XmpNamespace {
|
||||||
|
|
||||||
static final derivedFromPattern = RegExp(r'xmpMM:DerivedFrom/(.*)');
|
static final derivedFromPattern = RegExp(r'xmpMM:DerivedFrom/(.*)');
|
||||||
static final historyPattern = RegExp(r'xmpMM:History\[(\d+)\]/(.*)');
|
static final historyPattern = RegExp(r'xmpMM:History\[(\d+)\]/(.*)');
|
||||||
|
static final ingredientsPattern = RegExp(r'xmpMM:Ingredients\[(\d+)\]/(.*)');
|
||||||
|
static final pantryPattern = RegExp(r'xmpMM:Pantry\[(\d+)\]/(.*)');
|
||||||
|
|
||||||
final derivedFrom = <String, String>{};
|
final derivedFrom = <String, String>{};
|
||||||
final history = <int, Map<String, String>>{};
|
final history = <int, Map<String, String>>{};
|
||||||
|
final ingredients = <int, Map<String, String>>{};
|
||||||
|
final pantry = <int, Map<String, String>>{};
|
||||||
|
|
||||||
XmpMMNamespace() : super(ns);
|
XmpMMNamespace() : super(ns);
|
||||||
|
|
||||||
|
@ -63,7 +67,9 @@ class XmpMMNamespace extends XmpNamespace {
|
||||||
@override
|
@override
|
||||||
bool extractData(XmpProp prop) {
|
bool extractData(XmpProp prop) {
|
||||||
final hasStructs = extractStruct(prop, derivedFromPattern, derivedFrom);
|
final hasStructs = extractStruct(prop, derivedFromPattern, derivedFrom);
|
||||||
final hasIndexedStructs = extractIndexedStruct(prop, historyPattern, history);
|
var hasIndexedStructs = extractIndexedStruct(prop, historyPattern, history);
|
||||||
|
hasIndexedStructs |= extractIndexedStruct(prop, ingredientsPattern, ingredients);
|
||||||
|
hasIndexedStructs |= extractIndexedStruct(prop, pantryPattern, pantry);
|
||||||
return hasStructs || hasIndexedStructs;
|
return hasStructs || hasIndexedStructs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -79,6 +85,16 @@ class XmpMMNamespace extends XmpNamespace {
|
||||||
title: 'History',
|
title: 'History',
|
||||||
structByIndex: history,
|
structByIndex: history,
|
||||||
),
|
),
|
||||||
|
if (ingredients.isNotEmpty)
|
||||||
|
XmpStructArrayCard(
|
||||||
|
title: 'Ingredients',
|
||||||
|
structByIndex: ingredients,
|
||||||
|
),
|
||||||
|
if (pantry.isNotEmpty)
|
||||||
|
XmpStructArrayCard(
|
||||||
|
title: 'Pantry',
|
||||||
|
structByIndex: pantry,
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
@ -89,7 +89,7 @@ class _XmpStructArrayCardState extends State<XmpStructArrayCard> {
|
||||||
// without clipping the text
|
// without clipping the text
|
||||||
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
||||||
child: InfoRowGroup(
|
child: InfoRowGroup(
|
||||||
structs[_index],
|
structs[_index] ?? {},
|
||||||
maxValueLength: Constants.infoGroupMaxValueLength,
|
maxValueLength: Constants.infoGroupMaxValueLength,
|
||||||
linkHandlers: widget.linkifier?.call(_index + 1),
|
linkHandlers: widget.linkifier?.call(_index + 1),
|
||||||
),
|
),
|
||||||
|
|
|
@ -25,11 +25,13 @@ class XmpDirTile extends StatefulWidget {
|
||||||
final ImageEntry entry;
|
final ImageEntry entry;
|
||||||
final SplayTreeMap<String, String> tags;
|
final SplayTreeMap<String, String> tags;
|
||||||
final ValueNotifier<String> expandedNotifier;
|
final ValueNotifier<String> expandedNotifier;
|
||||||
|
final bool initiallyExpanded;
|
||||||
|
|
||||||
const XmpDirTile({
|
const XmpDirTile({
|
||||||
@required this.entry,
|
@required this.entry,
|
||||||
@required this.tags,
|
@required this.tags,
|
||||||
@required this.expandedNotifier,
|
@required this.expandedNotifier,
|
||||||
|
@required this.initiallyExpanded,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -76,6 +78,7 @@ class _XmpDirTileState extends State<XmpDirTile> with FeedbackMixin {
|
||||||
return AvesExpansionTile(
|
return AvesExpansionTile(
|
||||||
title: 'XMP',
|
title: 'XMP',
|
||||||
expandedNotifier: widget.expandedNotifier,
|
expandedNotifier: widget.expandedNotifier,
|
||||||
|
initiallyExpanded: widget.initiallyExpanded,
|
||||||
children: [
|
children: [
|
||||||
NotificationListener<OpenEmbeddedDataNotification>(
|
NotificationListener<OpenEmbeddedDataNotification>(
|
||||||
onNotification: (notification) {
|
onNotification: (notification) {
|
||||||
|
|
|
@ -142,7 +142,7 @@ class _HomePageState extends State<HomePage> {
|
||||||
);
|
);
|
||||||
case SearchPage.routeName:
|
case SearchPage.routeName:
|
||||||
return SearchPageRoute(
|
return SearchPageRoute(
|
||||||
delegate: ImageSearchDelegate(source: _mediaStore),
|
delegate: CollectionSearchDelegate(source: _mediaStore),
|
||||||
);
|
);
|
||||||
case CollectionPage.routeName:
|
case CollectionPage.routeName:
|
||||||
default:
|
default:
|
||||||
|
|
|
@ -4,11 +4,11 @@ import 'package:aves/theme/icons.dart';
|
||||||
import 'package:aves/widgets/search/search_delegate.dart';
|
import 'package:aves/widgets/search/search_delegate.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class SearchButton extends StatelessWidget {
|
class CollectionSearchButton extends StatelessWidget {
|
||||||
final CollectionSource source;
|
final CollectionSource source;
|
||||||
final CollectionLens parentCollection;
|
final CollectionLens parentCollection;
|
||||||
|
|
||||||
const SearchButton(this.source, {this.parentCollection});
|
const CollectionSearchButton(this.source, {this.parentCollection});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
@ -24,7 +24,7 @@ class SearchButton extends StatelessWidget {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
SearchPageRoute(
|
SearchPageRoute(
|
||||||
delegate: ImageSearchDelegate(
|
delegate: CollectionSearchDelegate(
|
||||||
source: source,
|
source: source,
|
||||||
parentCollection: parentCollection,
|
parentCollection: parentCollection,
|
||||||
),
|
),
|
||||||
|
|
|
@ -20,14 +20,14 @@ import 'package:aves/widgets/search/search_page.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
class ImageSearchDelegate {
|
class CollectionSearchDelegate {
|
||||||
final CollectionSource source;
|
final CollectionSource source;
|
||||||
final CollectionLens parentCollection;
|
final CollectionLens parentCollection;
|
||||||
final ValueNotifier<String> expandedSectionNotifier = ValueNotifier(null);
|
final ValueNotifier<String> expandedSectionNotifier = ValueNotifier(null);
|
||||||
|
|
||||||
static const searchHistoryCount = 10;
|
static const searchHistoryCount = 10;
|
||||||
|
|
||||||
ImageSearchDelegate({@required this.source, this.parentCollection});
|
CollectionSearchDelegate({@required this.source, this.parentCollection});
|
||||||
|
|
||||||
ThemeData appBarTheme(BuildContext context) {
|
ThemeData appBarTheme(BuildContext context) {
|
||||||
return Theme.of(context);
|
return Theme.of(context);
|
||||||
|
@ -289,7 +289,7 @@ class SearchPageRoute<T> extends PageRoute<T> {
|
||||||
delegate.route = this;
|
delegate.route = this;
|
||||||
}
|
}
|
||||||
|
|
||||||
final ImageSearchDelegate delegate;
|
final CollectionSearchDelegate delegate;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Color get barrierColor => null;
|
Color get barrierColor => null;
|
||||||
|
|
|
@ -7,7 +7,7 @@ import 'package:flutter/material.dart';
|
||||||
class SearchPage extends StatefulWidget {
|
class SearchPage extends StatefulWidget {
|
||||||
static const routeName = '/search';
|
static const routeName = '/search';
|
||||||
|
|
||||||
final ImageSearchDelegate delegate;
|
final CollectionSearchDelegate delegate;
|
||||||
final Animation<double> animation;
|
final Animation<double> animation;
|
||||||
|
|
||||||
const SearchPage({
|
const SearchPage({
|
||||||
|
@ -115,7 +115,7 @@ class _SearchPageState extends State<SearchPage> {
|
||||||
onSubmitted: (_) => widget.delegate.showResults(context),
|
onSubmitted: (_) => widget.delegate.showResults(context),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
hintText: MaterialLocalizations.of(context).searchFieldLabel,
|
hintText: 'Search collection',
|
||||||
hintStyle: theme.inputDecorationTheme.hintStyle,
|
hintStyle: theme.inputDecorationTheme.hintStyle,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
@ -3,7 +3,7 @@ description: Aves is a gallery and metadata explorer app, built for Android.
|
||||||
|
|
||||||
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||||
|
|
||||||
version: 1.3.0+36
|
version: 1.3.1+37
|
||||||
|
|
||||||
# brendan-duncan/image (as of v2.1.19):
|
# brendan-duncan/image (as of v2.1.19):
|
||||||
# - does not support TIFF with JPEG compression (issue #184)
|
# - does not support TIFF with JPEG compression (issue #184)
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
Thanks for using Aves!
|
Thanks for using Aves!
|
||||||
v1.3.0:
|
v1.3.1:
|
||||||
- added quick scale (aka one finger zoom) gesture to the viewer
|
- long press and move to select/deselect multiple entries
|
||||||
- fixed zoom focus with double-tap or pinch-to-zoom gestures
|
- metadata search in the Info page
|
||||||
|
- fixed crash when opening a collection with TIFF files on Android 11
|
||||||
Full changelog available on Github
|
Full changelog available on Github
|
Loading…
Reference in a new issue