Merge branch 'develop'
This commit is contained in:
commit
11d436ff5e
150 changed files with 3471 additions and 1840 deletions
13
CHANGELOG.md
13
CHANGELOG.md
|
@ -3,6 +3,19 @@ All notable changes to this project will be documented in this file.
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [v1.3.2] - 2021-01-17
|
||||
### Added
|
||||
Collection: identify multipage TIFF & multitrack HEIC/HEIF
|
||||
Viewer: support for multipage TIFF
|
||||
Viewer: support for cropped panoramas
|
||||
Albums: grouping options
|
||||
|
||||
### Changed
|
||||
upgraded libtiff to 4.2.0 for TIFF decoding
|
||||
|
||||
### Fixed
|
||||
- prevent scrolling when using Android Q style gesture navigation
|
||||
|
||||
## [v1.3.1] - 2021-01-04
|
||||
### Added
|
||||
- Collection: long press and move to select/deselect multiple entries
|
||||
|
|
|
@ -102,11 +102,7 @@ dependencies {
|
|||
implementation 'androidx.exifinterface:exifinterface:1.3.2'
|
||||
implementation 'com.commonsware.cwac:document:0.4.1'
|
||||
implementation 'com.drewnoakes:metadata-extractor:2.15.0'
|
||||
// as of v0.9.8.7, `Android-TiffBitmapFactory` master branch is set up to release and distribute via Bintray
|
||||
// as of 20201113, its `q_support` branch allows decoding TIFF without a `File`, but is not released
|
||||
// we forked it to bypass official releases, upgrading its Android/Gradle structure to make it compatible with JitPack
|
||||
// JitPack build result is available at https://jitpack.io/com/github/deckerst/Android-TiffBitmapFactory/<commit>/build.log
|
||||
implementation 'com.github.deckerst:Android-TiffBitmapFactory:7efb450636'
|
||||
implementation 'com.github.deckerst:Android-TiffBitmapFactory:f87db4305d' // forked, built by JitPack
|
||||
implementation 'com.github.bumptech.glide:glide:4.11.0'
|
||||
|
||||
kapt 'androidx.annotation:annotation:1.1.0'
|
||||
|
|
|
@ -13,7 +13,7 @@ import com.bumptech.glide.Glide
|
|||
import com.bumptech.glide.load.DecodeFormat
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||
import deckers.thibault.aves.utils.LogUtils.createTag
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||
|
@ -257,7 +257,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
|
||||
companion object {
|
||||
private val LOG_TAG = createTag(AppAdapterHandler::class.java)
|
||||
private val LOG_TAG = LogUtils.createTag(AppAdapterHandler::class.java)
|
||||
const val CHANNEL = "deckers.thibault/aves/app"
|
||||
}
|
||||
}
|
|
@ -251,7 +251,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
|||
TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
||||
metadataMap["0"] = tiffOptionsToMap(options)
|
||||
val dirCount = options.outDirectoryCount
|
||||
for (i in 1 until dirCount) {
|
||||
for (page in 1 until dirCount) {
|
||||
fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd()
|
||||
if (fd == null) {
|
||||
result.error("getTiffStructure-fd", "failed to get file descriptor", null)
|
||||
|
@ -259,10 +259,10 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
options = TiffBitmapFactory.Options().apply {
|
||||
inJustDecodeBounds = true
|
||||
inDirectoryNumber = i
|
||||
inDirectoryNumber = page
|
||||
}
|
||||
TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
||||
metadataMap["$i"] = tiffOptionsToMap(options)
|
||||
metadataMap["$page"] = tiffOptionsToMap(options)
|
||||
}
|
||||
result.success(metadataMap)
|
||||
} catch (e: Exception) {
|
||||
|
|
|
@ -58,6 +58,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
|
|||
val isFlipped = call.argument<Boolean>("isFlipped")
|
||||
val widthDip = call.argument<Double>("widthDip")
|
||||
val heightDip = call.argument<Double>("heightDip")
|
||||
val page = call.argument<Int>("page")
|
||||
val defaultSizeDip = call.argument<Double>("defaultSizeDip")
|
||||
|
||||
if (uri == null || mimeType == null || dateModifiedSecs == null || rotationDegrees == null || isFlipped == null || widthDip == null || heightDip == null || defaultSizeDip == null) {
|
||||
|
@ -75,6 +76,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
|
|||
isFlipped,
|
||||
width = (widthDip * density).roundToInt(),
|
||||
height = (heightDip * density).roundToInt(),
|
||||
page = page,
|
||||
defaultSize = (defaultSizeDip * density).roundToInt(),
|
||||
result,
|
||||
).fetch()
|
||||
|
@ -83,6 +85,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
|
|||
private fun getRegion(call: MethodCall, result: MethodChannel.Result) {
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val page = call.argument<Int>("page")
|
||||
val sampleSize = call.argument<Int>("sampleSize")
|
||||
val x = call.argument<Int>("regionX")
|
||||
val y = call.argument<Int>("regionY")
|
||||
|
@ -102,7 +105,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
|
|||
uri,
|
||||
sampleSize,
|
||||
regionRect,
|
||||
page = 0,
|
||||
page = page ?: 0,
|
||||
result,
|
||||
)
|
||||
else -> regionFetcher.fetch(
|
||||
|
|
|
@ -11,10 +11,7 @@ import com.adobe.internal.xmp.properties.XMPPropertyInfo
|
|||
import com.bumptech.glide.load.resource.bitmap.TransformationUtils
|
||||
import com.drew.imaging.ImageMetadataReader
|
||||
import com.drew.lang.Rational
|
||||
import com.drew.metadata.exif.ExifDirectoryBase
|
||||
import com.drew.metadata.exif.ExifIFD0Directory
|
||||
import com.drew.metadata.exif.ExifSubIFDDirectory
|
||||
import com.drew.metadata.exif.GpsDirectory
|
||||
import com.drew.metadata.exif.*
|
||||
import com.drew.metadata.file.FileTypeDirectory
|
||||
import com.drew.metadata.gif.GifAnimationDirectory
|
||||
import com.drew.metadata.iptc.IptcDirectory
|
||||
|
@ -61,6 +58,7 @@ import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
|||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
import kotlin.math.roundToLong
|
||||
|
@ -71,6 +69,8 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
"getAllMetadata" -> GlobalScope.launch(Dispatchers.IO) { getAllMetadata(call, Coresult(result)) }
|
||||
"getCatalogMetadata" -> GlobalScope.launch(Dispatchers.IO) { getCatalogMetadata(call, Coresult(result)) }
|
||||
"getOverlayMetadata" -> GlobalScope.launch(Dispatchers.IO) { getOverlayMetadata(call, Coresult(result)) }
|
||||
"getMultiPageInfo" -> GlobalScope.launch(Dispatchers.IO) { getMultiPageInfo(call, Coresult(result)) }
|
||||
"getPanoramaInfo" -> GlobalScope.launch(Dispatchers.IO) { getPanoramaInfo(call, Coresult(result)) }
|
||||
"getEmbeddedPictures" -> GlobalScope.launch(Dispatchers.IO) { getEmbeddedPictures(call, Coresult(result)) }
|
||||
"getExifThumbnails" -> GlobalScope.launch(Dispatchers.IO) { getExifThumbnails(call, Coresult(result)) }
|
||||
"extractXmpDataProp" -> GlobalScope.launch(Dispatchers.IO) { extractXmpDataProp(call, Coresult(result)) }
|
||||
|
@ -108,12 +108,12 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
metadataMap[dirName] = dirMap
|
||||
|
||||
// tags
|
||||
if (mimeType == MimeTypes.TIFF && dir is ExifIFD0Directory) {
|
||||
if (mimeType == MimeTypes.TIFF && (dir is ExifIFD0Directory || dir is ExifThumbnailDirectory)) {
|
||||
dirMap.putAll(dir.tags.map {
|
||||
val name = if (it.hasTagName()) {
|
||||
it.tagName
|
||||
} else {
|
||||
Geotiff.getTagName(it.tagType) ?: it.tagName
|
||||
TiffTags.getTagName(it.tagType) ?: it.tagName
|
||||
}
|
||||
Pair(name, it.description)
|
||||
})
|
||||
|
@ -230,19 +230,24 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
return
|
||||
}
|
||||
|
||||
val metadataMap = HashMap(getCatalogMetadataByMetadataExtractor(uri, mimeType, path, sizeBytes))
|
||||
val metadataMap = HashMap<String, Any>()
|
||||
getCatalogMetadataByMetadataExtractor(uri, mimeType, path, sizeBytes, metadataMap)
|
||||
if (isMultimedia(mimeType)) {
|
||||
metadataMap.putAll(getMultimediaCatalogMetadataByMediaMetadataRetriever(uri))
|
||||
getMultimediaCatalogMetadataByMediaMetadataRetriever(uri, mimeType, metadataMap)
|
||||
}
|
||||
|
||||
// report success even when empty
|
||||
result.success(metadataMap)
|
||||
}
|
||||
|
||||
private fun getCatalogMetadataByMetadataExtractor(uri: Uri, mimeType: String, path: String?, sizeBytes: Long?): Map<String, Any> {
|
||||
val metadataMap = HashMap<String, Any>()
|
||||
|
||||
var flags = 0
|
||||
private fun getCatalogMetadataByMetadataExtractor(
|
||||
uri: Uri,
|
||||
mimeType: String,
|
||||
path: String?,
|
||||
sizeBytes: Long?,
|
||||
metadataMap: HashMap<String, Any>,
|
||||
) {
|
||||
var flags = (metadataMap[KEY_FLAGS] ?: 0) as Int
|
||||
var foundExif = false
|
||||
|
||||
if (isSupportedByMetadataExtractor(mimeType)) {
|
||||
|
@ -390,13 +395,20 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
Log.w(LOG_TAG, "failed to get metadata by ExifInterface for uri=$uri", e)
|
||||
}
|
||||
}
|
||||
|
||||
if (mimeType == MimeTypes.TIFF && isMultiPageTiff(uri)) flags = flags or MASK_IS_MULTIPAGE
|
||||
|
||||
metadataMap[KEY_FLAGS] = flags
|
||||
return metadataMap
|
||||
}
|
||||
|
||||
private fun getMultimediaCatalogMetadataByMediaMetadataRetriever(uri: Uri): Map<String, Any> {
|
||||
val metadataMap = HashMap<String, Any>()
|
||||
val retriever = StorageUtils.openMetadataRetriever(context, uri) ?: return metadataMap
|
||||
private fun getMultimediaCatalogMetadataByMediaMetadataRetriever(
|
||||
uri: Uri,
|
||||
mimeType: String,
|
||||
metadataMap: HashMap<String, Any>,
|
||||
) {
|
||||
val retriever = StorageUtils.openMetadataRetriever(context, uri) ?: return
|
||||
|
||||
var flags = (metadataMap[KEY_FLAGS] ?: 0) as Int
|
||||
try {
|
||||
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) { metadataMap[KEY_ROTATION_DEGREES] = it }
|
||||
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
|
||||
|
@ -417,13 +429,20 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mimeType == MimeTypes.HEIC || mimeType == MimeTypes.HEIF) {
|
||||
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS) {
|
||||
if (it > 1) flags = flags or MASK_IS_MULTIPAGE
|
||||
}
|
||||
}
|
||||
|
||||
metadataMap[KEY_FLAGS] = flags
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to get catalog metadata by MediaMetadataRetriever for uri=$uri", e)
|
||||
} finally {
|
||||
// cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs
|
||||
retriever.release()
|
||||
}
|
||||
return metadataMap
|
||||
}
|
||||
|
||||
private fun getOverlayMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||
|
@ -494,6 +513,73 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
result.success(metadataMap)
|
||||
}
|
||||
|
||||
private fun getMultiPageInfo(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("getMultiPageInfo-args", "failed because of missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
val pages = HashMap<Int, Any>()
|
||||
if (mimeType == MimeTypes.TIFF) {
|
||||
fun toMap(options: TiffBitmapFactory.Options): Map<String, Any> {
|
||||
return hashMapOf(
|
||||
"width" to options.outWidth,
|
||||
"height" to options.outHeight,
|
||||
)
|
||||
}
|
||||
getTiffPageInfo(uri, 0)?.let { first ->
|
||||
pages[0] = toMap(first)
|
||||
val pageCount = first.outDirectoryCount
|
||||
for (i in 1 until pageCount) {
|
||||
getTiffPageInfo(uri, i)?.let { pages[i] = toMap(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
result.success(pages)
|
||||
}
|
||||
|
||||
private fun getPanoramaInfo(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||
if (mimeType == null || uri == null) {
|
||||
result.error("getPanoramaInfo-args", "failed because of missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
if (isSupportedByMetadataExtractor(mimeType)) {
|
||||
try {
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
val metadata = ImageMetadataReader.readMetadata(input)
|
||||
val xmpDirs = metadata.getDirectoriesOfType(XmpDirectory::class.java)
|
||||
try {
|
||||
fun getProp(propName: String): Int? = xmpDirs.map { it.xmpMeta.getPropertyInteger(XMP.GPANO_SCHEMA_NS, propName) }.firstOrNull { it != null }
|
||||
val fields: FieldMap = hashMapOf(
|
||||
"croppedAreaLeft" to getProp(XMP.GPANO_CROPPED_AREA_LEFT_PROP_NAME),
|
||||
"croppedAreaTop" to getProp(XMP.GPANO_CROPPED_AREA_TOP_PROP_NAME),
|
||||
"croppedAreaWidth" to getProp(XMP.GPANO_CROPPED_AREA_WIDTH_PROP_NAME),
|
||||
"croppedAreaHeight" to getProp(XMP.GPANO_CROPPED_AREA_HEIGHT_PROP_NAME),
|
||||
"fullPanoWidth" to getProp(XMP.GPANO_FULL_PANO_WIDTH_PROP_NAME),
|
||||
"fullPanoHeight" to getProp(XMP.GPANO_FULL_PANO_HEIGHT_PROP_NAME),
|
||||
)
|
||||
result.success(fields)
|
||||
return
|
||||
} catch (e: XMPException) {
|
||||
result.error("getPanoramaInfo-args", "failed to read XMP for uri=$uri", e.message)
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to read XMP", e)
|
||||
} catch (e: NoClassDefFoundError) {
|
||||
Log.w(LOG_TAG, "failed to read XMP", e)
|
||||
}
|
||||
}
|
||||
result.error("getPanoramaInfo-empty", "failed to read XMP from uri=$uri", null)
|
||||
}
|
||||
|
||||
private fun getEmbeddedPictures(call: MethodCall, result: MethodChannel.Result) {
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
if (uri == null) {
|
||||
|
@ -533,7 +619,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
|
||||
exif.thumbnailBitmap?.let { bitmap ->
|
||||
TransformationUtils.rotateImageExif(BitmapUtils.getBitmapPool(context), bitmap, orientation)?.let {
|
||||
thumbnails.add(it.getBytes(canHaveAlpha = false, recycle = false))
|
||||
it.getBytes(canHaveAlpha = false, recycle = false)?.let { thumbnails.add(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -622,6 +708,27 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
result.error("extractXmpDataProp-empty", "failed to extract file from XMP uri=$uri prop=$dataPropPath", null)
|
||||
}
|
||||
|
||||
private fun isMultiPageTiff(uri: Uri) = getTiffPageInfo(uri, 0)?.outDirectoryCount ?: 1 > 1
|
||||
|
||||
private fun getTiffPageInfo(uri: Uri, page: Int): TiffBitmapFactory.Options? {
|
||||
try {
|
||||
val fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd()
|
||||
if (fd == null) {
|
||||
Log.w(LOG_TAG, "failed to get file descriptor for uri=$uri")
|
||||
return null
|
||||
}
|
||||
val options = TiffBitmapFactory.Options().apply {
|
||||
inJustDecodeBounds = true
|
||||
inDirectoryNumber = page
|
||||
}
|
||||
TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
||||
return options
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to get TIFF page info for uri=$uri page=$page", e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val LOG_TAG = LogUtils.createTag(MetadataHandler::class.java)
|
||||
const val CHANNEL = "deckers.thibault/aves/metadata"
|
||||
|
@ -640,6 +747,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
private const val MASK_IS_FLIPPED = 1 shl 1
|
||||
private const val MASK_IS_GEOTIFF = 1 shl 2
|
||||
private const val MASK_IS_360 = 1 shl 3
|
||||
private const val MASK_IS_MULTIPAGE = 1 shl 4
|
||||
private const val XMP_SUBJECTS_SEPARATOR = ";"
|
||||
|
||||
// overlay metadata
|
||||
|
|
|
@ -32,12 +32,14 @@ class ThumbnailFetcher internal constructor(
|
|||
private val isFlipped: Boolean,
|
||||
width: Int?,
|
||||
height: Int?,
|
||||
page: Int?,
|
||||
private val defaultSize: Int,
|
||||
private val result: MethodChannel.Result,
|
||||
) {
|
||||
val uri: Uri = Uri.parse(uri)
|
||||
val width: Int = if (width?.takeIf { it > 0 } != null) width else defaultSize
|
||||
val height: Int = if (height?.takeIf { it > 0 } != null) height else defaultSize
|
||||
private val uri: Uri = Uri.parse(uri)
|
||||
private val width: Int = if (width?.takeIf { it > 0 } != null) width else defaultSize
|
||||
private val height: Int = if (height?.takeIf { it > 0 } != null) height else defaultSize
|
||||
private val page = page ?: 0
|
||||
|
||||
fun fetch() {
|
||||
var bitmap: Bitmap? = null
|
||||
|
@ -108,7 +110,7 @@ class ThumbnailFetcher internal constructor(
|
|||
// add signature to ignore cache for images which got modified but kept the same URI
|
||||
var options = RequestOptions()
|
||||
.format(DecodeFormat.PREFER_RGB_565)
|
||||
.signature(ObjectKey("$dateModifiedSecs-$rotationDegrees-$isFlipped-$width"))
|
||||
.signature(ObjectKey("$dateModifiedSecs-$rotationDegrees-$isFlipped-$width-$page"))
|
||||
.override(width, height)
|
||||
|
||||
val target = if (isVideo(mimeType)) {
|
||||
|
@ -119,7 +121,7 @@ class ThumbnailFetcher internal constructor(
|
|||
.load(VideoThumbnail(context, uri))
|
||||
.submit(width, height)
|
||||
} else {
|
||||
val model: Any = if (mimeType == MimeTypes.TIFF) TiffThumbnail(context, uri) else uri
|
||||
val model: Any = if (mimeType == MimeTypes.TIFF) TiffThumbnail(context, uri, page) else uri
|
||||
Glide.with(context)
|
||||
.asBitmap()
|
||||
.apply(options)
|
||||
|
|
|
@ -4,6 +4,7 @@ import android.app.Activity
|
|||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.DecodeFormat
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
|
@ -11,6 +12,7 @@ import com.bumptech.glide.request.RequestOptions
|
|||
import deckers.thibault.aves.decoder.VideoThumbnail
|
||||
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
|
||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.MimeTypes.isSupportedByFlutter
|
||||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||
|
@ -38,16 +40,34 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
|||
|
||||
override fun onCancel(o: Any) {}
|
||||
|
||||
private fun success(bytes: ByteArray) {
|
||||
handler.post { eventSink.success(bytes) }
|
||||
private fun success(bytes: ByteArray?) {
|
||||
handler.post {
|
||||
try {
|
||||
eventSink.success(bytes)
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to use event sink", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun error(errorCode: String, errorMessage: String, errorDetails: Any?) {
|
||||
handler.post { eventSink.error(errorCode, errorMessage, errorDetails) }
|
||||
handler.post {
|
||||
try {
|
||||
eventSink.error(errorCode, errorMessage, errorDetails)
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to use event sink", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun endOfStream() {
|
||||
handler.post { eventSink.endOfStream() }
|
||||
handler.post {
|
||||
try {
|
||||
eventSink.endOfStream()
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to use event sink", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Supported image formats:
|
||||
|
@ -64,6 +84,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
|||
val uri = (arguments["uri"] as String?)?.let { Uri.parse(it) }
|
||||
val rotationDegrees = arguments["rotationDegrees"] as Int
|
||||
val isFlipped = arguments["isFlipped"] as Boolean
|
||||
val page = arguments["page"] as Int
|
||||
|
||||
if (mimeType == null || uri == null) {
|
||||
error("streamImage-args", "failed because of missing arguments", null)
|
||||
|
@ -74,7 +95,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
|||
if (isVideo(mimeType)) {
|
||||
streamVideoByGlide(uri)
|
||||
} else if (mimeType == MimeTypes.TIFF) {
|
||||
streamTiffImage(uri)
|
||||
streamTiffImage(uri, page)
|
||||
} else if (!isSupportedByFlutter(mimeType, rotationDegrees, isFlipped)) {
|
||||
// decode exotic format on platform side, then encode it in portable format for Flutter
|
||||
streamImageByGlide(uri, mimeType, rotationDegrees, isFlipped)
|
||||
|
@ -139,34 +160,19 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
|||
private fun streamTiffImage(uri: Uri, page: Int = 0) {
|
||||
val resolver = activity.contentResolver
|
||||
try {
|
||||
var fd = resolver.openFileDescriptor(uri, "r")?.detachFd()
|
||||
val fd = resolver.openFileDescriptor(uri, "r")?.detachFd()
|
||||
if (fd == null) {
|
||||
error("streamImage-tiff-fd", "failed to get file descriptor for uri=$uri", null)
|
||||
return
|
||||
}
|
||||
var options = TiffBitmapFactory.Options().apply {
|
||||
inJustDecodeBounds = true
|
||||
val options = TiffBitmapFactory.Options().apply {
|
||||
inDirectoryNumber = page
|
||||
}
|
||||
TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
||||
val dirCount = options.outDirectoryCount
|
||||
|
||||
// TODO TLAD handle multipage TIFF
|
||||
if (dirCount > page) {
|
||||
fd = resolver.openFileDescriptor(uri, "r")?.detachFd()
|
||||
if (fd == null) {
|
||||
error("streamImage-tiff-fd", "failed to get file descriptor for uri=$uri", null)
|
||||
return
|
||||
}
|
||||
options = TiffBitmapFactory.Options().apply {
|
||||
inJustDecodeBounds = false
|
||||
inDirectoryNumber = page
|
||||
}
|
||||
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)
|
||||
}
|
||||
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) {
|
||||
error("streamImage-tiff-exception", "failed to get image from uri=$uri", toErrorDetails(e))
|
||||
|
@ -192,6 +198,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
|||
}
|
||||
|
||||
companion object {
|
||||
private val LOG_TAG = LogUtils.createTag(ImageByteStreamHandler::class.java)
|
||||
const val CHANNEL = "deckers.thibault/aves/imagebytestream"
|
||||
|
||||
const val bufferSize = 2 shl 17 // 256kB
|
||||
|
|
|
@ -9,7 +9,7 @@ import deckers.thibault.aves.model.AvesImageEntry
|
|||
import deckers.thibault.aves.model.provider.FieldMap
|
||||
import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback
|
||||
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
|
||||
import deckers.thibault.aves.utils.LogUtils.createTag
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.EventChannel.EventSink
|
||||
|
@ -51,15 +51,33 @@ class ImageOpStreamHandler(private val context: Context, private val arguments:
|
|||
|
||||
// {String uri, bool success, [Map<String, Object> newFields]}
|
||||
private fun success(result: Map<String, *>) {
|
||||
handler.post { eventSink.success(result) }
|
||||
handler.post {
|
||||
try {
|
||||
eventSink.success(result)
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to use event sink", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun error(errorCode: String, errorMessage: String, errorDetails: Any?) {
|
||||
handler.post { eventSink.error(errorCode, errorMessage, errorDetails) }
|
||||
handler.post {
|
||||
try {
|
||||
eventSink.error(errorCode, errorMessage, errorDetails)
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to use event sink", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun endOfStream() {
|
||||
handler.post { eventSink.endOfStream() }
|
||||
handler.post {
|
||||
try {
|
||||
eventSink.endOfStream()
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to use event sink", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun move() {
|
||||
|
@ -127,7 +145,7 @@ class ImageOpStreamHandler(private val context: Context, private val arguments:
|
|||
}
|
||||
|
||||
companion object {
|
||||
private val LOG_TAG = createTag(ImageOpStreamHandler::class.java)
|
||||
private val LOG_TAG = LogUtils.createTag(ImageOpStreamHandler::class.java)
|
||||
const val CHANNEL = "deckers.thibault/aves/imageopstream"
|
||||
}
|
||||
}
|
|
@ -3,8 +3,10 @@ package deckers.thibault.aves.channel.streams
|
|||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import deckers.thibault.aves.model.provider.FieldMap
|
||||
import deckers.thibault.aves.model.provider.MediaStoreImageProvider
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.EventChannel.EventSink
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
@ -34,11 +36,23 @@ class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : E
|
|||
override fun onCancel(arguments: Any?) {}
|
||||
|
||||
private fun success(result: FieldMap) {
|
||||
handler.post { eventSink.success(result) }
|
||||
handler.post {
|
||||
try {
|
||||
eventSink.success(result)
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to use event sink", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun endOfStream() {
|
||||
handler.post { eventSink.endOfStream() }
|
||||
handler.post {
|
||||
try {
|
||||
eventSink.endOfStream()
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to use event sink", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchAll() {
|
||||
|
@ -47,6 +61,7 @@ class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : E
|
|||
}
|
||||
|
||||
companion object {
|
||||
private val LOG_TAG = LogUtils.createTag(MediaStoreStreamHandler::class.java)
|
||||
const val CHANNEL = "deckers.thibault/aves/mediastorestream"
|
||||
}
|
||||
}
|
|
@ -3,6 +3,8 @@ package deckers.thibault.aves.channel.streams
|
|||
import android.app.Activity
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.PermissionManager.requestVolumeAccess
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.EventChannel.EventSink
|
||||
|
@ -30,15 +32,28 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
|
|||
override fun onCancel(arguments: Any?) {}
|
||||
|
||||
private fun success(result: Boolean) {
|
||||
handler.post { eventSink.success(result) }
|
||||
handler.post {
|
||||
try {
|
||||
eventSink.success(result)
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to use event sink", e)
|
||||
}
|
||||
}
|
||||
endOfStream()
|
||||
}
|
||||
|
||||
private fun endOfStream() {
|
||||
handler.post { eventSink.endOfStream() }
|
||||
handler.post {
|
||||
try {
|
||||
eventSink.endOfStream()
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to use event sink", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val LOG_TAG = LogUtils.createTag(StorageAccessStreamHandler::class.java)
|
||||
const val CHANNEL = "deckers.thibault/aves/storageaccessstream"
|
||||
}
|
||||
}
|
|
@ -26,7 +26,7 @@ class TiffThumbnailGlideModule : LibraryGlideModule() {
|
|||
}
|
||||
}
|
||||
|
||||
class TiffThumbnail(val context: Context, val uri: Uri)
|
||||
class TiffThumbnail(val context: Context, val uri: Uri, val page: Int)
|
||||
|
||||
internal class TiffThumbnailLoader : ModelLoader<TiffThumbnail, InputStream> {
|
||||
override fun buildLoadData(model: TiffThumbnail, width: Int, height: Int, options: Options): ModelLoader.LoadData<InputStream> {
|
||||
|
@ -46,6 +46,7 @@ internal class TiffThumbnailFetcher(val model: TiffThumbnail, val width: Int, va
|
|||
override fun loadData(priority: Priority, callback: DataCallback<in InputStream>) {
|
||||
val context = model.context
|
||||
val uri = model.uri
|
||||
val page = model.page
|
||||
|
||||
// determine sample size
|
||||
var fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd()
|
||||
|
@ -56,6 +57,7 @@ internal class TiffThumbnailFetcher(val model: TiffThumbnail, val width: Int, va
|
|||
var sampleSize = 1
|
||||
var options = TiffBitmapFactory.Options().apply {
|
||||
inJustDecodeBounds = true
|
||||
inDirectoryNumber = page
|
||||
}
|
||||
TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
||||
val imageWidth = options.outWidth
|
||||
|
@ -74,13 +76,14 @@ internal class TiffThumbnailFetcher(val model: TiffThumbnail, val width: Int, va
|
|||
}
|
||||
options = TiffBitmapFactory.Options().apply {
|
||||
inJustDecodeBounds = false
|
||||
inDirectoryNumber = page
|
||||
inSampleSize = sampleSize
|
||||
}
|
||||
val bitmap = TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
||||
if (bitmap == null) {
|
||||
callback.onLoadFailed(Exception("null bitmap"))
|
||||
} else {
|
||||
callback.onDataReady(bitmap.getBytes().inputStream())
|
||||
callback.onDataReady(bitmap.getBytes()?.inputStream())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,52 +0,0 @@
|
|||
package deckers.thibault.aves.metadata
|
||||
|
||||
object Geotiff {
|
||||
// ModelPixelScaleTag (optional)
|
||||
// Tag = 33550 (830E.H)
|
||||
// Type = DOUBLE
|
||||
// Count = 3
|
||||
const val TAG_MODEL_PIXEL_SCALE = 0x830e
|
||||
|
||||
// ModelTiepointTag (conditional)
|
||||
// Tag = 33922 (8482.H)
|
||||
// Type = DOUBLE
|
||||
// Count = 6*K, K = number of tiepoints
|
||||
const val TAG_MODEL_TIEPOINT = 0x8482
|
||||
|
||||
// ModelTransformationTag (conditional)
|
||||
// Tag = 34264 (85D8.H)
|
||||
// Type = DOUBLE
|
||||
// Count = 16
|
||||
const val TAG_MODEL_TRANSFORMATION = 0x85d8
|
||||
|
||||
// GeoKeyDirectoryTag (mandatory)
|
||||
// Tag = 34735 (87AF.H)
|
||||
// Type = UNSIGNED SHORT
|
||||
// Count = variable, >= 4
|
||||
const val TAG_GEO_KEY_DIRECTORY = 0x87af
|
||||
|
||||
// GeoDoubleParamsTag (optional)
|
||||
// Tag = 34736 (87BO.H)
|
||||
// Type = DOUBLE
|
||||
// Count = variable
|
||||
const val TAG_GEO_DOUBLE_PARAMS = 0x87b0
|
||||
|
||||
// GeoAsciiParamsTag (optional)
|
||||
// Tag = 34737 (87B1.H)
|
||||
// Type = ASCII
|
||||
// Count = variable
|
||||
val TAG_GEO_ASCII_PARAMS = 0x87b1
|
||||
|
||||
private val tagNameMap = hashMapOf(
|
||||
TAG_GEO_ASCII_PARAMS to "Geo Ascii Params",
|
||||
TAG_GEO_DOUBLE_PARAMS to "Geo Double Params",
|
||||
TAG_GEO_KEY_DIRECTORY to "Geo Key Directory",
|
||||
TAG_MODEL_PIXEL_SCALE to "Model Pixel Scale",
|
||||
TAG_MODEL_TIEPOINT to "Model Tiepoint",
|
||||
TAG_MODEL_TRANSFORMATION to "Model Transformation",
|
||||
)
|
||||
|
||||
fun getTagName(tag: Int): String? {
|
||||
return tagNameMap[tag]
|
||||
}
|
||||
}
|
|
@ -45,13 +45,13 @@ object MetadataExtractorHelper {
|
|||
- If the ModelPixelScaleTag is included in an IFD, then a ModelTiepointTag SHALL also be included.
|
||||
*/
|
||||
fun ExifIFD0Directory.isGeoTiff(): Boolean {
|
||||
if (!this.containsTag(Geotiff.TAG_GEO_KEY_DIRECTORY)) return false
|
||||
if (!this.containsTag(TiffTags.TAG_GEO_KEY_DIRECTORY)) return false
|
||||
|
||||
val modelTiepoint = this.containsTag(Geotiff.TAG_MODEL_TIEPOINT)
|
||||
val modelTransformation = this.containsTag(Geotiff.TAG_MODEL_TRANSFORMATION)
|
||||
val modelTiepoint = this.containsTag(TiffTags.TAG_MODEL_TIEPOINT)
|
||||
val modelTransformation = this.containsTag(TiffTags.TAG_MODEL_TRANSFORMATION)
|
||||
if (!modelTiepoint && !modelTransformation) return false
|
||||
|
||||
val modelPixelScale = this.containsTag(Geotiff.TAG_MODEL_PIXEL_SCALE)
|
||||
val modelPixelScale = this.containsTag(TiffTags.TAG_MODEL_PIXEL_SCALE)
|
||||
if ((modelTransformation && modelPixelScale) || (modelPixelScale && !modelTiepoint)) return false
|
||||
|
||||
return true
|
||||
|
|
|
@ -0,0 +1,138 @@
|
|||
package deckers.thibault.aves.metadata
|
||||
|
||||
object TiffTags {
|
||||
// XPosition
|
||||
// Tag = 286 (011E.H)
|
||||
const val TAG_X_POSITION = 0x011e
|
||||
|
||||
// YPosition
|
||||
// Tag = 287 (011F.H)
|
||||
const val TAG_Y_POSITION = 0x011f
|
||||
|
||||
// ColorMap
|
||||
// Tag = 320 (0140.H)
|
||||
const val TAG_COLOR_MAP = 0x0140
|
||||
|
||||
// ExtraSamples
|
||||
// Tag = 338 (0152.H)
|
||||
// values:
|
||||
// EXTRASAMPLE_UNSPECIFIED 0 // unspecified data
|
||||
// EXTRASAMPLE_ASSOCALPHA 1 // associated alpha data
|
||||
// EXTRASAMPLE_UNASSALPHA 2 // unassociated alpha data
|
||||
const val TAG_EXTRA_SAMPLES = 0x0152
|
||||
|
||||
// SampleFormat
|
||||
// Tag = 339 (0153.H)
|
||||
// values:
|
||||
// SAMPLEFORMAT_UINT 1 // unsigned integer data
|
||||
// SAMPLEFORMAT_INT 2 // signed integer data
|
||||
// SAMPLEFORMAT_IEEEFP 3 // IEEE floating point data
|
||||
// SAMPLEFORMAT_VOID 4 // untyped data
|
||||
// SAMPLEFORMAT_COMPLEXINT 5 // complex signed int
|
||||
// SAMPLEFORMAT_COMPLEXIEEEFP 6 // complex ieee floating
|
||||
const val TAG_SAMPLE_FORMAT = 0x0153
|
||||
|
||||
/*
|
||||
SGI
|
||||
tags 32995-32999
|
||||
*/
|
||||
|
||||
// Matteing
|
||||
// Tag = 32995 (80E3.H)
|
||||
// obsoleted by the 6.0 ExtraSamples (338)
|
||||
val TAG_MATTEING = 0x80e3
|
||||
|
||||
/*
|
||||
GeoTIFF
|
||||
*/
|
||||
|
||||
// ModelPixelScaleTag (optional)
|
||||
// Tag = 33550 (830E.H)
|
||||
// Type = DOUBLE
|
||||
// Count = 3
|
||||
const val TAG_MODEL_PIXEL_SCALE = 0x830e
|
||||
|
||||
// ModelTiepointTag (conditional)
|
||||
// Tag = 33922 (8482.H)
|
||||
// Type = DOUBLE
|
||||
// Count = 6*K, K = number of tiepoints
|
||||
const val TAG_MODEL_TIEPOINT = 0x8482
|
||||
|
||||
// ModelTransformationTag (conditional)
|
||||
// Tag = 34264 (85D8.H)
|
||||
// Type = DOUBLE
|
||||
// Count = 16
|
||||
const val TAG_MODEL_TRANSFORMATION = 0x85d8
|
||||
|
||||
// GeoKeyDirectoryTag (mandatory)
|
||||
// Tag = 34735 (87AF.H)
|
||||
// Type = UNSIGNED SHORT
|
||||
// Count = variable, >= 4
|
||||
const val TAG_GEO_KEY_DIRECTORY = 0x87af
|
||||
|
||||
// GeoDoubleParamsTag (optional)
|
||||
// Tag = 34736 (87BO.H)
|
||||
// Type = DOUBLE
|
||||
// Count = variable
|
||||
const val TAG_GEO_DOUBLE_PARAMS = 0x87b0
|
||||
|
||||
// GeoAsciiParamsTag (optional)
|
||||
// Tag = 34737 (87B1.H)
|
||||
// Type = ASCII
|
||||
// Count = variable
|
||||
val TAG_GEO_ASCII_PARAMS = 0x87b1
|
||||
|
||||
/*
|
||||
Photoshop
|
||||
https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/
|
||||
https://www.adobe.io/content/dam/udp/en/open/standards/tiff/TIFFphotoshop.pdf
|
||||
*/
|
||||
|
||||
// ImageSourceData
|
||||
// Tag = 37724 (935C.H)
|
||||
// Type = UNDEFINED
|
||||
val TAG_IMAGE_SOURCE_DATA = 0x935c
|
||||
|
||||
/*
|
||||
DNG
|
||||
https://www.adobe.com/content/dam/acom/en/products/photoshop/pdfs/dng_spec_1.4.0.0.pdf
|
||||
*/
|
||||
|
||||
// CameraSerialNumber
|
||||
// Tag = 50735 (C62F.H)
|
||||
// Type = ASCII
|
||||
// Count = variable
|
||||
val TAG_CAMERA_SERIAL_NUMBER = 0xc62f
|
||||
|
||||
// OriginalRawFileName (optional)
|
||||
// Tag = 50827 (C68B.H)
|
||||
// Type = ASCII or BYTE
|
||||
// Count = variable
|
||||
val TAG_ORIGINAL_RAW_FILE_NAME = 0xc68b
|
||||
|
||||
private val tagNameMap = hashMapOf(
|
||||
TAG_X_POSITION to "X Position",
|
||||
TAG_Y_POSITION to "Y Position",
|
||||
TAG_COLOR_MAP to "Color Map",
|
||||
TAG_EXTRA_SAMPLES to "Extra Samples",
|
||||
TAG_SAMPLE_FORMAT to "Sample Format",
|
||||
// SGI
|
||||
TAG_MATTEING to "Matteing",
|
||||
// GeoTIFF
|
||||
TAG_GEO_ASCII_PARAMS to "Geo Ascii Params",
|
||||
TAG_GEO_DOUBLE_PARAMS to "Geo Double Params",
|
||||
TAG_GEO_KEY_DIRECTORY to "Geo Key Directory",
|
||||
TAG_MODEL_PIXEL_SCALE to "Model Pixel Scale",
|
||||
TAG_MODEL_TIEPOINT to "Model Tiepoint",
|
||||
TAG_MODEL_TRANSFORMATION to "Model Transformation",
|
||||
// Photoshop
|
||||
TAG_IMAGE_SOURCE_DATA to "Image Source Data",
|
||||
// DNG
|
||||
TAG_CAMERA_SERIAL_NUMBER to "Camera Serial Number",
|
||||
TAG_ORIGINAL_RAW_FILE_NAME to "Original Raw File Name",
|
||||
)
|
||||
|
||||
fun getTagName(tag: Int): String? {
|
||||
return tagNameMap[tag]
|
||||
}
|
||||
}
|
|
@ -42,15 +42,15 @@ object XMP {
|
|||
// panorama
|
||||
// cf https://developers.google.com/streetview/spherical-metadata
|
||||
|
||||
private const val GPANO_SCHEMA_NS = "http://ns.google.com/photos/1.0/panorama/"
|
||||
const val GPANO_SCHEMA_NS = "http://ns.google.com/photos/1.0/panorama/"
|
||||
private const val PMTM_SCHEMA_NS = "http://www.hdrsoft.com/photomatix_settings01"
|
||||
|
||||
private const val GPANO_CROPPED_AREA_HEIGHT_PROP_NAME = "GPano:CroppedAreaImageHeightPixels"
|
||||
private const val GPANO_CROPPED_AREA_WIDTH_PROP_NAME = "GPano:CroppedAreaImageWidthPixels"
|
||||
private const val GPANO_CROPPED_AREA_LEFT_PROP_NAME = "GPano:CroppedAreaLeftPixels"
|
||||
private const val GPANO_CROPPED_AREA_TOP_PROP_NAME = "GPano:CroppedAreaTopPixels"
|
||||
private const val GPANO_FULL_PANO_HEIGHT_PIXELS_PROP_NAME = "GPano:FullPanoHeightPixels"
|
||||
private const val GPANO_FULL_PANO_WIDTH_PIXELS_PROP_NAME = "GPano:FullPanoWidthPixels"
|
||||
const val GPANO_CROPPED_AREA_HEIGHT_PROP_NAME = "GPano:CroppedAreaImageHeightPixels"
|
||||
const val GPANO_CROPPED_AREA_WIDTH_PROP_NAME = "GPano:CroppedAreaImageWidthPixels"
|
||||
const val GPANO_CROPPED_AREA_LEFT_PROP_NAME = "GPano:CroppedAreaLeftPixels"
|
||||
const val GPANO_CROPPED_AREA_TOP_PROP_NAME = "GPano:CroppedAreaTopPixels"
|
||||
const val GPANO_FULL_PANO_HEIGHT_PROP_NAME = "GPano:FullPanoHeightPixels"
|
||||
const val GPANO_FULL_PANO_WIDTH_PROP_NAME = "GPano:FullPanoWidthPixels"
|
||||
private const val GPANO_PROJECTION_TYPE_PROP_NAME = "GPano:ProjectionType"
|
||||
|
||||
private const val PMTM_IS_PANO360 = "pmtm:IsPano360"
|
||||
|
@ -60,8 +60,8 @@ object XMP {
|
|||
GPANO_CROPPED_AREA_WIDTH_PROP_NAME,
|
||||
GPANO_CROPPED_AREA_LEFT_PROP_NAME,
|
||||
GPANO_CROPPED_AREA_TOP_PROP_NAME,
|
||||
GPANO_FULL_PANO_HEIGHT_PIXELS_PROP_NAME,
|
||||
GPANO_FULL_PANO_WIDTH_PIXELS_PROP_NAME,
|
||||
GPANO_FULL_PANO_HEIGHT_PROP_NAME,
|
||||
GPANO_FULL_PANO_WIDTH_PROP_NAME,
|
||||
GPANO_PROJECTION_TYPE_PROP_NAME,
|
||||
)
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ import androidx.exifinterface.media.ExifInterface
|
|||
import com.commonsware.cwac.document.DocumentFileCompat
|
||||
import deckers.thibault.aves.model.AvesImageEntry
|
||||
import deckers.thibault.aves.model.ExifOrientationOp
|
||||
import deckers.thibault.aves.utils.LogUtils.createTag
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.MimeTypes.isImage
|
||||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||
import deckers.thibault.aves.utils.StorageUtils.copyFileToTemp
|
||||
|
@ -195,7 +195,7 @@ abstract class ImageProvider {
|
|||
}
|
||||
|
||||
companion object {
|
||||
private val LOG_TAG = createTag(ImageProvider::class.java)
|
||||
private val LOG_TAG = LogUtils.createTag(ImageProvider::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ import android.util.Log
|
|||
import com.commonsware.cwac.document.DocumentFileCompat
|
||||
import deckers.thibault.aves.model.AvesImageEntry
|
||||
import deckers.thibault.aves.model.SourceImageEntry
|
||||
import deckers.thibault.aves.utils.LogUtils.createTag
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.MimeTypes.isImage
|
||||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||
|
@ -312,7 +312,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
}
|
||||
|
||||
companion object {
|
||||
private val LOG_TAG = createTag(MediaStoreImageProvider::class.java)
|
||||
private val LOG_TAG = LogUtils.createTag(MediaStoreImageProvider::class.java)
|
||||
|
||||
private val IMAGE_CONTENT_URI = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||
private val VIDEO_CONTENT_URI = MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
||||
|
|
|
@ -2,23 +2,31 @@ package deckers.thibault.aves.utils
|
|||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.util.Log
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.resource.bitmap.TransformationUtils
|
||||
import deckers.thibault.aves.metadata.Metadata.getExifCode
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
object BitmapUtils {
|
||||
fun Bitmap.getBytes(canHaveAlpha: Boolean = false, quality: Int = 100, recycle: Boolean = true): ByteArray {
|
||||
val stream = ByteArrayOutputStream()
|
||||
// we compress the bitmap because Flutter cannot decode the raw bytes
|
||||
// `Bitmap.CompressFormat.PNG` is slower than `JPEG`, but it allows transparency
|
||||
if (canHaveAlpha) {
|
||||
this.compress(Bitmap.CompressFormat.PNG, quality, stream)
|
||||
} else {
|
||||
this.compress(Bitmap.CompressFormat.JPEG, quality, stream)
|
||||
private val LOG_TAG = LogUtils.createTag(BitmapUtils::class.java)
|
||||
|
||||
fun Bitmap.getBytes(canHaveAlpha: Boolean = false, quality: Int = 100, recycle: Boolean = true): ByteArray? {
|
||||
try {
|
||||
val stream = ByteArrayOutputStream()
|
||||
// we compress the bitmap because Flutter cannot decode the raw bytes
|
||||
// `Bitmap.CompressFormat.PNG` is slower than `JPEG`, but it allows transparency
|
||||
if (canHaveAlpha) {
|
||||
this.compress(Bitmap.CompressFormat.PNG, quality, stream)
|
||||
} else {
|
||||
this.compress(Bitmap.CompressFormat.JPEG, quality, stream)
|
||||
}
|
||||
if (recycle) this.recycle()
|
||||
return stream.toByteArray()
|
||||
} catch (e: IllegalStateException) {
|
||||
Log.e(LOG_TAG, "failed to get bytes from bitmap", e)
|
||||
}
|
||||
if (recycle) this.recycle()
|
||||
return stream.toByteArray()
|
||||
return null;
|
||||
}
|
||||
|
||||
fun applyExifOrientation(context: Context, bitmap: Bitmap?, rotationDegrees: Int?, isFlipped: Boolean?): Bitmap? {
|
||||
|
|
|
@ -8,8 +8,8 @@ object MimeTypes {
|
|||
// generic raster
|
||||
private const val BMP = "image/bmp"
|
||||
const val GIF = "image/gif"
|
||||
private const val HEIC = "image/heic"
|
||||
private const val HEIF = "image/heif"
|
||||
const val HEIC = "image/heic"
|
||||
const val HEIF = "image/heif"
|
||||
private const val ICO = "image/x-icon"
|
||||
private const val JPEG = "image/jpeg"
|
||||
private const val PNG = "image/png"
|
||||
|
|
|
@ -8,14 +8,13 @@ import android.os.Build
|
|||
import android.os.storage.StorageManager
|
||||
import android.util.Log
|
||||
import androidx.core.app.ActivityCompat
|
||||
import deckers.thibault.aves.utils.LogUtils.createTag
|
||||
import deckers.thibault.aves.utils.StorageUtils.PathSegments
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
object PermissionManager {
|
||||
private val LOG_TAG = createTag(PermissionManager::class.java)
|
||||
private val LOG_TAG = LogUtils.createTag(PermissionManager::class.java)
|
||||
|
||||
const val VOLUME_ACCESS_REQUEST_CODE = 1
|
||||
|
||||
|
|
|
@ -13,7 +13,6 @@ import android.text.TextUtils
|
|||
import android.util.Log
|
||||
import android.webkit.MimeTypeMap
|
||||
import com.commonsware.cwac.document.DocumentFileCompat
|
||||
import deckers.thibault.aves.utils.LogUtils.createTag
|
||||
import deckers.thibault.aves.utils.PermissionManager.getGrantedDirForPath
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
|
@ -23,7 +22,7 @@ import java.util.*
|
|||
import java.util.regex.Pattern
|
||||
|
||||
object StorageUtils {
|
||||
private val LOG_TAG = createTag(StorageUtils::class.java)
|
||||
private val LOG_TAG = LogUtils.createTag(StorageUtils::class.java)
|
||||
|
||||
/**
|
||||
* Volume paths
|
||||
|
|
|
@ -40,6 +40,7 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
|
|||
key.sampleSize,
|
||||
key.regionRect,
|
||||
key.imageSize,
|
||||
page: key.page,
|
||||
taskKey: key,
|
||||
);
|
||||
if (bytes == null) {
|
||||
|
@ -63,7 +64,7 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
|
|||
|
||||
class RegionProviderKey {
|
||||
final String uri, mimeType;
|
||||
final int rotationDegrees, sampleSize;
|
||||
final int rotationDegrees, sampleSize, page;
|
||||
final bool isFlipped;
|
||||
final Rectangle<int> regionRect;
|
||||
final Size imageSize;
|
||||
|
@ -74,6 +75,7 @@ class RegionProviderKey {
|
|||
@required this.mimeType,
|
||||
@required this.rotationDegrees,
|
||||
@required this.isFlipped,
|
||||
this.page = 0,
|
||||
@required this.sampleSize,
|
||||
@required this.regionRect,
|
||||
@required this.imageSize,
|
||||
|
@ -91,6 +93,7 @@ class RegionProviderKey {
|
|||
// but the entry attributes may change over time
|
||||
factory RegionProviderKey.fromEntry(
|
||||
ImageEntry entry, {
|
||||
int page = 0,
|
||||
@required int sampleSize,
|
||||
@required Rectangle<int> rect,
|
||||
}) {
|
||||
|
@ -99,6 +102,7 @@ class RegionProviderKey {
|
|||
mimeType: entry.mimeType,
|
||||
rotationDegrees: entry.rotationDegrees,
|
||||
isFlipped: entry.isFlipped,
|
||||
page: page,
|
||||
sampleSize: sampleSize,
|
||||
regionRect: rect,
|
||||
imageSize: Size(entry.width.toDouble(), entry.height.toDouble()),
|
||||
|
@ -108,7 +112,7 @@ class RegionProviderKey {
|
|||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is RegionProviderKey && other.uri == uri && other.mimeType == mimeType && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.sampleSize == sampleSize && other.regionRect == regionRect && other.imageSize == imageSize && other.scale == scale;
|
||||
return other is RegionProviderKey && other.uri == uri && other.mimeType == mimeType && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.page == page && other.sampleSize == sampleSize && other.regionRect == regionRect && other.imageSize == imageSize && other.scale == scale;
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -117,7 +121,7 @@ class RegionProviderKey {
|
|||
mimeType,
|
||||
rotationDegrees,
|
||||
isFlipped,
|
||||
mimeType,
|
||||
page,
|
||||
sampleSize,
|
||||
regionRect,
|
||||
imageSize,
|
||||
|
@ -125,5 +129,5 @@ class RegionProviderKey {
|
|||
);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, sampleSize=$sampleSize, regionRect=$regionRect, imageSize=$imageSize, scale=$scale}';
|
||||
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, page=$page, sampleSize=$sampleSize, regionRect=$regionRect, imageSize=$imageSize, scale=$scale}';
|
||||
}
|
||||
|
|
|
@ -41,6 +41,7 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
|
|||
key.isFlipped,
|
||||
key.extent,
|
||||
key.extent,
|
||||
page: key.page,
|
||||
taskKey: key,
|
||||
);
|
||||
if (bytes == null) {
|
||||
|
@ -64,7 +65,7 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
|
|||
|
||||
class ThumbnailProviderKey {
|
||||
final String uri, mimeType;
|
||||
final int dateModifiedSecs, rotationDegrees;
|
||||
final int dateModifiedSecs, rotationDegrees, page;
|
||||
final bool isFlipped;
|
||||
final double extent, scale;
|
||||
|
||||
|
@ -74,6 +75,7 @@ class ThumbnailProviderKey {
|
|||
@required this.dateModifiedSecs,
|
||||
@required this.rotationDegrees,
|
||||
@required this.isFlipped,
|
||||
this.page = 0,
|
||||
this.extent = 0,
|
||||
this.scale = 1,
|
||||
}) : assert(uri != null),
|
||||
|
@ -86,7 +88,7 @@ class ThumbnailProviderKey {
|
|||
|
||||
// do not store the entry as it is, because the key should be constant
|
||||
// but the entry attributes may change over time
|
||||
factory ThumbnailProviderKey.fromEntry(ImageEntry entry, {double extent = 0}) {
|
||||
factory ThumbnailProviderKey.fromEntry(ImageEntry entry, {int page = 0, double extent = 0}) {
|
||||
return ThumbnailProviderKey(
|
||||
uri: entry.uri,
|
||||
mimeType: entry.mimeType,
|
||||
|
@ -94,6 +96,7 @@ class ThumbnailProviderKey {
|
|||
dateModifiedSecs: entry.dateModifiedSecs ?? -1,
|
||||
rotationDegrees: entry.rotationDegrees,
|
||||
isFlipped: entry.isFlipped,
|
||||
page: page,
|
||||
extent: extent,
|
||||
);
|
||||
}
|
||||
|
@ -101,7 +104,7 @@ class ThumbnailProviderKey {
|
|||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is ThumbnailProviderKey && other.uri == uri && other.extent == extent && other.mimeType == mimeType && other.dateModifiedSecs == dateModifiedSecs && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.scale == scale;
|
||||
return other is ThumbnailProviderKey && other.uri == uri && other.extent == extent && other.mimeType == mimeType && other.dateModifiedSecs == dateModifiedSecs && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.page == page && other.scale == scale;
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -111,10 +114,11 @@ class ThumbnailProviderKey {
|
|||
dateModifiedSecs,
|
||||
rotationDegrees,
|
||||
isFlipped,
|
||||
page,
|
||||
extent,
|
||||
scale,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, dateModifiedSecs=$dateModifiedSecs, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, extent=$extent, scale=$scale}';
|
||||
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, dateModifiedSecs=$dateModifiedSecs, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, page=$page, extent=$extent, scale=$scale}';
|
||||
}
|
||||
|
|
|
@ -7,9 +7,15 @@ import 'package:flutter/material.dart';
|
|||
import 'package:pedantic/pedantic.dart';
|
||||
|
||||
class UriImage extends ImageProvider<UriImage> {
|
||||
final String uri, mimeType;
|
||||
final int page, rotationDegrees, expectedContentLength;
|
||||
final bool isFlipped;
|
||||
final double scale;
|
||||
|
||||
const UriImage({
|
||||
@required this.uri,
|
||||
@required this.mimeType,
|
||||
this.page = 0,
|
||||
@required this.rotationDegrees,
|
||||
@required this.isFlipped,
|
||||
this.expectedContentLength,
|
||||
|
@ -17,11 +23,6 @@ class UriImage extends ImageProvider<UriImage> {
|
|||
}) : assert(uri != null),
|
||||
assert(scale != null);
|
||||
|
||||
final String uri, mimeType;
|
||||
final int rotationDegrees, expectedContentLength;
|
||||
final bool isFlipped;
|
||||
final double scale;
|
||||
|
||||
@override
|
||||
Future<UriImage> obtainKey(ImageConfiguration configuration) {
|
||||
return SynchronousFuture<UriImage>(this);
|
||||
|
@ -50,6 +51,7 @@ class UriImage extends ImageProvider<UriImage> {
|
|||
mimeType,
|
||||
rotationDegrees,
|
||||
isFlipped,
|
||||
page: page,
|
||||
expectedContentLength: expectedContentLength,
|
||||
onBytesReceived: (cumulative, total) {
|
||||
chunkEvents.add(ImageChunkEvent(
|
||||
|
@ -73,12 +75,19 @@ class UriImage extends ImageProvider<UriImage> {
|
|||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is UriImage && other.uri == uri && other.scale == scale;
|
||||
return other is UriImage && other.uri == uri && other.mimeType == mimeType && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.page == page && other.scale == scale;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => hashValues(uri, scale);
|
||||
int get hashCode => hashValues(
|
||||
uri,
|
||||
mimeType,
|
||||
rotationDegrees,
|
||||
isFlipped,
|
||||
page,
|
||||
scale,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, scale=$scale}';
|
||||
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, page=$page, scale=$scale}';
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ import 'package:aves/theme/icons.dart';
|
|||
import 'package:flutter/widgets.dart';
|
||||
|
||||
enum ChipSetAction {
|
||||
group,
|
||||
sort,
|
||||
refresh,
|
||||
stats,
|
||||
|
|
|
@ -12,10 +12,14 @@ class EntryCache {
|
|||
int oldRotationDegrees,
|
||||
bool oldIsFlipped,
|
||||
) async {
|
||||
// TODO TLAD revisit this for multipage items, if someday image editing features are added for them
|
||||
const page = 0;
|
||||
|
||||
// evict fullscreen image
|
||||
await UriImage(
|
||||
uri: uri,
|
||||
mimeType: mimeType,
|
||||
page: page,
|
||||
rotationDegrees: oldRotationDegrees,
|
||||
isFlipped: oldIsFlipped,
|
||||
).evict();
|
||||
|
@ -27,6 +31,7 @@ class EntryCache {
|
|||
dateModifiedSecs: dateModifiedSecs,
|
||||
rotationDegrees: oldRotationDegrees,
|
||||
isFlipped: oldIsFlipped,
|
||||
page: page,
|
||||
)).evict();
|
||||
|
||||
// evict higher quality thumbnails (with powers of 2 from 32 to 1024 as specified extents)
|
||||
|
@ -39,6 +44,7 @@ class EntryCache {
|
|||
dateModifiedSecs: dateModifiedSecs,
|
||||
rotationDegrees: oldRotationDegrees,
|
||||
isFlipped: oldIsFlipped,
|
||||
page: page,
|
||||
extent: extent,
|
||||
)).evict());
|
||||
}
|
||||
|
|
|
@ -74,3 +74,20 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
|
|||
return c != 0 ? c : compareAsciiUpperCase(label, other.label);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO TLAD replace this by adding getters to CollectionFilter, with cached entry/count coming from Source
|
||||
class FilterGridItem<T extends CollectionFilter> {
|
||||
final T filter;
|
||||
final ImageEntry entry;
|
||||
|
||||
const FilterGridItem(this.filter, this.entry);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is FilterGridItem && other.filter == filter && other.entry == entry;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => hashValues(filter, entry);
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import 'package:aves/model/entry_cache.dart';
|
|||
import 'package:aves/model/favourite_repo.dart';
|
||||
import 'package:aves/model/image_metadata.dart';
|
||||
import 'package:aves/model/metadata_db.dart';
|
||||
import 'package:aves/model/multipage.dart';
|
||||
import 'package:aves/services/image_file_service.dart';
|
||||
import 'package:aves/services/metadata_service.dart';
|
||||
import 'package:aves/services/service_policy.dart';
|
||||
|
@ -211,6 +212,8 @@ class ImageEntry {
|
|||
|
||||
bool get is360 => _catalogMetadata?.is360 ?? false;
|
||||
|
||||
bool get isMultipage => _catalogMetadata?.isMultipage ?? false;
|
||||
|
||||
bool get canEdit => path != null;
|
||||
|
||||
bool get canPrint => !isVideo;
|
||||
|
@ -240,10 +243,19 @@ class ImageEntry {
|
|||
static const ratioSeparator = '\u2236';
|
||||
static const resolutionSeparator = ' \u00D7 ';
|
||||
|
||||
String get resolutionText {
|
||||
final w = width ?? '?';
|
||||
final h = height ?? '?';
|
||||
return isPortrait ? '$h$resolutionSeparator$w' : '$w$resolutionSeparator$h';
|
||||
String getResolutionText({MultiPageInfo multiPageInfo, int page}) {
|
||||
int w;
|
||||
int h;
|
||||
if (multiPageInfo != null && page != null) {
|
||||
final pageInfo = multiPageInfo.pages[page];
|
||||
w = pageInfo?.width;
|
||||
h = pageInfo?.height;
|
||||
}
|
||||
w ??= width;
|
||||
h ??= height;
|
||||
final ws = w ?? '?';
|
||||
final hs = h ?? '?';
|
||||
return isPortrait ? '$hs$resolutionSeparator$ws' : '$ws$resolutionSeparator$hs';
|
||||
}
|
||||
|
||||
String get aspectRatioText {
|
||||
|
@ -262,7 +274,18 @@ class ImageEntry {
|
|||
return isPortrait ? height / width : width / height;
|
||||
}
|
||||
|
||||
Size get displaySize => isPortrait ? Size(height.toDouble(), width.toDouble()) : Size(width.toDouble(), height.toDouble());
|
||||
Size getDisplaySize({MultiPageInfo multiPageInfo, int page}) {
|
||||
int w;
|
||||
int h;
|
||||
if (multiPageInfo != null && page != null) {
|
||||
final pageInfo = multiPageInfo.pages[page];
|
||||
w = pageInfo?.width;
|
||||
h = pageInfo?.height;
|
||||
}
|
||||
w ??= width;
|
||||
h ??= height;
|
||||
return isPortrait ? Size(h.toDouble(), w.toDouble()) : Size(w.toDouble(), h.toDouble());
|
||||
}
|
||||
|
||||
int get megaPixels => width != null && height != null ? (width * height / 1000000).round() : null;
|
||||
|
||||
|
|
|
@ -29,7 +29,7 @@ class DateMetadata {
|
|||
|
||||
class CatalogMetadata {
|
||||
final int contentId, dateMillis;
|
||||
final bool isAnimated, isGeotiff, is360;
|
||||
final bool isAnimated, isGeotiff, is360, isMultipage;
|
||||
bool isFlipped;
|
||||
int rotationDegrees;
|
||||
final String mimeType, xmpSubjects, xmpTitleDescription;
|
||||
|
@ -41,6 +41,7 @@ class CatalogMetadata {
|
|||
static const _isFlippedMask = 1 << 1;
|
||||
static const _isGeotiffMask = 1 << 2;
|
||||
static const _is360Mask = 1 << 3;
|
||||
static const _isMultipageMask = 1 << 4;
|
||||
|
||||
CatalogMetadata({
|
||||
this.contentId,
|
||||
|
@ -50,6 +51,7 @@ class CatalogMetadata {
|
|||
this.isFlipped = false,
|
||||
this.isGeotiff = false,
|
||||
this.is360 = false,
|
||||
this.isMultipage = false,
|
||||
this.rotationDegrees,
|
||||
this.xmpSubjects,
|
||||
this.xmpTitleDescription,
|
||||
|
@ -76,6 +78,7 @@ class CatalogMetadata {
|
|||
isFlipped: isFlipped,
|
||||
isGeotiff: isGeotiff,
|
||||
is360: is360,
|
||||
isMultipage: isMultipage,
|
||||
rotationDegrees: rotationDegrees,
|
||||
xmpSubjects: xmpSubjects,
|
||||
xmpTitleDescription: xmpTitleDescription,
|
||||
|
@ -94,6 +97,7 @@ class CatalogMetadata {
|
|||
isFlipped: flags & _isFlippedMask != 0,
|
||||
isGeotiff: flags & _isGeotiffMask != 0,
|
||||
is360: flags & _is360Mask != 0,
|
||||
isMultipage: flags & _isMultipageMask != 0,
|
||||
// `rotationDegrees` should default to `sourceRotationDegrees`, not 0
|
||||
rotationDegrees: map['rotationDegrees'],
|
||||
xmpSubjects: map['xmpSubjects'] ?? '',
|
||||
|
@ -107,7 +111,7 @@ class CatalogMetadata {
|
|||
'contentId': contentId,
|
||||
'mimeType': mimeType,
|
||||
'dateMillis': dateMillis,
|
||||
'flags': (isAnimated ? _isAnimatedMask : 0) | (isFlipped ? _isFlippedMask : 0) | (isGeotiff ? _isGeotiffMask : 0) | (is360 ? _is360Mask : 0),
|
||||
'flags': (isAnimated ? _isAnimatedMask : 0) | (isFlipped ? _isFlippedMask : 0) | (isGeotiff ? _isGeotiffMask : 0) | (is360 ? _is360Mask : 0) | (isMultipage ? _isMultipageMask : 0),
|
||||
'rotationDegrees': rotationDegrees,
|
||||
'xmpSubjects': xmpSubjects,
|
||||
'xmpTitleDescription': xmpTitleDescription,
|
||||
|
@ -116,7 +120,7 @@ class CatalogMetadata {
|
|||
};
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, is360=$is360, 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 {
|
||||
|
|
42
lib/model/multipage.dart
Normal file
42
lib/model/multipage.dart
Normal file
|
@ -0,0 +1,42 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class SinglePageInfo {
|
||||
final int width, height;
|
||||
|
||||
SinglePageInfo({
|
||||
this.width,
|
||||
this.height,
|
||||
});
|
||||
|
||||
factory SinglePageInfo.fromMap(Map map) {
|
||||
return SinglePageInfo(
|
||||
width: map['width'] as int,
|
||||
height: map['height'] as int,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{width=$width, height=$height}';
|
||||
}
|
||||
|
||||
class MultiPageInfo {
|
||||
final Map<int, SinglePageInfo> pages;
|
||||
|
||||
int get pageCount => pages.length;
|
||||
|
||||
MultiPageInfo({
|
||||
this.pages,
|
||||
});
|
||||
|
||||
factory MultiPageInfo.fromMap(Map map) {
|
||||
final pages = <int, SinglePageInfo>{};
|
||||
map.keys.forEach((key) {
|
||||
final index = key as int;
|
||||
pages.putIfAbsent(index, () => SinglePageInfo.fromMap(map[key]));
|
||||
});
|
||||
return MultiPageInfo(pages: pages);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{pages=$pages}';
|
||||
}
|
40
lib/model/panorama.dart
Normal file
40
lib/model/panorama.dart
Normal file
|
@ -0,0 +1,40 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class PanoramaInfo {
|
||||
final Rect croppedAreaRect;
|
||||
final Size fullPanoSize;
|
||||
|
||||
PanoramaInfo({
|
||||
this.croppedAreaRect,
|
||||
this.fullPanoSize,
|
||||
});
|
||||
|
||||
factory PanoramaInfo.fromMap(Map map) {
|
||||
final cLeft = map['croppedAreaLeft'] as int;
|
||||
final cTop = map['croppedAreaTop'] as int;
|
||||
final cWidth = map['croppedAreaWidth'] as int;
|
||||
final cHeight = map['croppedAreaHeight'] as int;
|
||||
Rect croppedAreaRect;
|
||||
if (cLeft != null && cTop != null && cWidth != null && cHeight != null) {
|
||||
croppedAreaRect = Rect.fromLTWH(cLeft.toDouble(), cTop.toDouble(), cWidth.toDouble(), cHeight.toDouble());
|
||||
}
|
||||
|
||||
final fWidth = map['fullPanoWidth'] as int;
|
||||
final fHeight = map['fullPanoHeight'] as int;
|
||||
Size fullPanoSize;
|
||||
if (fWidth != null && fHeight != null) {
|
||||
fullPanoSize = Size(fWidth.toDouble(), fHeight.toDouble());
|
||||
}
|
||||
|
||||
return PanoramaInfo(
|
||||
croppedAreaRect: croppedAreaRect,
|
||||
fullPanoSize: fullPanoSize,
|
||||
);
|
||||
}
|
||||
|
||||
bool get hasCroppedArea => croppedAreaRect != null && fullPanoSize != null;
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{croppedAreaRect=$croppedAreaRect, fullPanoSize=$fullPanoSize}';
|
||||
}
|
|
@ -1,13 +1,13 @@
|
|||
import 'package:screen/screen.dart';
|
||||
|
||||
enum KeepScreenOn { never, fullscreenOnly, always }
|
||||
enum KeepScreenOn { never, viewerOnly, always }
|
||||
|
||||
extension ExtraKeepScreenOn on KeepScreenOn {
|
||||
String get name {
|
||||
switch (this) {
|
||||
case KeepScreenOn.never:
|
||||
return 'Never';
|
||||
case KeepScreenOn.fullscreenOnly:
|
||||
case KeepScreenOn.viewerOnly:
|
||||
return 'Viewer page only';
|
||||
case KeepScreenOn.always:
|
||||
return 'Always';
|
||||
|
|
|
@ -3,7 +3,7 @@ import 'package:aves/model/settings/coordinate_format.dart';
|
|||
import 'package:aves/model/settings/entry_background.dart';
|
||||
import 'package:aves/model/settings/home_page.dart';
|
||||
import 'package:aves/model/settings/screen_on.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/location_section.dart';
|
||||
import 'package:aves/widgets/viewer/info/location_section.dart';
|
||||
import 'package:firebase_analytics/firebase_analytics.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
|
||||
|
@ -40,6 +40,7 @@ class Settings extends ChangeNotifier {
|
|||
static const showThumbnailVideoDurationKey = 'show_thumbnail_video_duration';
|
||||
|
||||
// filter grids
|
||||
static const albumGroupFactorKey = 'album_group_factor';
|
||||
static const albumSortFactorKey = 'album_sort_factor';
|
||||
static const countrySortFactorKey = 'country_sort_factor';
|
||||
static const tagSortFactorKey = 'tag_sort_factor';
|
||||
|
@ -47,6 +48,7 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
// viewer
|
||||
static const showOverlayMinimapKey = 'show_overlay_minimap';
|
||||
static const showOverlayInfoKey = 'show_overlay_info';
|
||||
static const showOverlayShootingDetailsKey = 'show_overlay_shooting_details';
|
||||
|
||||
// info
|
||||
|
@ -99,7 +101,7 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
set mustBackTwiceToExit(bool newValue) => setAndNotify(mustBackTwiceToExitKey, newValue);
|
||||
|
||||
KeepScreenOn get keepScreenOn => getEnumOrDefault(keepScreenOnKey, KeepScreenOn.fullscreenOnly, KeepScreenOn.values);
|
||||
KeepScreenOn get keepScreenOn => getEnumOrDefault(keepScreenOnKey, KeepScreenOn.viewerOnly, KeepScreenOn.values);
|
||||
|
||||
set keepScreenOn(KeepScreenOn newValue) {
|
||||
setAndNotify(keepScreenOnKey, newValue.toString());
|
||||
|
@ -144,6 +146,10 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
// filter grids
|
||||
|
||||
AlbumChipGroupFactor get albumGroupFactor => getEnumOrDefault(albumGroupFactorKey, AlbumChipGroupFactor.importance, AlbumChipGroupFactor.values);
|
||||
|
||||
set albumGroupFactor(AlbumChipGroupFactor newValue) => setAndNotify(albumGroupFactorKey, newValue.toString());
|
||||
|
||||
ChipSortFactor get albumSortFactor => getEnumOrDefault(albumSortFactorKey, ChipSortFactor.name, ChipSortFactor.values);
|
||||
|
||||
set albumSortFactor(ChipSortFactor newValue) => setAndNotify(albumSortFactorKey, newValue.toString());
|
||||
|
@ -166,6 +172,10 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
set showOverlayMinimap(bool newValue) => setAndNotify(showOverlayMinimapKey, newValue);
|
||||
|
||||
bool get showOverlayInfo => getBoolOrDefault(showOverlayInfoKey, true);
|
||||
|
||||
set showOverlayInfo(bool newValue) => setAndNotify(showOverlayInfoKey, newValue);
|
||||
|
||||
bool get showOverlayShootingDetails => getBoolOrDefault(showOverlayShootingDetailsKey, true);
|
||||
|
||||
set showOverlayShootingDetails(bool newValue) => setAndNotify(showOverlayShootingDetailsKey, newValue);
|
||||
|
|
|
@ -29,15 +29,25 @@ mixin AlbumMixin on SourceBase {
|
|||
}
|
||||
|
||||
String getUniqueAlbumName(String album) {
|
||||
final volumeRoot = androidFileUtils.getStorageVolume(album)?.path ?? '';
|
||||
final otherAlbums = _folderPaths.where((item) => item != album && item.startsWith(volumeRoot));
|
||||
final otherAlbums = _folderPaths.where((item) => item != album);
|
||||
final parts = album.split(separator);
|
||||
var partCount = 0;
|
||||
String testName;
|
||||
do {
|
||||
testName = separator + parts.skip(parts.length - ++partCount).join(separator);
|
||||
} while (otherAlbums.any((item) => item.endsWith(testName)));
|
||||
return parts.skip(parts.length - partCount).join(separator);
|
||||
final uniqueName = parts.skip(parts.length - partCount).join(separator);
|
||||
|
||||
final volume = androidFileUtils.getStorageVolume(album);
|
||||
final volumeRoot = volume?.path ?? '';
|
||||
final albumRelativePath = album.substring(volumeRoot.length);
|
||||
if (uniqueName.length < albumRelativePath.length || volume == null) {
|
||||
return uniqueName;
|
||||
} else if (volume.isPrimary) {
|
||||
return albumRelativePath;
|
||||
} else {
|
||||
return '$albumRelativePath (${volume.description})';
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, ImageEntry> getAlbumEntries() {
|
||||
|
|
|
@ -5,6 +5,7 @@ import 'package:aves/model/filters/album.dart';
|
|||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/section_keys.dart';
|
||||
import 'package:aves/model/source/tag.dart';
|
||||
import 'package:aves/utils/change_notifier.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
|
@ -22,7 +23,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
|
|||
List<ImageEntry> _filteredEntries;
|
||||
List<StreamSubscription> _subscriptions = [];
|
||||
|
||||
Map<dynamic, List<ImageEntry>> sections = Map.unmodifiable({});
|
||||
Map<SectionKey, List<ImageEntry>> sections = Map.unmodifiable({});
|
||||
|
||||
CollectionLens({
|
||||
@required this.source,
|
||||
|
@ -138,13 +139,13 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
|
|||
case EntrySortFactor.date:
|
||||
switch (groupFactor) {
|
||||
case EntryGroupFactor.album:
|
||||
sections = groupBy<ImageEntry, String>(_filteredEntries, (entry) => entry.directory);
|
||||
sections = groupBy<ImageEntry, EntryAlbumSectionKey>(_filteredEntries, (entry) => EntryAlbumSectionKey(entry.directory));
|
||||
break;
|
||||
case EntryGroupFactor.month:
|
||||
sections = groupBy<ImageEntry, DateTime>(_filteredEntries, (entry) => entry.monthTaken);
|
||||
sections = groupBy<ImageEntry, EntryDateSectionKey>(_filteredEntries, (entry) => EntryDateSectionKey(entry.monthTaken));
|
||||
break;
|
||||
case EntryGroupFactor.day:
|
||||
sections = groupBy<ImageEntry, DateTime>(_filteredEntries, (entry) => entry.dayTaken);
|
||||
sections = groupBy<ImageEntry, EntryDateSectionKey>(_filteredEntries, (entry) => EntryDateSectionKey(entry.dayTaken));
|
||||
break;
|
||||
case EntryGroupFactor.none:
|
||||
sections = Map.fromEntries([
|
||||
|
@ -159,8 +160,8 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
|
|||
]);
|
||||
break;
|
||||
case EntrySortFactor.name:
|
||||
final byAlbum = groupBy<ImageEntry, String>(_filteredEntries, (entry) => entry.directory);
|
||||
sections = SplayTreeMap<String, List<ImageEntry>>.of(byAlbum, source.compareAlbumsByName);
|
||||
final byAlbum = groupBy<ImageEntry, EntryAlbumSectionKey>(_filteredEntries, (entry) => EntryAlbumSectionKey(entry.directory));
|
||||
sections = SplayTreeMap<EntryAlbumSectionKey, List<ImageEntry>>.of(byAlbum, (a, b) => source.compareAlbumsByName(a.folderPath, b.folderPath));
|
||||
break;
|
||||
}
|
||||
sections = Map.unmodifiable(sections);
|
||||
|
|
|
@ -2,6 +2,8 @@ enum Activity { browse, select }
|
|||
|
||||
enum ChipSortFactor { date, name, count }
|
||||
|
||||
enum AlbumChipGroupFactor { none, importance, volume }
|
||||
|
||||
enum EntrySortFactor { date, size, name }
|
||||
|
||||
enum EntryGroupFactor { none, album, month, day }
|
||||
|
|
41
lib/model/source/section_keys.dart
Normal file
41
lib/model/source/section_keys.dart
Normal file
|
@ -0,0 +1,41 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class SectionKey {
|
||||
const SectionKey();
|
||||
}
|
||||
|
||||
class EntryAlbumSectionKey extends SectionKey {
|
||||
final String folderPath;
|
||||
|
||||
const EntryAlbumSectionKey(this.folderPath);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is EntryAlbumSectionKey && other.folderPath == folderPath;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => folderPath.hashCode;
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{folderPath=$folderPath}';
|
||||
}
|
||||
|
||||
class EntryDateSectionKey extends SectionKey {
|
||||
final DateTime date;
|
||||
|
||||
const EntryDateSectionKey(this.date);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is EntryDateSectionKey && other.date == date;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => date.hashCode;
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{date=$date}';
|
||||
}
|
|
@ -74,6 +74,7 @@ class ImageFileService {
|
|||
String mimeType,
|
||||
int rotationDegrees,
|
||||
bool isFlipped, {
|
||||
int page = 0,
|
||||
int expectedContentLength,
|
||||
BytesReceivedCallback onBytesReceived,
|
||||
}) {
|
||||
|
@ -86,6 +87,7 @@ class ImageFileService {
|
|||
'mimeType': mimeType,
|
||||
'rotationDegrees': rotationDegrees ?? 0,
|
||||
'isFlipped': isFlipped ?? false,
|
||||
'page': page ?? 0,
|
||||
}).listen(
|
||||
(data) {
|
||||
final chunk = data as Uint8List;
|
||||
|
@ -123,6 +125,7 @@ class ImageFileService {
|
|||
int sampleSize,
|
||||
Rectangle<int> regionRect,
|
||||
Size imageSize, {
|
||||
int page = 0,
|
||||
Object taskKey,
|
||||
int priority,
|
||||
}) {
|
||||
|
@ -132,6 +135,7 @@ class ImageFileService {
|
|||
final result = await platform.invokeMethod('getRegion', <String, dynamic>{
|
||||
'uri': uri,
|
||||
'mimeType': mimeType,
|
||||
'page': page,
|
||||
'sampleSize': sampleSize,
|
||||
'regionX': regionRect.left,
|
||||
'regionY': regionRect.top,
|
||||
|
@ -159,6 +163,7 @@ class ImageFileService {
|
|||
bool isFlipped,
|
||||
double width,
|
||||
double height, {
|
||||
int page,
|
||||
Object taskKey,
|
||||
int priority,
|
||||
}) {
|
||||
|
@ -176,6 +181,7 @@ class ImageFileService {
|
|||
'isFlipped': isFlipped,
|
||||
'widthDip': width,
|
||||
'heightDip': height,
|
||||
'page': page,
|
||||
'defaultSizeDip': thumbnailDefaultSize,
|
||||
});
|
||||
return result as Uint8List;
|
||||
|
@ -217,7 +223,6 @@ class ImageFileService {
|
|||
}
|
||||
|
||||
static Stream<MoveOpEvent> move(Iterable<ImageEntry> entries, {@required bool copy, @required String destinationAlbum}) {
|
||||
debugPrint('move ${entries.length} entries');
|
||||
try {
|
||||
return opChannel.receiveBroadcastStream(<String, dynamic>{
|
||||
'op': 'move',
|
||||
|
|
|
@ -2,6 +2,8 @@ import 'dart:typed_data';
|
|||
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/image_metadata.dart';
|
||||
import 'package:aves/model/multipage.dart';
|
||||
import 'package:aves/model/panorama.dart';
|
||||
import 'package:aves/services/service_policy.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
@ -80,6 +82,36 @@ class MetadataService {
|
|||
return null;
|
||||
}
|
||||
|
||||
static Future<MultiPageInfo> getMultiPageInfo(ImageEntry entry) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('getMultiPageInfo', <String, dynamic>{
|
||||
'mimeType': entry.mimeType,
|
||||
'uri': entry.uri,
|
||||
}) as Map;
|
||||
return MultiPageInfo.fromMap(result);
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getMultiPageInfo failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<PanoramaInfo> getPanoramaInfo(ImageEntry entry) async {
|
||||
try {
|
||||
// return map with values for:
|
||||
// 'croppedAreaLeft' (int), 'croppedAreaTop' (int), 'croppedAreaWidth' (int), 'croppedAreaHeight' (int),
|
||||
// 'fullPanoWidth' (int), 'fullPanoHeight' (int)
|
||||
final result = await platform.invokeMethod('getPanoramaInfo', <String, dynamic>{
|
||||
'mimeType': entry.mimeType,
|
||||
'uri': entry.uri,
|
||||
'sizeBytes': entry.sizeBytes,
|
||||
}) as Map;
|
||||
return PanoramaInfo.fromMap(result);
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('PanoramaInfo failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<List<Uint8List>> getEmbeddedPictures(String uri) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('getEmbeddedPictures', <String, dynamic>{
|
||||
|
|
|
@ -29,10 +29,11 @@ class Durations {
|
|||
// search animations
|
||||
static const filterRowExpandAnimation = Duration(milliseconds: 300);
|
||||
|
||||
// fullscreen animations
|
||||
static const fullscreenPageAnimation = Duration(milliseconds: 300);
|
||||
static const fullscreenOverlayAnimation = Duration(milliseconds: 200);
|
||||
static const fullscreenOverlayChangeAnimation = Duration(milliseconds: 150);
|
||||
// viewer animations
|
||||
static const viewerPageAnimation = Duration(milliseconds: 300);
|
||||
static const viewerOverlayAnimation = Duration(milliseconds: 200);
|
||||
static const viewerOverlayChangeAnimation = Duration(milliseconds: 150);
|
||||
static const viewerOverlayPageChooserAnimation = Duration(milliseconds: 200);
|
||||
|
||||
// info
|
||||
static const mapStyleSwitchAnimation = Duration(milliseconds: 300);
|
||||
|
|
|
@ -18,6 +18,8 @@ class AIcons {
|
|||
static const IconData raw = Icons.camera_outlined;
|
||||
static const IconData shooting = Icons.camera_outlined;
|
||||
static const IconData removableStorage = Icons.sd_storage_outlined;
|
||||
static const IconData sensorControl = Icons.explore_outlined;
|
||||
static const IconData sensorControlOff = Icons.explore_off_outlined;
|
||||
static const IconData settings = Icons.settings_outlined;
|
||||
static const IconData text = Icons.format_quote_outlined;
|
||||
static const IconData tag = Icons.local_offer_outlined;
|
||||
|
@ -64,6 +66,7 @@ class AIcons {
|
|||
// thumbnail overlay
|
||||
static const IconData animated = Icons.slideshow;
|
||||
static const IconData geo = Icons.language_outlined;
|
||||
static const IconData multipage = Icons.burst_mode_outlined;
|
||||
static const IconData play = Icons.play_circle_outline;
|
||||
static const IconData threesixty = Icons.threesixty_outlined;
|
||||
static const IconData selected = Icons.check_circle_outline;
|
||||
|
|
|
@ -68,7 +68,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(CollectionAppBar oldWidget) {
|
||||
void didUpdateWidget(covariant CollectionAppBar oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
_unregisterWidget(oldWidget);
|
||||
_registerWidget(widget);
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/widgets/collection/thumbnail_collection.dart';
|
||||
import 'package:aves/widgets/common/behaviour/double_back_pop.dart';
|
||||
import 'package:aves/widgets/common/gesture_area_protector.dart';
|
||||
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||
import 'package:aves/widgets/drawer/app_drawer.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
@ -30,7 +31,9 @@ class CollectionPage extends StatelessWidget {
|
|||
return SynchronousFuture(true);
|
||||
},
|
||||
child: DoubleBackPopScope(
|
||||
child: ThumbnailCollection(),
|
||||
child: GestureAreaProtectorStack(
|
||||
child: ThumbnailCollection(),
|
||||
),
|
||||
),
|
||||
),
|
||||
drawer: AppDrawer(
|
||||
|
|
|
@ -29,7 +29,7 @@ class _FilterBarState extends State<FilterBar> {
|
|||
CollectionFilter _userRemovedFilter;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(FilterBar oldWidget) {
|
||||
void didUpdateWidget(covariant FilterBar oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
final current = widget.filters;
|
||||
final existing = oldWidget.filters;
|
||||
|
|
|
@ -1,17 +1,20 @@
|
|||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/section_keys.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/widgets/collection/grid/header_generic.dart';
|
||||
import 'package:aves/widgets/common/grid/header.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_icons.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AlbumSectionHeader extends StatelessWidget {
|
||||
final String folderPath, albumName;
|
||||
|
||||
const AlbumSectionHeader({
|
||||
AlbumSectionHeader({
|
||||
Key key,
|
||||
@required CollectionSource source,
|
||||
@required this.folderPath,
|
||||
@required this.albumName,
|
||||
}) : super(key: key);
|
||||
}) : albumName = source.getUniqueAlbumName(folderPath),
|
||||
super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
@ -25,8 +28,8 @@ class AlbumSectionHeader extends StatelessWidget {
|
|||
child: albumIcon,
|
||||
);
|
||||
}
|
||||
return TitleSectionHeader(
|
||||
sectionKey: folderPath,
|
||||
return SectionHeader(
|
||||
sectionKey: EntryAlbumSectionKey(folderPath),
|
||||
leading: albumIcon,
|
||||
title: albumName,
|
||||
trailing: androidFileUtils.isOnRemovableStorage(folderPath)
|
||||
|
@ -38,4 +41,15 @@ class AlbumSectionHeader extends StatelessWidget {
|
|||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
static double getPreferredHeight(BuildContext context, double maxWidth, CollectionSource source, EntryAlbumSectionKey sectionKey) {
|
||||
final folderPath = sectionKey.folderPath;
|
||||
return SectionHeader.getPreferredHeight(
|
||||
context: context,
|
||||
maxWidth: maxWidth,
|
||||
title: source.getUniqueAlbumName(folderPath),
|
||||
hasLeading: androidFileUtils.getAlbumType(folderPath) != AlbumType.regular,
|
||||
hasTrailing: androidFileUtils.isOnRemovableStorage(folderPath),
|
||||
);
|
||||
}
|
||||
}
|
74
lib/widgets/collection/grid/headers/any.dart
Normal file
74
lib/widgets/collection/grid/headers/any.dart
Normal file
|
@ -0,0 +1,74 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/model/source/section_keys.dart';
|
||||
import 'package:aves/widgets/collection/grid/headers/album.dart';
|
||||
import 'package:aves/widgets/collection/grid/headers/date.dart';
|
||||
import 'package:aves/widgets/common/grid/header.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class CollectionSectionHeader extends StatelessWidget {
|
||||
final CollectionLens collection;
|
||||
final SectionKey sectionKey;
|
||||
final double height;
|
||||
|
||||
const CollectionSectionHeader({
|
||||
Key key,
|
||||
@required this.collection,
|
||||
@required this.sectionKey,
|
||||
@required this.height,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final header = _buildHeader();
|
||||
return header != null
|
||||
? SizedBox(
|
||||
height: height,
|
||||
child: header,
|
||||
)
|
||||
: SizedBox.shrink();
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
Widget _buildAlbumHeader() => AlbumSectionHeader(
|
||||
key: ValueKey(sectionKey),
|
||||
source: collection.source,
|
||||
folderPath: (sectionKey as EntryAlbumSectionKey).folderPath,
|
||||
);
|
||||
|
||||
switch (collection.sortFactor) {
|
||||
case EntrySortFactor.date:
|
||||
switch (collection.groupFactor) {
|
||||
case EntryGroupFactor.album:
|
||||
return _buildAlbumHeader();
|
||||
case EntryGroupFactor.month:
|
||||
return MonthSectionHeader(key: ValueKey(sectionKey), date: (sectionKey as EntryDateSectionKey).date);
|
||||
case EntryGroupFactor.day:
|
||||
return DaySectionHeader(key: ValueKey(sectionKey), date: (sectionKey as EntryDateSectionKey).date);
|
||||
case EntryGroupFactor.none:
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case EntrySortFactor.name:
|
||||
return _buildAlbumHeader();
|
||||
case EntrySortFactor.size:
|
||||
break;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static double getPreferredHeight(BuildContext context, double maxWidth, CollectionSource source, SectionKey sectionKey) {
|
||||
var headerExtent = 0.0;
|
||||
if (sectionKey is EntryAlbumSectionKey) {
|
||||
// only compute height for album headers, as they're the only likely ones to split on multiple lines
|
||||
headerExtent = AlbumSectionHeader.getPreferredHeight(context, maxWidth, source, sectionKey);
|
||||
}
|
||||
|
||||
final textScaleFactor = MediaQuery.textScaleFactorOf(context);
|
||||
headerExtent = max(headerExtent, SectionHeader.leadingDimension * textScaleFactor) + SectionHeader.padding.vertical;
|
||||
return headerExtent;
|
||||
}
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:aves/model/source/section_keys.dart';
|
||||
import 'package:aves/utils/time_utils.dart';
|
||||
import 'package:aves/widgets/collection/grid/header_generic.dart';
|
||||
import 'package:aves/widgets/common/grid/header.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
|
@ -35,8 +36,8 @@ class DaySectionHeader extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TitleSectionHeader(
|
||||
sectionKey: date,
|
||||
return SectionHeader(
|
||||
sectionKey: EntryDateSectionKey(date),
|
||||
title: text,
|
||||
);
|
||||
}
|
||||
|
@ -64,8 +65,8 @@ class MonthSectionHeader extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TitleSectionHeader(
|
||||
sectionKey: date,
|
||||
return SectionHeader(
|
||||
sectionKey: EntryDateSectionKey(date),
|
||||
title: text,
|
||||
);
|
||||
}
|
|
@ -1,210 +0,0 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/widgets/collection/grid/header_generic.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class SectionedListLayoutProvider extends StatelessWidget {
|
||||
final CollectionLens collection;
|
||||
final int columnCount;
|
||||
final double scrollableWidth;
|
||||
final double tileExtent;
|
||||
final Widget Function(ImageEntry entry) thumbnailBuilder;
|
||||
final Widget child;
|
||||
|
||||
const SectionedListLayoutProvider({
|
||||
@required this.collection,
|
||||
@required this.scrollableWidth,
|
||||
@required this.tileExtent,
|
||||
@required this.columnCount,
|
||||
@required this.thumbnailBuilder,
|
||||
@required this.child,
|
||||
}) : assert(scrollableWidth != 0);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ProxyProvider0<SectionedListLayout>(
|
||||
update: (context, __) => _updateLayouts(context),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
SectionedListLayout _updateLayouts(BuildContext context) {
|
||||
// debugPrint('$runtimeType _updateLayouts entries=${collection.entryCount} columnCount=$columnCount tileExtent=$tileExtent');
|
||||
final sectionLayouts = <SectionLayout>[];
|
||||
final showHeaders = collection.showHeaders;
|
||||
final source = collection.source;
|
||||
final sections = collection.sections;
|
||||
final sectionKeys = sections.keys.toList();
|
||||
var currentIndex = 0, currentOffset = 0.0;
|
||||
sectionKeys.forEach((sectionKey) {
|
||||
final sectionEntryCount = sections[sectionKey].length;
|
||||
final sectionChildCount = 1 + (sectionEntryCount / columnCount).ceil();
|
||||
|
||||
final headerExtent = showHeaders ? SectionHeader.computeHeaderHeight(context, source, sectionKey, scrollableWidth) : 0.0;
|
||||
|
||||
final sectionFirstIndex = currentIndex;
|
||||
currentIndex += sectionChildCount;
|
||||
final sectionLastIndex = currentIndex - 1;
|
||||
|
||||
final sectionMinOffset = currentOffset;
|
||||
currentOffset += headerExtent + tileExtent * (sectionChildCount - 1);
|
||||
final sectionMaxOffset = currentOffset;
|
||||
|
||||
sectionLayouts.add(
|
||||
SectionLayout(
|
||||
sectionKey: sectionKey,
|
||||
firstIndex: sectionFirstIndex,
|
||||
lastIndex: sectionLastIndex,
|
||||
minOffset: sectionMinOffset,
|
||||
maxOffset: sectionMaxOffset,
|
||||
headerExtent: headerExtent,
|
||||
tileExtent: tileExtent,
|
||||
builder: (context, listIndex) => _buildInSection(
|
||||
listIndex - sectionFirstIndex,
|
||||
collection,
|
||||
sectionKey,
|
||||
headerExtent,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
return SectionedListLayout(
|
||||
collection: collection,
|
||||
columnCount: columnCount,
|
||||
tileExtent: tileExtent,
|
||||
sectionLayouts: sectionLayouts,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInSection(int sectionChildIndex, CollectionLens collection, dynamic sectionKey, double headerExtent) {
|
||||
if (sectionChildIndex == 0) {
|
||||
return headerBuilder(collection, sectionKey, headerExtent);
|
||||
}
|
||||
sectionChildIndex--;
|
||||
|
||||
final section = collection.sections[sectionKey];
|
||||
final sectionEntryCount = section.length;
|
||||
|
||||
final minEntryIndex = sectionChildIndex * columnCount;
|
||||
final maxEntryIndex = min(sectionEntryCount, minEntryIndex + columnCount);
|
||||
final children = <Widget>[];
|
||||
for (var i = minEntryIndex; i < maxEntryIndex; i++) {
|
||||
final entry = section[i];
|
||||
children.add(thumbnailBuilder(entry));
|
||||
}
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
|
||||
Widget headerBuilder(CollectionLens collection, dynamic sectionKey, double headerExtent) {
|
||||
return collection.showHeaders
|
||||
? SectionHeader(
|
||||
collection: collection,
|
||||
sectionKey: sectionKey,
|
||||
height: headerExtent,
|
||||
)
|
||||
: SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
class SectionedListLayout {
|
||||
final CollectionLens collection;
|
||||
final int columnCount;
|
||||
final double tileExtent;
|
||||
final List<SectionLayout> sectionLayouts;
|
||||
|
||||
const SectionedListLayout({
|
||||
@required this.collection,
|
||||
@required this.columnCount,
|
||||
@required this.tileExtent,
|
||||
@required this.sectionLayouts,
|
||||
});
|
||||
|
||||
Rect getTileRect(ImageEntry entry) {
|
||||
final section = collection.sections.entries.firstWhere((kv) => kv.value.contains(entry), orElse: () => null);
|
||||
if (section == null) return null;
|
||||
|
||||
final sectionKey = section.key;
|
||||
final sectionLayout = sectionLayouts.firstWhere((sl) => sl.sectionKey == sectionKey, orElse: () => null);
|
||||
if (sectionLayout == null) return null;
|
||||
|
||||
final showHeaders = collection.showHeaders;
|
||||
final sectionEntryIndex = section.value.indexOf(entry);
|
||||
final column = sectionEntryIndex % columnCount;
|
||||
final row = (sectionEntryIndex / columnCount).floor();
|
||||
final listIndex = sectionLayout.firstIndex + (showHeaders ? 1 : 0) + row;
|
||||
|
||||
final left = tileExtent * column;
|
||||
final top = sectionLayout.indexToLayoutOffset(listIndex);
|
||||
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 {
|
||||
final dynamic sectionKey;
|
||||
final int firstIndex, lastIndex;
|
||||
final double minOffset, maxOffset;
|
||||
final double headerExtent, tileExtent;
|
||||
final IndexedWidgetBuilder builder;
|
||||
|
||||
const SectionLayout({
|
||||
@required this.sectionKey,
|
||||
@required this.firstIndex,
|
||||
@required this.lastIndex,
|
||||
@required this.minOffset,
|
||||
@required this.maxOffset,
|
||||
@required this.headerExtent,
|
||||
@required this.tileExtent,
|
||||
@required this.builder,
|
||||
});
|
||||
|
||||
bool hasChild(int index) => firstIndex <= index && index <= lastIndex;
|
||||
|
||||
bool hasChildAtOffset(double scrollOffset) => minOffset <= scrollOffset && scrollOffset <= maxOffset;
|
||||
|
||||
double indexToLayoutOffset(int index) {
|
||||
return minOffset + (index == firstIndex ? 0 : headerExtent + (index - firstIndex - 1) * tileExtent);
|
||||
}
|
||||
|
||||
double indexToMaxScrollOffset(int index) {
|
||||
return minOffset + headerExtent + (index - firstIndex) * tileExtent;
|
||||
}
|
||||
|
||||
int getMinChildIndexForScrollOffset(double scrollOffset) {
|
||||
scrollOffset -= minOffset + headerExtent;
|
||||
return firstIndex + (scrollOffset < 0 ? 0 : (scrollOffset / tileExtent).floor());
|
||||
}
|
||||
|
||||
int getMaxChildIndexForScrollOffset(double scrollOffset) {
|
||||
scrollOffset -= minOffset + headerExtent;
|
||||
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}';
|
||||
}
|
|
@ -1,92 +0,0 @@
|
|||
import 'package:aves/main.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/services/viewer_service.dart';
|
||||
import 'package:aves/widgets/collection/grid/list_known_extent.dart';
|
||||
import 'package:aves/widgets/collection/grid/list_section_layout.dart';
|
||||
import 'package:aves/widgets/collection/thumbnail/decorated.dart';
|
||||
import 'package:aves/widgets/common/behaviour/routes.dart';
|
||||
import 'package:aves/widgets/common/scaling.dart';
|
||||
import 'package:aves/widgets/fullscreen/fullscreen_page.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
// Use a `SliverList` instead of multiple `SliverGrid` because having one `SliverGrid` per section does not scale up.
|
||||
// With the multiple `SliverGrid` solution, thumbnails at the beginning of each sections are built even though they are offscreen
|
||||
// because of `RenderSliverMultiBoxAdaptor.addInitialChild` called by `RenderSliverGrid.performLayout` (line 547), as of Flutter v1.17.0.
|
||||
class CollectionListSliver extends StatelessWidget {
|
||||
const CollectionListSliver();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sectionLayouts = Provider.of<SectionedListLayout>(context).sectionLayouts;
|
||||
final childCount = sectionLayouts.isEmpty ? 0 : sectionLayouts.last.lastIndex + 1;
|
||||
return SliverKnownExtentList(
|
||||
sectionLayouts: sectionLayouts,
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
if (index >= childCount) return null;
|
||||
final sectionLayout = sectionLayouts.firstWhere((section) => section.hasChild(index), orElse: () => null);
|
||||
return sectionLayout?.builder(context, index) ?? SizedBox.shrink();
|
||||
},
|
||||
childCount: childCount,
|
||||
addAutomaticKeepAlives: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GridThumbnail extends StatelessWidget {
|
||||
final CollectionLens collection;
|
||||
final ImageEntry entry;
|
||||
final double tileExtent;
|
||||
final ValueNotifier<bool> isScrollingNotifier;
|
||||
|
||||
const GridThumbnail({
|
||||
Key key,
|
||||
this.collection,
|
||||
@required this.entry,
|
||||
@required this.tileExtent,
|
||||
this.isScrollingNotifier,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
key: ValueKey(entry.uri),
|
||||
onTap: () {
|
||||
if (AvesApp.mode == AppMode.main) {
|
||||
if (collection.isBrowsing) {
|
||||
_goToFullscreen(context);
|
||||
} else if (collection.isSelecting) {
|
||||
collection.toggleSelection(entry);
|
||||
}
|
||||
} else if (AvesApp.mode == AppMode.pick) {
|
||||
ViewerService.pick(entry.uri);
|
||||
}
|
||||
},
|
||||
child: MetaData(
|
||||
metaData: ScalerMetadata(entry),
|
||||
child: DecoratedThumbnail(
|
||||
entry: entry,
|
||||
extent: tileExtent,
|
||||
collection: collection,
|
||||
isScrollingNotifier: isScrollingNotifier,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _goToFullscreen(BuildContext context) {
|
||||
Navigator.push(
|
||||
context,
|
||||
TransparentMaterialPageRoute(
|
||||
settings: RouteSettings(name: MultiFullscreenPage.routeName),
|
||||
pageBuilder: (c, a, sa) => MultiFullscreenPage(
|
||||
collection: collection,
|
||||
initialEntry: entry,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
46
lib/widgets/collection/grid/section_layout.dart
Normal file
46
lib/widgets/collection/grid/section_layout.dart
Normal file
|
@ -0,0 +1,46 @@
|
|||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/model/source/section_keys.dart';
|
||||
import 'package:aves/widgets/collection/grid/headers/any.dart';
|
||||
import 'package:aves/widgets/common/grid/section_layout.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SectionedEntryListLayoutProvider extends SectionedListLayoutProvider<ImageEntry> {
|
||||
final CollectionLens collection;
|
||||
|
||||
const SectionedEntryListLayoutProvider({
|
||||
@required this.collection,
|
||||
@required double scrollableWidth,
|
||||
@required int columnCount,
|
||||
@required double tileExtent,
|
||||
@required Widget Function(ImageEntry entry) tileBuilder,
|
||||
@required Widget child,
|
||||
}) : super(
|
||||
scrollableWidth: scrollableWidth,
|
||||
columnCount: columnCount,
|
||||
tileExtent: tileExtent,
|
||||
tileBuilder: tileBuilder,
|
||||
child: child,
|
||||
);
|
||||
|
||||
@override
|
||||
bool get showHeaders => collection.showHeaders;
|
||||
|
||||
@override
|
||||
Map<SectionKey, List<ImageEntry>> get sections => collection.sections;
|
||||
|
||||
@override
|
||||
double getHeaderExtent(BuildContext context, SectionKey sectionKey) {
|
||||
return CollectionSectionHeader.getPreferredHeight(context, scrollableWidth, collection.source, sectionKey);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildHeader(BuildContext context, SectionKey sectionKey, double headerExtent) {
|
||||
return CollectionSectionHeader(
|
||||
collection: collection,
|
||||
sectionKey: sectionKey,
|
||||
height: headerExtent,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -4,7 +4,7 @@ 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:aves/widgets/common/grid/section_layout.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
@ -29,7 +29,7 @@ class GridSelectionGestureDetector extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetector> {
|
||||
bool _pressing, _selecting;
|
||||
bool _pressing = false, _selecting;
|
||||
int _fromIndex, _lastToIndex;
|
||||
Offset _localPosition;
|
||||
EdgeInsets _scrollableInsets;
|
||||
|
@ -135,7 +135,8 @@ class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetec
|
|||
// 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);
|
||||
final sectionedListLayout = context.read<SectionedListLayout<ImageEntry>>();
|
||||
return sectionedListLayout.getItemAt(offset);
|
||||
}
|
||||
|
||||
void _toggleSelectionToIndex(int toIndex) {
|
||||
|
|
64
lib/widgets/collection/grid/thumbnail.dart
Normal file
64
lib/widgets/collection/grid/thumbnail.dart
Normal file
|
@ -0,0 +1,64 @@
|
|||
import 'package:aves/main.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/services/viewer_service.dart';
|
||||
import 'package:aves/widgets/collection/thumbnail/decorated.dart';
|
||||
import 'package:aves/widgets/common/behaviour/routes.dart';
|
||||
import 'package:aves/widgets/common/scaling.dart';
|
||||
import 'package:aves/widgets/viewer/entry_viewer_page.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class InteractiveThumbnail extends StatelessWidget {
|
||||
final CollectionLens collection;
|
||||
final ImageEntry entry;
|
||||
final double tileExtent;
|
||||
final ValueNotifier<bool> isScrollingNotifier;
|
||||
|
||||
const InteractiveThumbnail({
|
||||
Key key,
|
||||
this.collection,
|
||||
@required this.entry,
|
||||
@required this.tileExtent,
|
||||
this.isScrollingNotifier,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
key: ValueKey(entry.uri),
|
||||
onTap: () {
|
||||
if (AvesApp.mode == AppMode.main) {
|
||||
if (collection.isBrowsing) {
|
||||
_goToViewer(context);
|
||||
} else if (collection.isSelecting) {
|
||||
collection.toggleSelection(entry);
|
||||
}
|
||||
} else if (AvesApp.mode == AppMode.pick) {
|
||||
ViewerService.pick(entry.uri);
|
||||
}
|
||||
},
|
||||
child: MetaData(
|
||||
metaData: ScalerMetadata(entry),
|
||||
child: DecoratedThumbnail(
|
||||
entry: entry,
|
||||
extent: tileExtent,
|
||||
collection: collection,
|
||||
isScrollingNotifier: isScrollingNotifier,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _goToViewer(BuildContext context) {
|
||||
Navigator.push(
|
||||
context,
|
||||
TransparentMaterialPageRoute(
|
||||
settings: RouteSettings(name: MultiEntryViewerPage.routeName),
|
||||
pageBuilder: (c, a, sa) => MultiEntryViewerPage(
|
||||
collection: collection,
|
||||
initialEntry: entry,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -30,12 +30,12 @@ class DecoratedThumbnail extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var child = entry.isSvg
|
||||
? ThumbnailVectorImage(
|
||||
? VectorImageThumbnail(
|
||||
entry: entry,
|
||||
extent: extent,
|
||||
heroTag: heroTag,
|
||||
)
|
||||
: ThumbnailRasterImage(
|
||||
: RasterImageThumbnail(
|
||||
entry: entry,
|
||||
extent: extent,
|
||||
isScrollingNotifier: isScrollingNotifier,
|
||||
|
|
|
@ -36,6 +36,7 @@ class ThumbnailEntryOverlay extends StatelessWidget {
|
|||
children: [
|
||||
if (entry.hasGps && settings.showThumbnailLocation) GpsIcon(iconSize: iconSize),
|
||||
if (entry.isRaw && settings.showThumbnailRaw) RawIcon(iconSize: iconSize),
|
||||
if (entry.isMultipage) MultipageIcon(iconSize: iconSize),
|
||||
if (entry.isGeotiff) GeotiffIcon(iconSize: iconSize),
|
||||
if (entry.isAnimated)
|
||||
AnimatedImageIcon(iconSize: iconSize)
|
||||
|
|
|
@ -8,29 +8,33 @@ import 'package:aves/widgets/collection/thumbnail/error.dart';
|
|||
import 'package:aves/widgets/common/fx/transition_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ThumbnailRasterImage extends StatefulWidget {
|
||||
class RasterImageThumbnail extends StatefulWidget {
|
||||
final ImageEntry entry;
|
||||
final double extent;
|
||||
final int page;
|
||||
final ValueNotifier<bool> isScrollingNotifier;
|
||||
final Object heroTag;
|
||||
|
||||
const ThumbnailRasterImage({
|
||||
const RasterImageThumbnail({
|
||||
Key key,
|
||||
@required this.entry,
|
||||
@required this.extent,
|
||||
this.page = 0,
|
||||
this.isScrollingNotifier,
|
||||
this.heroTag,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
_ThumbnailRasterImageState createState() => _ThumbnailRasterImageState();
|
||||
_RasterImageThumbnailState createState() => _RasterImageThumbnailState();
|
||||
}
|
||||
|
||||
class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
|
||||
class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
|
||||
ThumbnailProvider _fastThumbnailProvider, _sizedThumbnailProvider;
|
||||
|
||||
ImageEntry get entry => widget.entry;
|
||||
|
||||
int get page => widget.page;
|
||||
|
||||
double get extent => widget.extent;
|
||||
|
||||
Object get heroTag => widget.heroTag;
|
||||
|
@ -47,7 +51,7 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
|
|||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ThumbnailRasterImage oldWidget) {
|
||||
void didUpdateWidget(covariant RasterImageThumbnail oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.entry != entry) {
|
||||
_unregisterWidget(oldWidget);
|
||||
|
@ -61,12 +65,12 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
|
|||
super.dispose();
|
||||
}
|
||||
|
||||
void _registerWidget(ThumbnailRasterImage widget) {
|
||||
void _registerWidget(RasterImageThumbnail widget) {
|
||||
widget.entry.imageChangeNotifier.addListener(_onImageChanged);
|
||||
_initProvider();
|
||||
}
|
||||
|
||||
void _unregisterWidget(ThumbnailRasterImage widget) {
|
||||
void _unregisterWidget(RasterImageThumbnail widget) {
|
||||
widget.entry.imageChangeNotifier.removeListener(_onImageChanged);
|
||||
_pauseProvider();
|
||||
}
|
||||
|
@ -75,11 +79,11 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
|
|||
if (!entry.canDecode) return;
|
||||
|
||||
_fastThumbnailProvider = ThumbnailProvider(
|
||||
ThumbnailProviderKey.fromEntry(entry),
|
||||
ThumbnailProviderKey.fromEntry(entry, page: page),
|
||||
);
|
||||
if (!entry.isVideo) {
|
||||
_sizedThumbnailProvider = ThumbnailProvider(
|
||||
ThumbnailProviderKey.fromEntry(entry, extent: requestExtent),
|
||||
ThumbnailProviderKey.fromEntry(entry, page: page, extent: requestExtent),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -149,6 +153,7 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
|
|||
final imageProvider = UriImage(
|
||||
uri: entry.uri,
|
||||
mimeType: entry.mimeType,
|
||||
page: page,
|
||||
rotationDegrees: entry.rotationDegrees,
|
||||
isFlipped: entry.isFlipped,
|
||||
expectedContentLength: entry.sizeBytes,
|
||||
|
|
|
@ -7,12 +7,12 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class ThumbnailVectorImage extends StatelessWidget {
|
||||
class VectorImageThumbnail extends StatelessWidget {
|
||||
final ImageEntry entry;
|
||||
final double extent;
|
||||
final Object heroTag;
|
||||
|
||||
const ThumbnailVectorImage({
|
||||
const VectorImageThumbnail({
|
||||
Key key,
|
||||
@required this.entry,
|
||||
@required this.extent,
|
||||
|
@ -29,7 +29,7 @@ class ThumbnailVectorImage extends StatelessWidget {
|
|||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final availableSize = constraints.biggest;
|
||||
final fitSize = applyBoxFit(fit, entry.displaySize, availableSize).destination;
|
||||
final fitSize = applyBoxFit(fit, entry.getDisplaySize(), availableSize).destination;
|
||||
final offset = fitSize / 2 - availableSize / 2;
|
||||
final child = DecoratedBox(
|
||||
decoration: CheckeredDecoration(checkSize: extent / 8, offset: offset),
|
||||
|
|
|
@ -12,12 +12,14 @@ import 'package:aves/theme/durations.dart';
|
|||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/widgets/collection/app_bar.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_sliver.dart';
|
||||
import 'package:aves/widgets/collection/grid/section_layout.dart';
|
||||
import 'package:aves/widgets/collection/grid/selector.dart';
|
||||
import 'package:aves/widgets/collection/grid/thumbnail.dart';
|
||||
import 'package:aves/widgets/collection/thumbnail/decorated.dart';
|
||||
import 'package:aves/widgets/common/behaviour/routes.dart';
|
||||
import 'package:aves/widgets/common/behaviour/sloppy_scroll_physics.dart';
|
||||
import 'package:aves/widgets/common/grid/section_layout.dart';
|
||||
import 'package:aves/widgets/common/grid/sliver.dart';
|
||||
import 'package:aves/widgets/common/identity/scroll_thumb.dart';
|
||||
import 'package:aves/widgets/common/providers/highlight_info_provider.dart';
|
||||
import 'package:aves/widgets/common/scaling.dart';
|
||||
|
@ -25,6 +27,7 @@ import 'package:aves/widgets/common/tile_extent_manager.dart';
|
|||
import 'package:draggable_scrollbar/draggable_scrollbar.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class ThumbnailCollection extends StatelessWidget {
|
||||
|
@ -61,17 +64,19 @@ class ThumbnailCollection extends StatelessWidget {
|
|||
// so that view updates on collection filter changes
|
||||
return Consumer<CollectionLens>(
|
||||
builder: (context, collection, child) {
|
||||
final scrollView = CollectionScrollView(
|
||||
scrollableKey: _scrollableKey,
|
||||
collection: collection,
|
||||
appBar: CollectionAppBar(
|
||||
appBarHeightNotifier: _appBarHeightNotifier,
|
||||
final scrollView = AnimationLimiter(
|
||||
child: CollectionScrollView(
|
||||
scrollableKey: _scrollableKey,
|
||||
collection: collection,
|
||||
appBar: CollectionAppBar(
|
||||
appBarHeightNotifier: _appBarHeightNotifier,
|
||||
collection: collection,
|
||||
),
|
||||
appBarHeightNotifier: _appBarHeightNotifier,
|
||||
isScrollingNotifier: _isScrollingNotifier,
|
||||
scrollController: scrollController,
|
||||
cacheExtent: cacheExtent,
|
||||
),
|
||||
appBarHeightNotifier: _appBarHeightNotifier,
|
||||
isScrollingNotifier: _isScrollingNotifier,
|
||||
scrollController: scrollController,
|
||||
cacheExtent: cacheExtent,
|
||||
);
|
||||
|
||||
final scaler = GridScaleGestureDetector<ImageEntry>(
|
||||
|
@ -98,7 +103,7 @@ class ThumbnailCollection extends StatelessWidget {
|
|||
highlightable: false,
|
||||
),
|
||||
getScaledItemTileRect: (context, entry) {
|
||||
final sectionedListLayout = Provider.of<SectionedListLayout>(context, listen: false);
|
||||
final sectionedListLayout = context.read<SectionedListLayout<ImageEntry>>();
|
||||
return sectionedListLayout.getTileRect(entry) ?? Rect.zero;
|
||||
},
|
||||
onScaled: (entry) => Provider.of<HighlightInfo>(context, listen: false).add(entry),
|
||||
|
@ -115,12 +120,12 @@ class ThumbnailCollection extends StatelessWidget {
|
|||
|
||||
final sectionedListLayoutProvider = ValueListenableBuilder<double>(
|
||||
valueListenable: _tileExtentNotifier,
|
||||
builder: (context, tileExtent, child) => SectionedListLayoutProvider(
|
||||
builder: (context, tileExtent, child) => SectionedEntryListLayoutProvider(
|
||||
collection: collection,
|
||||
scrollableWidth: viewportSize.width,
|
||||
tileExtent: tileExtent,
|
||||
columnCount: tileExtentManager.getEffectiveColumnCountForExtent(viewportSize, tileExtent),
|
||||
thumbnailBuilder: (entry) => GridThumbnail(
|
||||
tileBuilder: (entry) => InteractiveThumbnail(
|
||||
key: ValueKey(entry.contentId),
|
||||
collection: collection,
|
||||
entry: entry,
|
||||
|
@ -173,7 +178,7 @@ class _CollectionScrollViewState extends State<CollectionScrollView> {
|
|||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(CollectionScrollView oldWidget) {
|
||||
void didUpdateWidget(covariant CollectionScrollView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
_unregisterWidget(oldWidget);
|
||||
_registerWidget(widget);
|
||||
|
@ -215,7 +220,7 @@ class _CollectionScrollViewState extends State<CollectionScrollView> {
|
|||
child: _buildEmptyCollectionPlaceholder(collection),
|
||||
hasScrollBody: false,
|
||||
)
|
||||
: CollectionListSliver(),
|
||||
: SectionedListSliver<ImageEntry>(),
|
||||
SliverToBoxAdapter(
|
||||
child: Selector<MediaQueryData, double>(
|
||||
selector: (context, mq) => mq.viewInsets.bottom,
|
||||
|
|
|
@ -30,7 +30,7 @@ class _MultiCrossFaderState extends State<MultiCrossFader> {
|
|||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(MultiCrossFader oldWidget) {
|
||||
void didUpdateWidget(covariant MultiCrossFader oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (_first == oldWidget.child) {
|
||||
_second = widget.child;
|
||||
|
|
|
@ -5,18 +5,25 @@ import 'package:flutter/material.dart';
|
|||
final _filter = ImageFilter.blur(sigmaX: 4, sigmaY: 4);
|
||||
|
||||
class BlurredRect extends StatelessWidget {
|
||||
final bool enabled;
|
||||
final Widget child;
|
||||
|
||||
const BlurredRect({Key key, this.child}) : super(key: key);
|
||||
const BlurredRect({
|
||||
Key key,
|
||||
this.enabled = true,
|
||||
this.child,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRect(
|
||||
child: BackdropFilter(
|
||||
filter: _filter,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
return enabled
|
||||
? ClipRect(
|
||||
child: BackdropFilter(
|
||||
filter: _filter,
|
||||
child: child,
|
||||
),
|
||||
)
|
||||
: child;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -57,7 +57,7 @@ class _SweeperState extends State<Sweeper> with SingleTickerProviderStateMixin {
|
|||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(Sweeper oldWidget) {
|
||||
void didUpdateWidget(covariant Sweeper oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
_unregisterWidget(oldWidget);
|
||||
_registerWidget(widget);
|
||||
|
|
|
@ -57,7 +57,7 @@ class _TransitionImageState extends State<TransitionImage> {
|
|||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(TransitionImage oldWidget) {
|
||||
void didUpdateWidget(covariant TransitionImage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (_isListeningToStream) {
|
||||
_imageStream.removeListener(_getListener());
|
||||
|
|
38
lib/widgets/common/gesture_area_protector.dart
Normal file
38
lib/widgets/common/gesture_area_protector.dart
Normal file
|
@ -0,0 +1,38 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
// This widget should be added on top of Scaffolds with:
|
||||
// - `resizeToAvoidBottomInset` set to false,
|
||||
// - a vertically scrollable body.
|
||||
// It will prevent the body from scrolling when a user swipe from bottom to use Android Q style navigation gestures.
|
||||
class BottomGestureAreaProtector extends StatelessWidget {
|
||||
// as of Flutter v1.22.5, `systemGestureInsets` from `MediaQuery` mistakenly reports no bottom inset,
|
||||
// so we use an empirical measurement instead
|
||||
static const double systemGestureInsetsBottom = 32;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: systemGestureInsetsBottom,
|
||||
child: AbsorbPointer(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GestureAreaProtectorStack extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
const GestureAreaProtectorStack({@required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
child,
|
||||
BottomGestureAreaProtector(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,120 +1,26 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/model/source/section_keys.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/utils/constants.dart';
|
||||
import 'package:aves/widgets/collection/grid/header_album.dart';
|
||||
import 'package:aves/widgets/collection/grid/header_date.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class SectionHeader extends StatelessWidget {
|
||||
final CollectionLens collection;
|
||||
final dynamic sectionKey;
|
||||
final double height;
|
||||
|
||||
const SectionHeader({
|
||||
Key key,
|
||||
@required this.collection,
|
||||
@required this.sectionKey,
|
||||
@required this.height,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget header;
|
||||
switch (collection.sortFactor) {
|
||||
case EntrySortFactor.date:
|
||||
switch (collection.groupFactor) {
|
||||
case EntryGroupFactor.album:
|
||||
header = _buildAlbumSectionHeader();
|
||||
break;
|
||||
case EntryGroupFactor.month:
|
||||
header = MonthSectionHeader(key: ValueKey(sectionKey), date: sectionKey as DateTime);
|
||||
break;
|
||||
case EntryGroupFactor.day:
|
||||
header = DaySectionHeader(key: ValueKey(sectionKey), date: sectionKey as DateTime);
|
||||
break;
|
||||
case EntryGroupFactor.none:
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case EntrySortFactor.size:
|
||||
break;
|
||||
case EntrySortFactor.name:
|
||||
header = _buildAlbumSectionHeader();
|
||||
break;
|
||||
}
|
||||
return header != null
|
||||
? SizedBox(
|
||||
height: height,
|
||||
child: header,
|
||||
)
|
||||
: SizedBox.shrink();
|
||||
}
|
||||
|
||||
Widget _buildAlbumSectionHeader() {
|
||||
final folderPath = sectionKey as String;
|
||||
return AlbumSectionHeader(
|
||||
key: ValueKey(folderPath),
|
||||
folderPath: folderPath,
|
||||
albumName: collection.source.getUniqueAlbumName(folderPath),
|
||||
);
|
||||
}
|
||||
|
||||
// TODO TLAD cache header extent computation?
|
||||
static double computeHeaderHeight(BuildContext context, CollectionSource source, dynamic sectionKey, double scrollableWidth) {
|
||||
var headerExtent = 0.0;
|
||||
final textScaleFactor = MediaQuery.textScaleFactorOf(context);
|
||||
if (sectionKey is String) {
|
||||
// only compute height for album headers, as they're the only likely ones to split on multiple lines
|
||||
final hasLeading = androidFileUtils.getAlbumType(sectionKey) != AlbumType.regular;
|
||||
final hasTrailing = androidFileUtils.isOnRemovableStorage(sectionKey);
|
||||
final text = source.getUniqueAlbumName(sectionKey);
|
||||
final maxWidth = scrollableWidth - TitleSectionHeader.padding.horizontal;
|
||||
final para = RenderParagraph(
|
||||
TextSpan(
|
||||
children: [
|
||||
// as of Flutter v1.22.3, `RenderParagraph` fails to lay out `WidgetSpan` offscreen
|
||||
// so we use a hair space times a magic number to match width
|
||||
TextSpan(
|
||||
text: '\u200A' * (hasLeading ? 23 : 1),
|
||||
// force a higher first line to match leading icon/selector dimension
|
||||
style: TextStyle(height: 2.3 * textScaleFactor),
|
||||
), // 23 hair spaces match a width of 40.0
|
||||
if (hasTrailing) TextSpan(text: '\u200A' * 17),
|
||||
TextSpan(
|
||||
text: text,
|
||||
style: Constants.titleTextStyle,
|
||||
),
|
||||
],
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
textScaleFactor: textScaleFactor,
|
||||
)..layout(BoxConstraints(maxWidth: maxWidth), parentUsesSize: true);
|
||||
headerExtent = para.getMaxIntrinsicHeight(maxWidth);
|
||||
}
|
||||
headerExtent = max(headerExtent, TitleSectionHeader.leadingDimension * textScaleFactor) + TitleSectionHeader.padding.vertical;
|
||||
return headerExtent;
|
||||
}
|
||||
}
|
||||
|
||||
class TitleSectionHeader extends StatelessWidget {
|
||||
final dynamic sectionKey;
|
||||
final SectionKey sectionKey;
|
||||
final Widget leading, trailing;
|
||||
final String title;
|
||||
final bool selectable;
|
||||
|
||||
const TitleSectionHeader({
|
||||
const SectionHeader({
|
||||
Key key,
|
||||
@required this.sectionKey,
|
||||
this.leading,
|
||||
@required this.title,
|
||||
this.trailing,
|
||||
this.selectable = true,
|
||||
}) : super(key: key);
|
||||
|
||||
static const leadingDimension = 32.0;
|
||||
|
@ -136,7 +42,8 @@ class TitleSectionHeader extends StatelessWidget {
|
|||
children: [
|
||||
WidgetSpan(
|
||||
alignment: widgetSpanAlignment,
|
||||
child: SectionSelectableLeading(
|
||||
child: _SectionSelectableLeading(
|
||||
selectable: selectable,
|
||||
sectionKey: sectionKey,
|
||||
browsingBuilder: leading != null
|
||||
? (context) => Container(
|
||||
|
@ -178,24 +85,61 @@ class TitleSectionHeader extends StatelessWidget {
|
|||
collection.addToSelection(sectionEntries);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO TLAD cache header extent computation?
|
||||
static double getPreferredHeight({
|
||||
@required BuildContext context,
|
||||
@required double maxWidth,
|
||||
@required String title,
|
||||
bool hasLeading = false,
|
||||
bool hasTrailing = false,
|
||||
}) {
|
||||
final textScaleFactor = MediaQuery.textScaleFactorOf(context);
|
||||
final maxContentWidth = maxWidth - SectionHeader.padding.horizontal;
|
||||
final para = RenderParagraph(
|
||||
TextSpan(
|
||||
children: [
|
||||
// as of Flutter v1.22.3, `RenderParagraph` fails to lay out `WidgetSpan` offscreen
|
||||
// so we use a hair space times a magic number to match width
|
||||
TextSpan(
|
||||
text: '\u200A' * (hasLeading ? 23 : 1),
|
||||
// force a higher first line to match leading icon/selector dimension
|
||||
style: TextStyle(height: 2.3 * textScaleFactor),
|
||||
), // 23 hair spaces match a width of 40.0
|
||||
if (hasTrailing) TextSpan(text: '\u200A' * 17),
|
||||
TextSpan(
|
||||
text: title,
|
||||
style: Constants.titleTextStyle,
|
||||
),
|
||||
],
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
textScaleFactor: textScaleFactor,
|
||||
)..layout(BoxConstraints(maxWidth: maxContentWidth), parentUsesSize: true);
|
||||
return para.getMaxIntrinsicHeight(maxContentWidth);
|
||||
}
|
||||
}
|
||||
|
||||
class SectionSelectableLeading extends StatelessWidget {
|
||||
final dynamic sectionKey;
|
||||
class _SectionSelectableLeading extends StatelessWidget {
|
||||
final bool selectable;
|
||||
final SectionKey sectionKey;
|
||||
final WidgetBuilder browsingBuilder;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const SectionSelectableLeading({
|
||||
const _SectionSelectableLeading({
|
||||
Key key,
|
||||
this.selectable = true,
|
||||
@required this.sectionKey,
|
||||
@required this.browsingBuilder,
|
||||
@required this.onPressed,
|
||||
}) : super(key: key);
|
||||
|
||||
static const leadingDimension = TitleSectionHeader.leadingDimension;
|
||||
static const leadingDimension = SectionHeader.leadingDimension;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!selectable) return _buildBrowsing(context);
|
||||
|
||||
final collection = Provider.of<CollectionLens>(context);
|
||||
return ValueListenableBuilder<Activity>(
|
||||
valueListenable: collection.activityNotifier,
|
||||
|
@ -236,7 +180,7 @@ class SectionSelectableLeading extends StatelessWidget {
|
|||
);
|
||||
},
|
||||
)
|
||||
: browsingBuilder?.call(context) ?? SizedBox(height: leadingDimension);
|
||||
: _buildBrowsing(context);
|
||||
return AnimatedSwitcher(
|
||||
duration: Durations.sectionHeaderAnimation,
|
||||
switchInCurve: Curves.easeInOut,
|
||||
|
@ -262,4 +206,6 @@ class SectionSelectableLeading extends StatelessWidget {
|
|||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBrowsing(BuildContext context) => browsingBuilder?.call(context) ?? SizedBox(height: leadingDimension);
|
||||
}
|
242
lib/widgets/common/grid/section_layout.dart
Normal file
242
lib/widgets/common/grid/section_layout.dart
Normal file
|
@ -0,0 +1,242 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:aves/model/source/section_keys.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
abstract class SectionedListLayoutProvider<T> extends StatelessWidget {
|
||||
final double scrollableWidth;
|
||||
final int columnCount;
|
||||
final double spacing, tileExtent;
|
||||
final Widget Function(T item) tileBuilder;
|
||||
final Widget child;
|
||||
|
||||
const SectionedListLayoutProvider({
|
||||
@required this.scrollableWidth,
|
||||
@required this.columnCount,
|
||||
this.spacing = 0,
|
||||
@required this.tileExtent,
|
||||
@required this.tileBuilder,
|
||||
@required this.child,
|
||||
}) : assert(scrollableWidth != 0);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ProxyProvider0<SectionedListLayout<T>>(
|
||||
update: (context, __) => _updateLayouts(context),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
SectionedListLayout<T> _updateLayouts(BuildContext context) {
|
||||
final _showHeaders = showHeaders;
|
||||
final _sections = sections;
|
||||
final sectionKeys = _sections.keys.toList();
|
||||
|
||||
final sectionLayouts = <SectionLayout>[];
|
||||
var currentIndex = 0, currentOffset = 0.0;
|
||||
sectionKeys.forEach((sectionKey) {
|
||||
final section = _sections[sectionKey];
|
||||
final sectionItemCount = section.length;
|
||||
final rowCount = (sectionItemCount / columnCount).ceil();
|
||||
final sectionChildCount = 1 + rowCount;
|
||||
|
||||
final headerExtent = _showHeaders ? getHeaderExtent(context, sectionKey) : 0.0;
|
||||
|
||||
final sectionFirstIndex = currentIndex;
|
||||
currentIndex += sectionChildCount;
|
||||
final sectionLastIndex = currentIndex - 1;
|
||||
|
||||
final sectionMinOffset = currentOffset;
|
||||
currentOffset += headerExtent + tileExtent * rowCount + spacing * (rowCount - 1);
|
||||
final sectionMaxOffset = currentOffset;
|
||||
|
||||
sectionLayouts.add(
|
||||
SectionLayout(
|
||||
sectionKey: sectionKey,
|
||||
firstIndex: sectionFirstIndex,
|
||||
lastIndex: sectionLastIndex,
|
||||
minOffset: sectionMinOffset,
|
||||
maxOffset: sectionMaxOffset,
|
||||
headerExtent: headerExtent,
|
||||
tileExtent: tileExtent,
|
||||
spacing: spacing,
|
||||
builder: (context, listIndex) => _buildInSection(
|
||||
context,
|
||||
section,
|
||||
listIndex * columnCount,
|
||||
listIndex - sectionFirstIndex,
|
||||
sectionKey,
|
||||
headerExtent,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
return SectionedListLayout<T>(
|
||||
sections: _sections,
|
||||
showHeaders: _showHeaders,
|
||||
columnCount: columnCount,
|
||||
tileExtent: tileExtent,
|
||||
spacing: spacing,
|
||||
sectionLayouts: sectionLayouts,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInSection(
|
||||
BuildContext context,
|
||||
List<T> section,
|
||||
int sectionGridIndex,
|
||||
int sectionChildIndex,
|
||||
SectionKey sectionKey,
|
||||
double headerExtent,
|
||||
) {
|
||||
if (sectionChildIndex == 0) {
|
||||
final header = headerExtent > 0 ? buildHeader(context, sectionKey, headerExtent) : SizedBox.shrink();
|
||||
return _buildAnimation(sectionGridIndex, header);
|
||||
}
|
||||
sectionChildIndex--;
|
||||
|
||||
final sectionItemCount = section.length;
|
||||
|
||||
final minItemIndex = sectionChildIndex * columnCount;
|
||||
final maxItemIndex = min(sectionItemCount, minItemIndex + columnCount);
|
||||
final children = <Widget>[];
|
||||
for (var i = minItemIndex; i < maxItemIndex; i++) {
|
||||
final itemGridIndex = sectionGridIndex + i - minItemIndex;
|
||||
final item = tileBuilder(section[i]);
|
||||
if (i != minItemIndex) children.add(SizedBox(width: spacing));
|
||||
children.add(_buildAnimation(itemGridIndex, item));
|
||||
}
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAnimation(int index, Widget child) {
|
||||
return AnimationConfiguration.staggeredGrid(
|
||||
position: index,
|
||||
columnCount: columnCount,
|
||||
duration: Durations.staggeredAnimation,
|
||||
delay: Durations.staggeredAnimationDelay,
|
||||
child: SlideAnimation(
|
||||
verticalOffset: 50.0,
|
||||
child: FadeInAnimation(
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool get showHeaders;
|
||||
|
||||
Map<SectionKey, List<T>> get sections;
|
||||
|
||||
double getHeaderExtent(BuildContext context, SectionKey sectionKey);
|
||||
|
||||
Widget buildHeader(BuildContext context, SectionKey sectionKey, double headerExtent);
|
||||
}
|
||||
|
||||
class SectionedListLayout<T> {
|
||||
final Map<SectionKey, List<T>> sections;
|
||||
final bool showHeaders;
|
||||
final int columnCount;
|
||||
final double tileExtent, spacing;
|
||||
final List<SectionLayout> sectionLayouts;
|
||||
|
||||
const SectionedListLayout({
|
||||
@required this.sections,
|
||||
@required this.showHeaders,
|
||||
@required this.columnCount,
|
||||
@required this.tileExtent,
|
||||
@required this.spacing,
|
||||
@required this.sectionLayouts,
|
||||
});
|
||||
|
||||
Rect getTileRect(T item) {
|
||||
final section = sections.entries.firstWhere((kv) => kv.value.contains(item), orElse: () => null);
|
||||
if (section == null) return null;
|
||||
|
||||
final sectionKey = section.key;
|
||||
final sectionLayout = sectionLayouts.firstWhere((sl) => sl.sectionKey == sectionKey, orElse: () => null);
|
||||
if (sectionLayout == null) return null;
|
||||
|
||||
final sectionItemIndex = section.value.indexOf(item);
|
||||
final column = sectionItemIndex % columnCount;
|
||||
final row = (sectionItemIndex / columnCount).floor();
|
||||
final listIndex = sectionLayout.firstIndex + (showHeaders ? 1 : 0) + row;
|
||||
|
||||
final left = tileExtent * column + spacing * (column - 1);
|
||||
final top = sectionLayout.indexToLayoutOffset(listIndex);
|
||||
return Rect.fromLTWH(left, top, tileExtent, tileExtent);
|
||||
}
|
||||
|
||||
T getItemAt(Offset position) {
|
||||
var dy = position.dy;
|
||||
final sectionLayout = sectionLayouts.firstWhere((sl) => dy < sl.maxOffset, orElse: () => null);
|
||||
if (sectionLayout == null) return null;
|
||||
|
||||
final section = sections[sectionLayout.sectionKey];
|
||||
if (section == null) return null;
|
||||
|
||||
dy -= sectionLayout.minOffset + sectionLayout.headerExtent;
|
||||
if (dy < 0) return null;
|
||||
|
||||
final row = dy ~/ (tileExtent + spacing);
|
||||
final column = position.dx ~/ (tileExtent + spacing);
|
||||
final index = row * columnCount + column;
|
||||
if (index >= section.length) return null;
|
||||
|
||||
return section[index];
|
||||
}
|
||||
}
|
||||
|
||||
class SectionLayout {
|
||||
final SectionKey sectionKey;
|
||||
final int firstIndex, lastIndex, bodyFirstIndex;
|
||||
final double minOffset, maxOffset, bodyMinOffset;
|
||||
final double headerExtent, tileExtent, spacing, mainAxisStride;
|
||||
final IndexedWidgetBuilder builder;
|
||||
|
||||
const SectionLayout({
|
||||
@required this.sectionKey,
|
||||
@required this.firstIndex,
|
||||
@required this.lastIndex,
|
||||
@required this.minOffset,
|
||||
@required this.maxOffset,
|
||||
@required this.headerExtent,
|
||||
@required this.tileExtent,
|
||||
@required this.spacing,
|
||||
@required this.builder,
|
||||
}) : bodyFirstIndex = firstIndex + 1,
|
||||
bodyMinOffset = minOffset + headerExtent,
|
||||
mainAxisStride = tileExtent + spacing;
|
||||
|
||||
bool hasChild(int index) => firstIndex <= index && index <= lastIndex;
|
||||
|
||||
bool hasChildAtOffset(double scrollOffset) => minOffset <= scrollOffset && scrollOffset <= maxOffset;
|
||||
|
||||
double indexToLayoutOffset(int index) {
|
||||
index -= bodyFirstIndex;
|
||||
if (index < 0) return minOffset;
|
||||
return bodyMinOffset + index * mainAxisStride;
|
||||
}
|
||||
|
||||
int getMinChildIndexForScrollOffset(double scrollOffset) {
|
||||
scrollOffset -= bodyMinOffset;
|
||||
if (scrollOffset < 0) return firstIndex;
|
||||
return bodyFirstIndex + scrollOffset ~/ mainAxisStride;
|
||||
}
|
||||
|
||||
int getMaxChildIndexForScrollOffset(double scrollOffset) {
|
||||
scrollOffset -= bodyMinOffset;
|
||||
if (scrollOffset < 0) return firstIndex;
|
||||
return bodyFirstIndex + (scrollOffset / mainAxisStride).ceil() - 1;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{sectionKey=$sectionKey, firstIndex=$firstIndex, lastIndex=$lastIndex, minOffset=$minOffset, maxOffset=$maxOffset, headerExtent=$headerExtent, tileExtent=$tileExtent, spacing=$spacing}';
|
||||
}
|
|
@ -1,32 +1,59 @@
|
|||
import 'dart:math' as math;
|
||||
|
||||
import 'package:aves/widgets/collection/grid/list_section_layout.dart';
|
||||
import 'package:aves/widgets/common/grid/section_layout.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class SliverKnownExtentList extends SliverMultiBoxAdaptorWidget {
|
||||
// Use a `SliverList` instead of multiple `SliverGrid` because having one `SliverGrid` per section does not scale up.
|
||||
// With the multiple `SliverGrid` solution, thumbnails at the beginning of each sections are built even though they are offscreen
|
||||
// because of `RenderSliverMultiBoxAdaptor.addInitialChild` called by `RenderSliverGrid.performLayout` (line 547), as of Flutter v1.17.0.
|
||||
class SectionedListSliver<T> extends StatelessWidget {
|
||||
const SectionedListSliver();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sectionLayouts = context.watch<SectionedListLayout<T>>().sectionLayouts;
|
||||
final childCount = sectionLayouts.isEmpty ? 0 : sectionLayouts.last.lastIndex + 1;
|
||||
return _SliverKnownExtentList(
|
||||
sectionLayouts: sectionLayouts,
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
if (index >= childCount) return null;
|
||||
final sectionLayout = sectionLayouts.firstWhere((section) => section.hasChild(index), orElse: () => null);
|
||||
return sectionLayout?.builder(context, index) ?? SizedBox.shrink();
|
||||
},
|
||||
childCount: childCount,
|
||||
addAutomaticKeepAlives: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SliverKnownExtentList extends SliverMultiBoxAdaptorWidget {
|
||||
final List<SectionLayout> sectionLayouts;
|
||||
|
||||
const SliverKnownExtentList({
|
||||
const _SliverKnownExtentList({
|
||||
Key key,
|
||||
@required SliverChildDelegate delegate,
|
||||
@required this.sectionLayouts,
|
||||
}) : super(key: key, delegate: delegate);
|
||||
|
||||
@override
|
||||
RenderSliverKnownExtentBoxAdaptor createRenderObject(BuildContext context) {
|
||||
_RenderSliverKnownExtentBoxAdaptor createRenderObject(BuildContext context) {
|
||||
final element = context as SliverMultiBoxAdaptorElement;
|
||||
return RenderSliverKnownExtentBoxAdaptor(childManager: element, sectionLayouts: sectionLayouts);
|
||||
return _RenderSliverKnownExtentBoxAdaptor(childManager: element, sectionLayouts: sectionLayouts);
|
||||
}
|
||||
|
||||
@override
|
||||
void updateRenderObject(BuildContext context, RenderSliverKnownExtentBoxAdaptor renderObject) {
|
||||
void updateRenderObject(BuildContext context, _RenderSliverKnownExtentBoxAdaptor renderObject) {
|
||||
renderObject.sectionLayouts = sectionLayouts;
|
||||
}
|
||||
}
|
||||
|
||||
class RenderSliverKnownExtentBoxAdaptor extends RenderSliverMultiBoxAdaptor {
|
||||
class _RenderSliverKnownExtentBoxAdaptor extends RenderSliverMultiBoxAdaptor {
|
||||
List<SectionLayout> _sectionLayouts;
|
||||
|
||||
List<SectionLayout> get sectionLayouts => _sectionLayouts;
|
||||
|
@ -38,7 +65,7 @@ class RenderSliverKnownExtentBoxAdaptor extends RenderSliverMultiBoxAdaptor {
|
|||
markNeedsLayout();
|
||||
}
|
||||
|
||||
RenderSliverKnownExtentBoxAdaptor({
|
||||
_RenderSliverKnownExtentBoxAdaptor({
|
||||
@required RenderSliverBoxChildManager childManager,
|
||||
@required List<SectionLayout> sectionLayouts,
|
||||
}) : _sectionLayouts = sectionLayouts,
|
||||
|
@ -119,7 +146,7 @@ class RenderSliverKnownExtentBoxAdaptor extends RenderSliverMultiBoxAdaptor {
|
|||
|
||||
if (firstChild != null) {
|
||||
final leadingGarbage = _calculateLeadingGarbage(firstIndex);
|
||||
final trailingGarbage = _calculateTrailingGarbage(targetLastIndex);
|
||||
final trailingGarbage = targetLastIndex != null ? _calculateTrailingGarbage(targetLastIndex) : 0;
|
||||
collectGarbage(leadingGarbage, trailingGarbage);
|
||||
} else {
|
||||
collectGarbage(0, 0);
|
||||
|
@ -164,7 +191,7 @@ class RenderSliverKnownExtentBoxAdaptor extends RenderSliverMultiBoxAdaptor {
|
|||
// Reset the scroll offset to offset all items prior and up to the
|
||||
// missing item. Let parent re-layout everything.
|
||||
final layout = sectionAtIndex(index) ?? sectionLayouts.first;
|
||||
geometry = SliverGeometry(scrollOffsetCorrection: layout.indexToMaxScrollOffset(index));
|
||||
geometry = SliverGeometry(scrollOffsetCorrection: layout.indexToLayoutOffset(index));
|
||||
return;
|
||||
}
|
||||
final childParentData = child.parentData as SliverMultiBoxAdaptorParentData;
|
||||
|
@ -188,7 +215,7 @@ class RenderSliverKnownExtentBoxAdaptor extends RenderSliverMultiBoxAdaptor {
|
|||
if (child == null) {
|
||||
// We have run out of children.
|
||||
final layout = sectionAtIndex(index) ?? sectionLayouts.last;
|
||||
estimatedMaxScrollOffset = layout.indexToMaxScrollOffset(index);
|
||||
estimatedMaxScrollOffset = layout.indexToLayoutOffset(index);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
|
@ -223,7 +250,7 @@ class RenderSliverKnownExtentBoxAdaptor extends RenderSliverMultiBoxAdaptor {
|
|||
|
||||
final paintExtent = calculatePaintOffset(
|
||||
constraints,
|
||||
from: leadingScrollOffset,
|
||||
from: math.min(constraints.scrollOffset, leadingScrollOffset),
|
||||
to: trailingScrollOffset,
|
||||
);
|
||||
|
|
@ -66,7 +66,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
|||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(AvesFilterChip oldWidget) {
|
||||
void didUpdateWidget(covariant AvesFilterChip oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.filter != filter) {
|
||||
_initColorLoader();
|
||||
|
|
|
@ -102,6 +102,21 @@ class RawIcon extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
class MultipageIcon extends StatelessWidget {
|
||||
final double iconSize;
|
||||
|
||||
const MultipageIcon({Key key, this.iconSize}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return OverlayIcon(
|
||||
icon: AIcons.multipage,
|
||||
size: iconSize,
|
||||
iconScale: .8,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class OverlayIcon extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final double size;
|
||||
|
|
|
@ -35,11 +35,11 @@ class MagnifierCore extends StatefulWidget {
|
|||
|
||||
@override
|
||||
State<StatefulWidget> createState() {
|
||||
return MagnifierCoreState();
|
||||
return _MagnifierCoreState();
|
||||
}
|
||||
}
|
||||
|
||||
class MagnifierCoreState extends State<MagnifierCore> with TickerProviderStateMixin, MagnifierControllerDelegate, CornerHitDetector {
|
||||
class _MagnifierCoreState extends State<MagnifierCore> with TickerProviderStateMixin, MagnifierControllerDelegate, CornerHitDetector {
|
||||
Offset _startFocalPoint, _lastViewportFocalPosition;
|
||||
double _startScale, _quickScaleLastY, _quickScaleLastDistance;
|
||||
bool _doubleTap, _quickScaleMoved;
|
||||
|
|
|
@ -58,19 +58,14 @@ class Magnifier extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _MagnifierState extends State<Magnifier> {
|
||||
Size _childSize;
|
||||
|
||||
bool _controlledController;
|
||||
MagnifierController _controller;
|
||||
|
||||
void _setChildSize(Size childSize) {
|
||||
_childSize = childSize.isEmpty ? null : childSize;
|
||||
}
|
||||
Size get childSize => widget.childSize;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_setChildSize(widget.childSize);
|
||||
if (widget.controller == null) {
|
||||
_controlledController = true;
|
||||
_controller = MagnifierController();
|
||||
|
@ -81,12 +76,8 @@ class _MagnifierState extends State<Magnifier> {
|
|||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(Magnifier oldWidget) {
|
||||
if (oldWidget.childSize != widget.childSize && widget.childSize != null) {
|
||||
setState(() {
|
||||
_setChildSize(widget.childSize);
|
||||
});
|
||||
}
|
||||
void didUpdateWidget(covariant Magnifier oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.controller == null) {
|
||||
if (!_controlledController) {
|
||||
_controlledController = true;
|
||||
|
@ -96,7 +87,6 @@ class _MagnifierState extends State<Magnifier> {
|
|||
_controlledController = false;
|
||||
_controller = widget.controller;
|
||||
}
|
||||
super.didUpdateWidget(oldWidget);
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -116,7 +106,7 @@ class _MagnifierState extends State<Magnifier> {
|
|||
widget.maxScale ?? ScaleLevel(factor: double.infinity),
|
||||
widget.initialScale ?? ScaleLevel(ref: ScaleReference.contained),
|
||||
constraints.biggest,
|
||||
_childSize ?? constraints.biggest,
|
||||
widget.childSize?.isEmpty == true ? constraints.biggest : widget.childSize,
|
||||
));
|
||||
|
||||
return MagnifierCore(
|
||||
|
|
|
@ -2,7 +2,7 @@ import 'dart:collection';
|
|||
|
||||
import 'package:aves/services/android_debug_service.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/common.dart';
|
||||
import 'package:aves/widgets/viewer/info/common.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class DebugAndroidDirSection extends StatefulWidget {
|
||||
|
|
|
@ -2,7 +2,7 @@ import 'dart:collection';
|
|||
|
||||
import 'package:aves/services/android_debug_service.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/common.dart';
|
||||
import 'package:aves/widgets/viewer/info/common.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class DebugAndroidEnvironmentSection extends StatefulWidget {
|
||||
|
|
|
@ -10,7 +10,7 @@ import 'package:aves/widgets/debug/firebase.dart';
|
|||
import 'package:aves/widgets/debug/overlay.dart';
|
||||
import 'package:aves/widgets/debug/settings.dart';
|
||||
import 'package:aves/widgets/debug/storage.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/common.dart';
|
||||
import 'package:aves/widgets/viewer/info/common.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
|
@ -22,10 +22,10 @@ class AppDebugPage extends StatefulWidget {
|
|||
const AppDebugPage({this.source});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => AppDebugPageState();
|
||||
State<StatefulWidget> createState() => _AppDebugPageState();
|
||||
}
|
||||
|
||||
class AppDebugPageState extends State<AppDebugPage> {
|
||||
class _AppDebugPageState extends State<AppDebugPage> {
|
||||
List<ImageEntry> get entries => widget.source.rawEntries;
|
||||
|
||||
static OverlayEntry _taskQueueOverlayEntry;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/common.dart';
|
||||
import 'package:aves/widgets/viewer/info/common.dart';
|
||||
import 'package:firebase_analytics/firebase_analytics.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
|
||||
|
|
|
@ -4,7 +4,7 @@ import 'package:aves/widgets/common/identity/aves_expansion_tile.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:aves/widgets/fullscreen/info/common.dart';
|
||||
import 'package:aves/widgets/viewer/info/common.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@ import 'package:aves/services/android_file_service.dart';
|
|||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/utils/file_utils.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/common.dart';
|
||||
import 'package:aves/widgets/viewer/info/common.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class DebugStorageSection extends StatefulWidget {
|
||||
|
|
|
@ -49,11 +49,12 @@ class _AlbumPickPageState extends State<AlbumPickPage> {
|
|||
return FilterGridPage<AlbumFilter>(
|
||||
source: source,
|
||||
appBar: appBar,
|
||||
filterEntries: AlbumListPage.getAlbumEntries(source),
|
||||
filterSections: AlbumListPage.getAlbumEntries(source),
|
||||
showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none,
|
||||
applyQuery: (filters, query) {
|
||||
if (query == null || query.isEmpty) return filters;
|
||||
query = query.toUpperCase();
|
||||
return filters.where((filter) => filter.uniqueName.toUpperCase().contains(query)).toList();
|
||||
return filters.where((item) => item.filter.uniqueName.toUpperCase().contains(query)).toList();
|
||||
},
|
||||
queryNotifier: _queryNotifier,
|
||||
emptyBuilder: () => EmptyContent(
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import 'package:aves/model/actions/chip_actions.dart';
|
||||
import 'package:aves/model/filters/album.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/album.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
|
@ -12,6 +11,7 @@ import 'package:aves/widgets/collection/empty.dart';
|
|||
import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/section_keys.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
@ -26,8 +26,8 @@ class AlbumListPage extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Selector<Settings, Tuple2<ChipSortFactor, Set<CollectionFilter>>>(
|
||||
selector: (context, s) => Tuple2(s.albumSortFactor, s.pinnedFilters),
|
||||
return Selector<Settings, Tuple3<AlbumChipGroupFactor, ChipSortFactor, Set<CollectionFilter>>>(
|
||||
selector: (context, s) => Tuple3(s.albumGroupFactor, s.albumSortFactor, s.pinnedFilters),
|
||||
builder: (context, s, child) {
|
||||
return AnimatedBuilder(
|
||||
animation: androidFileUtils.appNameChangeNotifier,
|
||||
|
@ -36,6 +36,8 @@ class AlbumListPage extends StatelessWidget {
|
|||
builder: (context, snapshot) => FilterNavigationPage<AlbumFilter>(
|
||||
source: source,
|
||||
title: 'Albums',
|
||||
groupable: true,
|
||||
showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none,
|
||||
chipSetActionDelegate: AlbumChipSetActionDelegate(source: source),
|
||||
chipActionDelegate: AlbumChipActionDelegate(source: source),
|
||||
chipActionsBuilder: (filter) => [
|
||||
|
@ -43,7 +45,7 @@ class AlbumListPage extends StatelessWidget {
|
|||
ChipAction.rename,
|
||||
ChipAction.delete,
|
||||
],
|
||||
filterEntries: getAlbumEntries(source),
|
||||
filterSections: getAlbumEntries(source),
|
||||
emptyBuilder: () => EmptyContent(
|
||||
icon: AIcons.album,
|
||||
text: 'No albums',
|
||||
|
@ -57,61 +59,61 @@ class AlbumListPage extends StatelessWidget {
|
|||
|
||||
// common with album selection page to move/copy entries
|
||||
|
||||
static Map<AlbumFilter, ImageEntry> getAlbumEntries(CollectionSource source) {
|
||||
final pinned = settings.pinnedFilters.whereType<AlbumFilter>();
|
||||
final entriesByDate = source.sortedEntriesForFilterList;
|
||||
|
||||
AlbumFilter _buildFilter(String album) => AlbumFilter(album, source.getUniqueAlbumName(album));
|
||||
|
||||
static Map<ChipSectionKey, List<FilterGridItem<AlbumFilter>>> getAlbumEntries(CollectionSource source) {
|
||||
// albums are initially sorted by name at the source level
|
||||
var sortedFilters = source.sortedAlbums.map(_buildFilter);
|
||||
final filters = source.sortedAlbums.map((album) => AlbumFilter(album, source.getUniqueAlbumName(album)));
|
||||
|
||||
if (settings.albumSortFactor == ChipSortFactor.name) {
|
||||
final pinnedAlbums = <AlbumFilter>[], regularAlbums = <AlbumFilter>[], appAlbums = <AlbumFilter>[], specialAlbums = <AlbumFilter>[];
|
||||
for (var filter in sortedFilters) {
|
||||
if (pinned.contains(filter)) {
|
||||
pinnedAlbums.add(filter);
|
||||
} else {
|
||||
switch (androidFileUtils.getAlbumType(filter.album)) {
|
||||
case AlbumType.regular:
|
||||
regularAlbums.add(filter);
|
||||
break;
|
||||
case AlbumType.app:
|
||||
appAlbums.add(filter);
|
||||
break;
|
||||
default:
|
||||
specialAlbums.add(filter);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Map.fromEntries([...pinnedAlbums, ...specialAlbums, ...appAlbums, ...regularAlbums].map((filter) {
|
||||
return MapEntry(
|
||||
filter,
|
||||
entriesByDate.firstWhere((entry) => entry.directory == filter.album, orElse: () => null),
|
||||
);
|
||||
}));
|
||||
}
|
||||
final sorted = FilterNavigationPage.sort(settings.albumSortFactor, source, filters);
|
||||
return _group(sorted);
|
||||
}
|
||||
|
||||
if (settings.albumSortFactor == ChipSortFactor.count) {
|
||||
final filtersWithCount = List.of(sortedFilters.map((filter) => MapEntry(filter, source.count(filter))));
|
||||
filtersWithCount.sort(FilterNavigationPage.compareChipsByEntryCount);
|
||||
sortedFilters = filtersWithCount.map((kv) => kv.key).toList();
|
||||
}
|
||||
|
||||
final allMapEntries = sortedFilters.map((filter) => MapEntry(
|
||||
filter,
|
||||
entriesByDate.firstWhere((entry) => entry.directory == filter.album, orElse: () => null),
|
||||
));
|
||||
final byPin = groupBy<MapEntry<AlbumFilter, ImageEntry>, bool>(allMapEntries, (e) => pinned.contains(e.key));
|
||||
static Map<ChipSectionKey, List<FilterGridItem<AlbumFilter>>> _group(Iterable<FilterGridItem<AlbumFilter>> sortedMapEntries) {
|
||||
final pinned = settings.pinnedFilters.whereType<AlbumFilter>();
|
||||
final byPin = groupBy<FilterGridItem<AlbumFilter>, bool>(sortedMapEntries, (e) => pinned.contains(e.filter));
|
||||
final pinnedMapEntries = (byPin[true] ?? []);
|
||||
final unpinnedMapEntries = (byPin[false] ?? []);
|
||||
|
||||
if (settings.albumSortFactor == ChipSortFactor.date) {
|
||||
pinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
|
||||
unpinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
|
||||
var sections = <ChipSectionKey, List<FilterGridItem<AlbumFilter>>>{};
|
||||
switch (settings.albumGroupFactor) {
|
||||
case AlbumChipGroupFactor.importance:
|
||||
sections = groupBy<FilterGridItem<AlbumFilter>, ChipSectionKey>(unpinnedMapEntries, (kv) {
|
||||
switch (androidFileUtils.getAlbumType(kv.filter.album)) {
|
||||
case AlbumType.regular:
|
||||
return AlbumImportanceSectionKey.regular;
|
||||
case AlbumType.app:
|
||||
return AlbumImportanceSectionKey.apps;
|
||||
default:
|
||||
return AlbumImportanceSectionKey.special;
|
||||
}
|
||||
});
|
||||
sections = {
|
||||
AlbumImportanceSectionKey.special: sections[AlbumImportanceSectionKey.special],
|
||||
AlbumImportanceSectionKey.apps: sections[AlbumImportanceSectionKey.apps],
|
||||
AlbumImportanceSectionKey.regular: sections[AlbumImportanceSectionKey.regular],
|
||||
}..removeWhere((key, value) => value == null);
|
||||
break;
|
||||
case AlbumChipGroupFactor.volume:
|
||||
sections = groupBy<FilterGridItem<AlbumFilter>, ChipSectionKey>(unpinnedMapEntries, (kv) {
|
||||
return StorageVolumeSectionKey(androidFileUtils.getStorageVolume(kv.filter.album));
|
||||
});
|
||||
break;
|
||||
case AlbumChipGroupFactor.none:
|
||||
return {
|
||||
if (pinnedMapEntries.isNotEmpty || unpinnedMapEntries.isNotEmpty)
|
||||
ChipSectionKey(): [
|
||||
...pinnedMapEntries,
|
||||
...unpinnedMapEntries,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return Map.fromEntries([...pinnedMapEntries, ...unpinnedMapEntries]);
|
||||
if (pinnedMapEntries.isNotEmpty) {
|
||||
sections = Map.fromEntries([
|
||||
MapEntry(AlbumImportanceSectionKey.pinned, pinnedMapEntries),
|
||||
...sections.entries,
|
||||
]);
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,6 +24,8 @@ abstract class ChipSetActionDelegate {
|
|||
case ChipSetAction.stats:
|
||||
_goToStats(context);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -71,6 +73,36 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate {
|
|||
|
||||
@override
|
||||
set sortFactor(ChipSortFactor factor) => settings.albumSortFactor = factor;
|
||||
|
||||
@override
|
||||
void onActionSelected(BuildContext context, ChipSetAction action) {
|
||||
switch (action) {
|
||||
case ChipSetAction.group:
|
||||
_showGroupDialog(context);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
super.onActionSelected(context, action);
|
||||
}
|
||||
|
||||
Future<void> _showGroupDialog(BuildContext context) async {
|
||||
final factor = await showDialog<AlbumChipGroupFactor>(
|
||||
context: context,
|
||||
builder: (context) => AvesSelectionDialog<AlbumChipGroupFactor>(
|
||||
initialValue: settings.albumGroupFactor,
|
||||
options: {
|
||||
AlbumChipGroupFactor.importance: 'By importance',
|
||||
AlbumChipGroupFactor.volume: 'By storage volume',
|
||||
AlbumChipGroupFactor.none: 'Do not group',
|
||||
},
|
||||
title: 'Group',
|
||||
),
|
||||
);
|
||||
if (factor != null) {
|
||||
settings.albumGroupFactor = factor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CountryChipSetActionDelegate extends ChipSetActionDelegate {
|
||||
|
|
|
@ -43,11 +43,11 @@ class DecoratedFilterChip extends StatelessWidget {
|
|||
final backgroundImage = entry == null
|
||||
? Container(color: Colors.white)
|
||||
: entry.isSvg
|
||||
? ThumbnailVectorImage(
|
||||
? VectorImageThumbnail(
|
||||
entry: entry,
|
||||
extent: extent,
|
||||
)
|
||||
: ThumbnailRasterImage(
|
||||
: RasterImageThumbnail(
|
||||
entry: entry,
|
||||
extent: extent,
|
||||
);
|
||||
|
@ -78,6 +78,12 @@ class DecoratedFilterChip extends StatelessWidget {
|
|||
],
|
||||
);
|
||||
|
||||
child = SizedBox(
|
||||
width: extent,
|
||||
height: extent,
|
||||
child: child,
|
||||
);
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
|
|
|
@ -2,12 +2,13 @@ import 'dart:ui';
|
|||
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/highlight.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/widgets/common/behaviour/double_back_pop.dart';
|
||||
import 'package:aves/widgets/common/behaviour/routes.dart';
|
||||
import 'package:aves/widgets/common/gesture_area_protector.dart';
|
||||
import 'package:aves/widgets/common/grid/section_layout.dart';
|
||||
import 'package:aves/widgets/common/grid/sliver.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
|
||||
import 'package:aves/widgets/common/identity/scroll_thumb.dart';
|
||||
import 'package:aves/widgets/common/providers/highlight_info_provider.dart';
|
||||
|
@ -16,6 +17,8 @@ import 'package:aves/widgets/common/scaling.dart';
|
|||
import 'package:aves/widgets/common/tile_extent_manager.dart';
|
||||
import 'package:aves/widgets/drawer/app_drawer.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/decorated_filter_chip.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/section_keys.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/section_layout.dart';
|
||||
import 'package:draggable_scrollbar/draggable_scrollbar.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
@ -25,11 +28,12 @@ import 'package:provider/provider.dart';
|
|||
class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
|
||||
final CollectionSource source;
|
||||
final Widget appBar;
|
||||
final Map<T, ImageEntry> filterEntries;
|
||||
final Map<ChipSectionKey, List<FilterGridItem<T>>> filterSections;
|
||||
final bool showHeaders;
|
||||
final ValueNotifier<String> queryNotifier;
|
||||
final Widget Function() emptyBuilder;
|
||||
final String settingsRouteKey;
|
||||
final Iterable<T> Function(Iterable<T> filters, String query) applyQuery;
|
||||
final Iterable<FilterGridItem<T>> Function(Iterable<FilterGridItem<T>> filters, String query) applyQuery;
|
||||
final FilterCallback onTap;
|
||||
final OffsetFilterCallback onLongPress;
|
||||
|
||||
|
@ -45,7 +49,8 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
|
|||
Key key,
|
||||
@required this.source,
|
||||
@required this.appBar,
|
||||
@required this.filterEntries,
|
||||
@required this.filterSections,
|
||||
this.showHeaders = false,
|
||||
@required this.queryNotifier,
|
||||
this.applyQuery,
|
||||
@required this.emptyBuilder,
|
||||
|
@ -65,81 +70,110 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
|
|||
child: Scaffold(
|
||||
body: DoubleBackPopScope(
|
||||
child: HighlightInfoProvider(
|
||||
child: SafeArea(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final viewportSize = constraints.biggest;
|
||||
assert(viewportSize.isFinite, 'Cannot layout collection with unbounded constraints.');
|
||||
if (viewportSize.isEmpty) return SizedBox.shrink();
|
||||
child: GestureAreaProtectorStack(
|
||||
child: SafeArea(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final viewportSize = constraints.biggest;
|
||||
assert(viewportSize.isFinite, 'Cannot layout collection with unbounded constraints.');
|
||||
if (viewportSize.isEmpty) return SizedBox.shrink();
|
||||
|
||||
final tileExtentManager = TileExtentManager(
|
||||
settingsRouteKey: settingsRouteKey ?? context.currentRouteName,
|
||||
extentNotifier: _tileExtentNotifier,
|
||||
columnCountDefault: columnCountDefault,
|
||||
extentMin: extentMin,
|
||||
spacing: spacing,
|
||||
)..applyTileExtent(viewportSize: viewportSize);
|
||||
final tileExtentManager = TileExtentManager(
|
||||
settingsRouteKey: settingsRouteKey ?? context.currentRouteName,
|
||||
extentNotifier: _tileExtentNotifier,
|
||||
columnCountDefault: columnCountDefault,
|
||||
extentMin: extentMin,
|
||||
spacing: spacing,
|
||||
)..applyTileExtent(viewportSize: viewportSize);
|
||||
|
||||
return ValueListenableBuilder<double>(
|
||||
valueListenable: _tileExtentNotifier,
|
||||
builder: (context, tileExtent, child) {
|
||||
final columnCount = tileExtentManager.getEffectiveColumnCountForExtent(viewportSize, tileExtent);
|
||||
final pinnedFilters = settings.pinnedFilters;
|
||||
return ValueListenableBuilder<String>(
|
||||
valueListenable: queryNotifier,
|
||||
builder: (context, query, child) {
|
||||
Map<ChipSectionKey, List<FilterGridItem<T>>> visibleFilterSections;
|
||||
if (applyQuery == null) {
|
||||
visibleFilterSections = filterSections;
|
||||
} else {
|
||||
visibleFilterSections = {};
|
||||
filterSections.forEach((sectionKey, sectionFilters) {
|
||||
final visibleFilters = applyQuery(sectionFilters, query);
|
||||
if (visibleFilters.isNotEmpty) {
|
||||
visibleFilterSections[sectionKey] = visibleFilters.toList();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return ValueListenableBuilder<String>(
|
||||
valueListenable: queryNotifier,
|
||||
builder: (context, query, child) {
|
||||
final allFilters = filterEntries.keys;
|
||||
final visibleFilters = (applyQuery != null ? applyQuery(allFilters, query) : allFilters).toList();
|
||||
final scrollView = AnimationLimiter(
|
||||
child: _buildDraggableScrollView(_buildScrollView(context, visibleFilterSections.isEmpty)),
|
||||
);
|
||||
|
||||
final scrollView = AnimationLimiter(
|
||||
child: _buildDraggableScrollView(_buildScrollView(context, columnCount, visibleFilters)),
|
||||
);
|
||||
|
||||
return GridScaleGestureDetector<FilterGridItem>(
|
||||
tileExtentManager: tileExtentManager,
|
||||
scrollableKey: _scrollableKey,
|
||||
appBarHeightNotifier: _appBarHeightNotifier,
|
||||
viewportSize: viewportSize,
|
||||
gridBuilder: (center, extent, child) => CustomPaint(
|
||||
painter: GridPainter(
|
||||
center: center,
|
||||
extent: extent,
|
||||
spacing: tileExtentManager.spacing,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
child: child,
|
||||
final scaler = GridScaleGestureDetector<FilterGridItem<T>>(
|
||||
tileExtentManager: tileExtentManager,
|
||||
scrollableKey: _scrollableKey,
|
||||
appBarHeightNotifier: _appBarHeightNotifier,
|
||||
viewportSize: viewportSize,
|
||||
gridBuilder: (center, extent, child) => CustomPaint(
|
||||
painter: GridPainter(
|
||||
center: center,
|
||||
extent: extent,
|
||||
spacing: tileExtentManager.spacing,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
scaledBuilder: (item, extent) {
|
||||
final filter = item.filter;
|
||||
return SizedBox(
|
||||
width: extent,
|
||||
height: extent,
|
||||
child: child,
|
||||
),
|
||||
scaledBuilder: (item, extent) {
|
||||
final filter = item.filter;
|
||||
return DecoratedFilterChip(
|
||||
source: source,
|
||||
filter: filter,
|
||||
entry: item.entry,
|
||||
extent: extent,
|
||||
pinned: pinnedFilters.contains(filter),
|
||||
highlightable: false,
|
||||
);
|
||||
},
|
||||
getScaledItemTileRect: (context, item) {
|
||||
final sectionedListLayout = context.read<SectionedListLayout<FilterGridItem<T>>>();
|
||||
return sectionedListLayout.getTileRect(item) ?? Rect.zero;
|
||||
},
|
||||
onScaled: (item) => Provider.of<HighlightInfo>(context, listen: false).add(item.filter),
|
||||
child: scrollView,
|
||||
);
|
||||
|
||||
final sectionedListLayoutProvider = ValueListenableBuilder<double>(
|
||||
valueListenable: _tileExtentNotifier,
|
||||
builder: (context, tileExtent, child) => SectionedFilterListLayoutProvider<T>(
|
||||
sections: visibleFilterSections,
|
||||
showHeaders: showHeaders,
|
||||
scrollableWidth: viewportSize.width,
|
||||
tileExtent: tileExtent,
|
||||
columnCount: tileExtentManager.getEffectiveColumnCountForExtent(viewportSize, tileExtent),
|
||||
spacing: spacing,
|
||||
tileBuilder: (gridItem) {
|
||||
final filter = gridItem.filter;
|
||||
final entry = gridItem.entry;
|
||||
return MetaData(
|
||||
metaData: ScalerMetadata(FilterGridItem<T>(filter, entry)),
|
||||
child: DecoratedFilterChip(
|
||||
key: Key(filter.key),
|
||||
source: source,
|
||||
filter: filter,
|
||||
entry: item.entry,
|
||||
extent: extent,
|
||||
pinned: settings.pinnedFilters.contains(filter),
|
||||
highlightable: false,
|
||||
entry: entry,
|
||||
extent: _tileExtentNotifier.value,
|
||||
pinned: pinnedFilters.contains(filter),
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
),
|
||||
);
|
||||
},
|
||||
getScaledItemTileRect: (context, item) {
|
||||
final index = visibleFilters.indexOf(item.filter);
|
||||
final column = index % columnCount;
|
||||
final row = (index / columnCount).floor();
|
||||
final left = tileExtent * column + spacing * (column - 1);
|
||||
final top = tileExtent * row + spacing * (row - 1);
|
||||
return Rect.fromLTWH(left, top, tileExtent, tileExtent);
|
||||
},
|
||||
onScaled: (item) => Provider.of<HighlightInfo>(context, listen: false).add(item.filter),
|
||||
child: scrollView,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
child: scaler,
|
||||
),
|
||||
);
|
||||
return sectionedListLayoutProvider;
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -173,81 +207,42 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
ScrollView _buildScrollView(BuildContext context, int columnCount, List<T> visibleFilters) {
|
||||
final pinnedFilters = settings.pinnedFilters;
|
||||
ScrollView _buildScrollView(BuildContext context, bool empty) {
|
||||
Widget content;
|
||||
if (empty) {
|
||||
content = SliverFillRemaining(
|
||||
child: Selector<MediaQueryData, double>(
|
||||
selector: (context, mq) => mq.viewInsets.bottom,
|
||||
builder: (context, mqViewInsetsBottom, child) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: mqViewInsetsBottom),
|
||||
child: emptyBuilder(),
|
||||
);
|
||||
},
|
||||
),
|
||||
hasScrollBody: false,
|
||||
);
|
||||
} else {
|
||||
content = SectionedListSliver<FilterGridItem<T>>();
|
||||
}
|
||||
|
||||
final padding = SliverToBoxAdapter(
|
||||
child: Selector<MediaQueryData, double>(
|
||||
selector: (context, mq) => mq.viewInsets.bottom,
|
||||
builder: (context, mqViewInsetsBottom, child) {
|
||||
return SizedBox(height: mqViewInsetsBottom);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return CustomScrollView(
|
||||
key: _scrollableKey,
|
||||
controller: PrimaryScrollController.of(context),
|
||||
slivers: [
|
||||
appBar,
|
||||
visibleFilters.isEmpty
|
||||
? SliverFillRemaining(
|
||||
child: Selector<MediaQueryData, double>(
|
||||
selector: (context, mq) => mq.viewInsets.bottom,
|
||||
builder: (context, mqViewInsetsBottom, child) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: mqViewInsetsBottom),
|
||||
child: emptyBuilder(),
|
||||
);
|
||||
},
|
||||
),
|
||||
hasScrollBody: false,
|
||||
)
|
||||
: SliverGrid(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, i) {
|
||||
final filter = visibleFilters[i];
|
||||
final entry = filterEntries[filter];
|
||||
final child = MetaData(
|
||||
metaData: ScalerMetadata(FilterGridItem(filter, entry)),
|
||||
child: DecoratedFilterChip(
|
||||
key: Key(filter.key),
|
||||
source: source,
|
||||
filter: filter,
|
||||
entry: entry,
|
||||
extent: _tileExtentNotifier.value,
|
||||
pinned: pinnedFilters.contains(filter),
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
),
|
||||
);
|
||||
return AnimationConfiguration.staggeredGrid(
|
||||
position: i,
|
||||
columnCount: columnCount,
|
||||
duration: Durations.staggeredAnimation,
|
||||
delay: Durations.staggeredAnimationDelay,
|
||||
child: SlideAnimation(
|
||||
verticalOffset: 50.0,
|
||||
child: FadeInAnimation(
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: visibleFilters.length,
|
||||
),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: columnCount,
|
||||
mainAxisSpacing: spacing,
|
||||
crossAxisSpacing: spacing,
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Selector<MediaQueryData, double>(
|
||||
selector: (context, mq) => mq.viewInsets.bottom,
|
||||
builder: (context, mqViewInsetsBottom, child) {
|
||||
return SizedBox(height: mqViewInsetsBottom);
|
||||
},
|
||||
),
|
||||
),
|
||||
content,
|
||||
padding,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FilterGridItem<T extends CollectionFilter> {
|
||||
final T filter;
|
||||
final ImageEntry entry;
|
||||
|
||||
const FilterGridItem(this.filter, this.entry);
|
||||
}
|
||||
|
|
|
@ -3,10 +3,10 @@ import 'dart:ui';
|
|||
import 'package:aves/main.dart';
|
||||
import 'package:aves/model/actions/chip_actions.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/widgets/collection/collection_page.dart';
|
||||
|
@ -16,6 +16,7 @@ import 'package:aves/widgets/common/basic/menu_row.dart';
|
|||
import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/section_keys.dart';
|
||||
import 'package:aves/widgets/search/search_button.dart';
|
||||
import 'package:aves/widgets/search/search_delegate.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
@ -26,18 +27,21 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
|
|||
final CollectionSource source;
|
||||
final String title;
|
||||
final ChipSetActionDelegate chipSetActionDelegate;
|
||||
final bool groupable, showHeaders;
|
||||
final ChipActionDelegate chipActionDelegate;
|
||||
final Map<T, ImageEntry> filterEntries;
|
||||
final Widget Function() emptyBuilder;
|
||||
final List<ChipAction> Function(T filter) chipActionsBuilder;
|
||||
final Map<ChipSectionKey, List<FilterGridItem<T>>> filterSections;
|
||||
final Widget Function() emptyBuilder;
|
||||
|
||||
const FilterNavigationPage({
|
||||
@required this.source,
|
||||
@required this.title,
|
||||
this.groupable = false,
|
||||
this.showHeaders = false,
|
||||
@required this.chipSetActionDelegate,
|
||||
@required this.chipActionDelegate,
|
||||
@required this.chipActionsBuilder,
|
||||
@required this.filterEntries,
|
||||
@required this.filterSections,
|
||||
@required this.emptyBuilder,
|
||||
});
|
||||
|
||||
|
@ -58,7 +62,8 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
|
|||
titleSpacing: 0,
|
||||
floating: true,
|
||||
),
|
||||
filterEntries: filterEntries,
|
||||
filterSections: filterSections,
|
||||
showHeaders: showHeaders,
|
||||
queryNotifier: ValueNotifier(''),
|
||||
emptyBuilder: () => ValueListenableBuilder<SourceState>(
|
||||
valueListenable: source.stateNotifier,
|
||||
|
@ -114,6 +119,11 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
|
|||
value: ChipSetAction.sort,
|
||||
child: MenuRow(text: 'Sort…', icon: AIcons.sort),
|
||||
),
|
||||
if (groupable)
|
||||
PopupMenuItem(
|
||||
value: ChipSetAction.group,
|
||||
child: MenuRow(text: 'Group…', icon: AIcons.group),
|
||||
),
|
||||
if (kDebugMode)
|
||||
PopupMenuItem(
|
||||
value: ChipSetAction.refresh,
|
||||
|
@ -143,13 +153,40 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
|
|||
));
|
||||
}
|
||||
|
||||
static int compareChipsByDate(MapEntry<CollectionFilter, ImageEntry> a, MapEntry<CollectionFilter, ImageEntry> b) {
|
||||
final c = b.value.bestDate?.compareTo(a.value.bestDate) ?? -1;
|
||||
return c != 0 ? c : a.key.compareTo(b.key);
|
||||
static int compareFiltersByDate(FilterGridItem<CollectionFilter> a, FilterGridItem<CollectionFilter> b) {
|
||||
final c = b.entry.bestDate?.compareTo(a.entry.bestDate) ?? -1;
|
||||
return c != 0 ? c : a.filter.compareTo(b.filter);
|
||||
}
|
||||
|
||||
static int compareChipsByEntryCount(MapEntry<CollectionFilter, num> a, MapEntry<CollectionFilter, num> b) {
|
||||
static int compareFiltersByEntryCount(MapEntry<CollectionFilter, num> a, MapEntry<CollectionFilter, num> b) {
|
||||
final c = b.value.compareTo(a.value) ?? -1;
|
||||
return c != 0 ? c : a.key.compareTo(b.key);
|
||||
}
|
||||
|
||||
static Iterable<FilterGridItem<T>> sort<T extends CollectionFilter>(ChipSortFactor sortFactor, CollectionSource source, Iterable<T> filters) {
|
||||
Iterable<FilterGridItem<T>> toGridItem(CollectionSource source, Iterable<T> filters) {
|
||||
final entriesByDate = source.sortedEntriesForFilterList;
|
||||
return filters.map((filter) => FilterGridItem(
|
||||
filter,
|
||||
entriesByDate.firstWhere(filter.filter, orElse: () => null),
|
||||
));
|
||||
}
|
||||
|
||||
Iterable<FilterGridItem<T>> allMapEntries;
|
||||
switch (sortFactor) {
|
||||
case ChipSortFactor.name:
|
||||
allMapEntries = toGridItem(source, filters);
|
||||
break;
|
||||
case ChipSortFactor.date:
|
||||
allMapEntries = toGridItem(source, filters).toList()..sort(compareFiltersByDate);
|
||||
break;
|
||||
case ChipSortFactor.count:
|
||||
final filtersWithCount = List.of(filters.map((filter) => MapEntry(filter, source.count(filter))));
|
||||
filtersWithCount.sort(compareFiltersByEntryCount);
|
||||
filters = filtersWithCount.map((kv) => kv.key).toList();
|
||||
allMapEntries = toGridItem(source, filters);
|
||||
break;
|
||||
}
|
||||
return allMapEntries;
|
||||
}
|
||||
}
|
||||
|
|
27
lib/widgets/filter_grids/common/section_header.dart
Normal file
27
lib/widgets/filter_grids/common/section_header.dart
Normal file
|
@ -0,0 +1,27 @@
|
|||
import 'package:aves/widgets/common/grid/header.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/section_keys.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class FilterChipSectionHeader extends StatelessWidget {
|
||||
final ChipSectionKey sectionKey;
|
||||
|
||||
const FilterChipSectionHeader({
|
||||
Key key,
|
||||
@required this.sectionKey,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SectionHeader(
|
||||
sectionKey: sectionKey,
|
||||
leading: sectionKey.leading,
|
||||
title: sectionKey.title,
|
||||
selectable: false,
|
||||
);
|
||||
}
|
||||
|
||||
static double getPreferredHeight(BuildContext context) {
|
||||
final textScaleFactor = MediaQuery.textScaleFactorOf(context);
|
||||
return SectionHeader.leadingDimension * textScaleFactor + SectionHeader.padding.vertical;
|
||||
}
|
||||
}
|
82
lib/widgets/filter_grids/common/section_keys.dart
Normal file
82
lib/widgets/filter_grids/common/section_keys.dart
Normal file
|
@ -0,0 +1,82 @@
|
|||
import 'package:aves/model/source/section_keys.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ChipSectionKey extends SectionKey {
|
||||
final String title;
|
||||
|
||||
const ChipSectionKey({
|
||||
this.title = '',
|
||||
});
|
||||
|
||||
Widget get leading => null;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is ChipSectionKey && other.title == title;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => title.hashCode;
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{title=$title}';
|
||||
}
|
||||
|
||||
class AlbumImportanceSectionKey extends ChipSectionKey {
|
||||
final AlbumImportance importance;
|
||||
|
||||
AlbumImportanceSectionKey._private(this.importance) : super(title: importance.getText());
|
||||
|
||||
static AlbumImportanceSectionKey pinned = AlbumImportanceSectionKey._private(AlbumImportance.pinned);
|
||||
static AlbumImportanceSectionKey special = AlbumImportanceSectionKey._private(AlbumImportance.special);
|
||||
static AlbumImportanceSectionKey apps = AlbumImportanceSectionKey._private(AlbumImportance.apps);
|
||||
static AlbumImportanceSectionKey regular = AlbumImportanceSectionKey._private(AlbumImportance.regular);
|
||||
|
||||
@override
|
||||
Widget get leading => Icon(importance.getIcon());
|
||||
}
|
||||
|
||||
enum AlbumImportance { pinned, special, apps, regular }
|
||||
|
||||
extension ExtraAlbumImportance on AlbumImportance {
|
||||
String getText() {
|
||||
switch (this) {
|
||||
case AlbumImportance.pinned:
|
||||
return 'Pinned';
|
||||
case AlbumImportance.special:
|
||||
return 'Common';
|
||||
case AlbumImportance.apps:
|
||||
return 'Apps';
|
||||
case AlbumImportance.regular:
|
||||
return 'Others';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
IconData getIcon() {
|
||||
switch (this) {
|
||||
case AlbumImportance.pinned:
|
||||
return AIcons.pin;
|
||||
case AlbumImportance.special:
|
||||
return Icons.label_important_outline;
|
||||
case AlbumImportance.apps:
|
||||
return Icons.apps_outlined;
|
||||
case AlbumImportance.regular:
|
||||
return AIcons.album;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class StorageVolumeSectionKey extends ChipSectionKey {
|
||||
final StorageVolume volume;
|
||||
|
||||
StorageVolumeSectionKey(this.volume) : super(title: volume.description);
|
||||
|
||||
@override
|
||||
Widget get leading => volume.isRemovable ? Icon(AIcons.removableStorage) : null;
|
||||
}
|
44
lib/widgets/filter_grids/common/section_layout.dart
Normal file
44
lib/widgets/filter_grids/common/section_layout.dart
Normal file
|
@ -0,0 +1,44 @@
|
|||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/source/section_keys.dart';
|
||||
import 'package:aves/widgets/common/grid/section_layout.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/section_header.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SectionedFilterListLayoutProvider<T extends CollectionFilter> extends SectionedListLayoutProvider<FilterGridItem<T>> {
|
||||
const SectionedFilterListLayoutProvider({
|
||||
@required this.sections,
|
||||
@required this.showHeaders,
|
||||
@required double scrollableWidth,
|
||||
@required int columnCount,
|
||||
double spacing = 0,
|
||||
@required double tileExtent,
|
||||
@required Widget Function(FilterGridItem<T> gridItem) tileBuilder,
|
||||
@required Widget child,
|
||||
}) : super(
|
||||
scrollableWidth: scrollableWidth,
|
||||
columnCount: columnCount,
|
||||
spacing: spacing,
|
||||
tileExtent: tileExtent,
|
||||
tileBuilder: tileBuilder,
|
||||
child: child,
|
||||
);
|
||||
|
||||
@override
|
||||
final Map<SectionKey, List<FilterGridItem<T>>> sections;
|
||||
|
||||
@override
|
||||
final bool showHeaders;
|
||||
|
||||
@override
|
||||
double getHeaderExtent(BuildContext context, SectionKey sectionKey) {
|
||||
return FilterChipSectionHeader.getPreferredHeight(context);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildHeader(BuildContext context, SectionKey sectionKey, double headerExtent) {
|
||||
return FilterChipSectionHeader(
|
||||
sectionKey: sectionKey,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,7 +1,6 @@
|
|||
import 'package:aves/model/actions/chip_actions.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/filters/location.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
|
@ -11,6 +10,7 @@ import 'package:aves/widgets/collection/empty.dart';
|
|||
import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/section_keys.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
@ -30,7 +30,7 @@ class CountryListPage extends StatelessWidget {
|
|||
builder: (context, s, child) {
|
||||
return StreamBuilder(
|
||||
stream: source.eventBus.on<LocationsChangedEvent>(),
|
||||
builder: (context, snapshot) => FilterNavigationPage(
|
||||
builder: (context, snapshot) => FilterNavigationPage<LocationFilter>(
|
||||
source: source,
|
||||
title: 'Countries',
|
||||
chipSetActionDelegate: CountryChipSetActionDelegate(source: source),
|
||||
|
@ -38,7 +38,7 @@ class CountryListPage extends StatelessWidget {
|
|||
chipActionsBuilder: (filter) => [
|
||||
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
|
||||
],
|
||||
filterEntries: _getCountryEntries(),
|
||||
filterSections: _getCountryEntries(),
|
||||
emptyBuilder: () => EmptyContent(
|
||||
icon: AIcons.location,
|
||||
text: 'No countries',
|
||||
|
@ -49,37 +49,26 @@ class CountryListPage extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Map<LocationFilter, ImageEntry> _getCountryEntries() {
|
||||
final pinned = settings.pinnedFilters.whereType<LocationFilter>();
|
||||
|
||||
final entriesByDate = source.sortedEntriesForFilterList;
|
||||
Map<ChipSectionKey, List<FilterGridItem<LocationFilter>>> _getCountryEntries() {
|
||||
// countries are initially sorted by name at the source level
|
||||
var sortedFilters = source.sortedCountries.map((location) => LocationFilter(LocationLevel.country, location));
|
||||
if (settings.countrySortFactor == ChipSortFactor.count) {
|
||||
final filtersWithCount = List.of(sortedFilters.map((filter) => MapEntry(filter, source.count(filter))));
|
||||
filtersWithCount.sort(FilterNavigationPage.compareChipsByEntryCount);
|
||||
sortedFilters = filtersWithCount.map((kv) => kv.key).toList();
|
||||
}
|
||||
final filters = source.sortedCountries.map((location) => LocationFilter(LocationLevel.country, location));
|
||||
|
||||
final locatedEntries = entriesByDate.where((entry) => entry.isLocated);
|
||||
final allMapEntries = sortedFilters.map((filter) {
|
||||
final split = filter.countryNameAndCode.split(LocationFilter.locationSeparator);
|
||||
ImageEntry entry;
|
||||
if (split.length > 1) {
|
||||
final countryCode = split[1];
|
||||
entry = locatedEntries.firstWhere((entry) => entry.addressDetails.countryCode == countryCode, orElse: () => null);
|
||||
}
|
||||
return MapEntry(filter, entry);
|
||||
});
|
||||
final byPin = groupBy<MapEntry<LocationFilter, ImageEntry>, bool>(allMapEntries, (e) => pinned.contains(e.key));
|
||||
final sorted = FilterNavigationPage.sort(settings.countrySortFactor, source, filters);
|
||||
return _group(sorted);
|
||||
}
|
||||
|
||||
static Map<ChipSectionKey, List<FilterGridItem<LocationFilter>>> _group(Iterable<FilterGridItem<LocationFilter>> sortedMapEntries) {
|
||||
final pinned = settings.pinnedFilters.whereType<LocationFilter>();
|
||||
final byPin = groupBy<FilterGridItem<LocationFilter>, bool>(sortedMapEntries, (e) => pinned.contains(e.filter));
|
||||
final pinnedMapEntries = (byPin[true] ?? []);
|
||||
final unpinnedMapEntries = (byPin[false] ?? []);
|
||||
|
||||
if (settings.countrySortFactor == ChipSortFactor.date) {
|
||||
pinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
|
||||
unpinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
|
||||
}
|
||||
|
||||
return Map.fromEntries([...pinnedMapEntries, ...unpinnedMapEntries]);
|
||||
return {
|
||||
if (pinnedMapEntries.isNotEmpty || unpinnedMapEntries.isNotEmpty)
|
||||
ChipSectionKey(): [
|
||||
...pinnedMapEntries,
|
||||
...unpinnedMapEntries,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import 'package:aves/model/actions/chip_actions.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/filters/tag.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
|
@ -11,6 +10,7 @@ import 'package:aves/widgets/collection/empty.dart';
|
|||
import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/section_keys.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
@ -30,7 +30,7 @@ class TagListPage extends StatelessWidget {
|
|||
builder: (context, s, child) {
|
||||
return StreamBuilder(
|
||||
stream: source.eventBus.on<TagsChangedEvent>(),
|
||||
builder: (context, snapshot) => FilterNavigationPage(
|
||||
builder: (context, snapshot) => FilterNavigationPage<TagFilter>(
|
||||
source: source,
|
||||
title: 'Tags',
|
||||
chipSetActionDelegate: TagChipSetActionDelegate(source: source),
|
||||
|
@ -38,7 +38,7 @@ class TagListPage extends StatelessWidget {
|
|||
chipActionsBuilder: (filter) => [
|
||||
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
|
||||
],
|
||||
filterEntries: _getTagEntries(),
|
||||
filterSections: _getTagEntries(),
|
||||
emptyBuilder: () => EmptyContent(
|
||||
icon: AIcons.tag,
|
||||
text: 'No tags',
|
||||
|
@ -49,31 +49,26 @@ class TagListPage extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Map<TagFilter, ImageEntry> _getTagEntries() {
|
||||
final pinned = settings.pinnedFilters.whereType<TagFilter>();
|
||||
|
||||
final entriesByDate = source.sortedEntriesForFilterList;
|
||||
Map<ChipSectionKey, List<FilterGridItem<TagFilter>>> _getTagEntries() {
|
||||
// tags are initially sorted by name at the source level
|
||||
var sortedFilters = source.sortedTags.map((tag) => TagFilter(tag));
|
||||
if (settings.tagSortFactor == ChipSortFactor.count) {
|
||||
final filtersWithCount = List.of(sortedFilters.map((filter) => MapEntry(filter, source.count(filter))));
|
||||
filtersWithCount.sort(FilterNavigationPage.compareChipsByEntryCount);
|
||||
sortedFilters = filtersWithCount.map((kv) => kv.key).toList();
|
||||
}
|
||||
final filters = source.sortedTags.map((tag) => TagFilter(tag));
|
||||
|
||||
final allMapEntries = sortedFilters.map((filter) => MapEntry(
|
||||
filter,
|
||||
entriesByDate.firstWhere((entry) => entry.xmpSubjects.contains(filter.tag), orElse: () => null),
|
||||
));
|
||||
final byPin = groupBy<MapEntry<TagFilter, ImageEntry>, bool>(allMapEntries, (e) => pinned.contains(e.key));
|
||||
final sorted = FilterNavigationPage.sort(settings.tagSortFactor, source, filters);
|
||||
return _group(sorted);
|
||||
}
|
||||
|
||||
static Map<ChipSectionKey, List<FilterGridItem<TagFilter>>> _group(Iterable<FilterGridItem<TagFilter>> sortedMapEntries) {
|
||||
final pinned = settings.pinnedFilters.whereType<TagFilter>();
|
||||
final byPin = groupBy<FilterGridItem<TagFilter>, bool>(sortedMapEntries, (e) => pinned.contains(e.filter));
|
||||
final pinnedMapEntries = (byPin[true] ?? []);
|
||||
final unpinnedMapEntries = (byPin[false] ?? []);
|
||||
|
||||
if (settings.tagSortFactor == ChipSortFactor.date) {
|
||||
pinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
|
||||
unpinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
|
||||
}
|
||||
|
||||
return Map.fromEntries([...pinnedMapEntries, ...unpinnedMapEntries]);
|
||||
return {
|
||||
if (pinnedMapEntries.isNotEmpty || unpinnedMapEntries.isNotEmpty)
|
||||
ChipSectionKey(): [
|
||||
...pinnedMapEntries,
|
||||
...unpinnedMapEntries,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,100 +0,0 @@
|
|||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/widgets/common/magnifier/pan/gesture_detector_scope.dart';
|
||||
import 'package:aves/widgets/common/magnifier/pan/scroll_physics.dart';
|
||||
import 'package:aves/widgets/fullscreen/image_view.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class MultiImagePage extends StatefulWidget {
|
||||
final CollectionLens collection;
|
||||
final PageController pageController;
|
||||
final ValueChanged<int> onPageChanged;
|
||||
final VoidCallback onTap;
|
||||
final List<Tuple2<String, IjkMediaController>> videoControllers;
|
||||
final void Function(String uri) onViewDisposed;
|
||||
|
||||
const MultiImagePage({
|
||||
this.collection,
|
||||
this.pageController,
|
||||
this.onPageChanged,
|
||||
this.onTap,
|
||||
this.videoControllers,
|
||||
this.onViewDisposed,
|
||||
});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => MultiImagePageState();
|
||||
}
|
||||
|
||||
class MultiImagePageState extends State<MultiImagePage> with AutomaticKeepAliveClientMixin {
|
||||
List<ImageEntry> get entries => widget.collection.sortedEntries;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
|
||||
return MagnifierGestureDetectorScope(
|
||||
axis: [Axis.horizontal, Axis.vertical],
|
||||
child: PageView.builder(
|
||||
key: Key('horizontal-pageview'),
|
||||
scrollDirection: Axis.horizontal,
|
||||
controller: widget.pageController,
|
||||
physics: MagnifierScrollerPhysics(parent: BouncingScrollPhysics()),
|
||||
onPageChanged: widget.onPageChanged,
|
||||
itemBuilder: (context, index) {
|
||||
final entry = entries[index];
|
||||
return ClipRect(
|
||||
child: ImageView(
|
||||
key: Key('imageview'),
|
||||
entry: entry,
|
||||
heroTag: widget.collection.heroTag(entry),
|
||||
onTap: (_) => widget.onTap?.call(),
|
||||
videoControllers: widget.videoControllers,
|
||||
onDisposed: () => widget.onViewDisposed?.call(entry.uri),
|
||||
),
|
||||
);
|
||||
},
|
||||
itemCount: entries.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
}
|
||||
|
||||
class SingleImagePage extends StatefulWidget {
|
||||
final ImageEntry entry;
|
||||
final VoidCallback onTap;
|
||||
final List<Tuple2<String, IjkMediaController>> videoControllers;
|
||||
|
||||
const SingleImagePage({
|
||||
this.entry,
|
||||
this.onTap,
|
||||
this.videoControllers,
|
||||
});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => SingleImagePageState();
|
||||
}
|
||||
|
||||
class SingleImagePageState extends State<SingleImagePage> with AutomaticKeepAliveClientMixin {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
|
||||
return MagnifierGestureDetectorScope(
|
||||
axis: [Axis.vertical],
|
||||
child: ImageView(
|
||||
entry: widget.entry,
|
||||
onTap: (_) => widget.onTap?.call(),
|
||||
videoControllers: widget.videoControllers,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
}
|
|
@ -1,337 +0,0 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/image_metadata.dart';
|
||||
import 'package:aves/model/settings/coordinate_format.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/services/metadata_service.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/utils/constants.dart';
|
||||
import 'package:aves/widgets/common/fx/blurred.dart';
|
||||
import 'package:aves/widgets/fullscreen/overlay/common.dart';
|
||||
import 'package:decorated_icon/decorated_icon.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class FullscreenBottomOverlay extends StatefulWidget {
|
||||
final List<ImageEntry> entries;
|
||||
final int index;
|
||||
final bool showPosition;
|
||||
final EdgeInsets viewInsets, viewPadding;
|
||||
|
||||
const FullscreenBottomOverlay({
|
||||
Key key,
|
||||
@required this.entries,
|
||||
@required this.index,
|
||||
@required this.showPosition,
|
||||
this.viewInsets,
|
||||
this.viewPadding,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _FullscreenBottomOverlayState();
|
||||
}
|
||||
|
||||
class _FullscreenBottomOverlayState extends State<FullscreenBottomOverlay> {
|
||||
Future<OverlayMetadata> _detailLoader;
|
||||
ImageEntry _lastEntry;
|
||||
OverlayMetadata _lastDetails;
|
||||
|
||||
static const innerPadding = EdgeInsets.symmetric(vertical: 4, horizontal: 8);
|
||||
|
||||
ImageEntry get entry {
|
||||
final entries = widget.entries;
|
||||
final index = widget.index;
|
||||
return index < entries.length ? entries[index] : null;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initDetailLoader();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(FullscreenBottomOverlay oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (entry != _lastEntry) {
|
||||
_initDetailLoader();
|
||||
}
|
||||
}
|
||||
|
||||
void _initDetailLoader() {
|
||||
_detailLoader = MetadataService.getOverlayMetadata(entry);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IgnorePointer(
|
||||
child: BlurredRect(
|
||||
child: Selector<MediaQueryData, Tuple3<double, EdgeInsets, EdgeInsets>>(
|
||||
selector: (c, mq) => Tuple3(mq.size.width, mq.viewInsets, mq.viewPadding),
|
||||
builder: (c, mq, child) {
|
||||
final mqWidth = mq.item1;
|
||||
final mqViewInsets = mq.item2;
|
||||
final mqViewPadding = mq.item3;
|
||||
|
||||
final viewInsets = widget.viewInsets ?? mqViewInsets;
|
||||
final viewPadding = widget.viewPadding ?? mqViewPadding;
|
||||
final overlayContentMaxWidth = mqWidth - viewPadding.horizontal - innerPadding.horizontal;
|
||||
|
||||
return Container(
|
||||
color: kOverlayBackgroundColor,
|
||||
padding: viewInsets + viewPadding.copyWith(top: 0),
|
||||
child: FutureBuilder<OverlayMetadata>(
|
||||
future: _detailLoader,
|
||||
builder: (futureContext, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.done && !snapshot.hasError) {
|
||||
_lastDetails = snapshot.data;
|
||||
_lastEntry = entry;
|
||||
}
|
||||
return _lastEntry == null
|
||||
? SizedBox.shrink()
|
||||
: Padding(
|
||||
// keep padding inside `FutureBuilder` so that overlay takes no space until data is ready
|
||||
padding: innerPadding,
|
||||
child: _FullscreenBottomOverlayContent(
|
||||
entry: _lastEntry,
|
||||
details: _lastDetails,
|
||||
position: widget.showPosition ? '${widget.index + 1}/${widget.entries.length}' : null,
|
||||
maxWidth: overlayContentMaxWidth,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const double _iconPadding = 8.0;
|
||||
const double _iconSize = 16.0;
|
||||
const double _interRowPadding = 2.0;
|
||||
const double _subRowMinWidth = 300.0;
|
||||
|
||||
class _FullscreenBottomOverlayContent extends AnimatedWidget {
|
||||
final ImageEntry entry;
|
||||
final OverlayMetadata details;
|
||||
final String position;
|
||||
final double maxWidth;
|
||||
|
||||
_FullscreenBottomOverlayContent({
|
||||
Key key,
|
||||
this.entry,
|
||||
this.details,
|
||||
this.position,
|
||||
this.maxWidth,
|
||||
}) : super(key: key, listenable: entry.metadataChangeNotifier);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DefaultTextStyle(
|
||||
style: Theme.of(context).textTheme.bodyText2.copyWith(
|
||||
shadows: [Constants.embossShadow],
|
||||
),
|
||||
softWrap: false,
|
||||
overflow: TextOverflow.fade,
|
||||
maxLines: 1,
|
||||
child: SizedBox(
|
||||
width: maxWidth,
|
||||
child: Selector<MediaQueryData, Orientation>(
|
||||
selector: (c, mq) => mq.orientation,
|
||||
builder: (c, orientation, child) {
|
||||
final twoColumns = orientation == Orientation.landscape && maxWidth / 2 > _subRowMinWidth;
|
||||
final subRowWidth = twoColumns ? min(_subRowMinWidth, maxWidth / 2) : maxWidth;
|
||||
final positionTitle = [
|
||||
if (position != null) position,
|
||||
if (entry.bestTitle != null) entry.bestTitle,
|
||||
].join(' • ');
|
||||
final hasShootingDetails = details != null && !details.isEmpty && settings.showOverlayShootingDetails;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (positionTitle.isNotEmpty) Text(positionTitle, strutStyle: Constants.overflowStrutStyle),
|
||||
_buildSoloLocationRow(),
|
||||
if (twoColumns)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: _interRowPadding),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(width: subRowWidth, child: _DateRow(entry)),
|
||||
_buildDuoShootingRow(subRowWidth, hasShootingDetails),
|
||||
],
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
Container(
|
||||
padding: EdgeInsets.only(top: _interRowPadding),
|
||||
width: subRowWidth,
|
||||
child: _DateRow(entry),
|
||||
),
|
||||
_buildSoloShootingRow(subRowWidth, hasShootingDetails),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSoloLocationRow() => AnimatedSwitcher(
|
||||
duration: Durations.fullscreenOverlayChangeAnimation,
|
||||
switchInCurve: Curves.easeInOutCubic,
|
||||
switchOutCurve: Curves.easeInOutCubic,
|
||||
transitionBuilder: _soloTransition,
|
||||
child: entry.hasGps
|
||||
? Container(
|
||||
padding: EdgeInsets.only(top: _interRowPadding),
|
||||
child: _LocationRow(entry: entry),
|
||||
)
|
||||
: SizedBox.shrink(),
|
||||
);
|
||||
|
||||
Widget _buildSoloShootingRow(double subRowWidth, bool hasShootingDetails) => AnimatedSwitcher(
|
||||
duration: Durations.fullscreenOverlayChangeAnimation,
|
||||
switchInCurve: Curves.easeInOutCubic,
|
||||
switchOutCurve: Curves.easeInOutCubic,
|
||||
transitionBuilder: _soloTransition,
|
||||
child: hasShootingDetails
|
||||
? Container(
|
||||
padding: EdgeInsets.only(top: _interRowPadding),
|
||||
width: subRowWidth,
|
||||
child: _ShootingRow(details),
|
||||
)
|
||||
: SizedBox.shrink(),
|
||||
);
|
||||
|
||||
Widget _buildDuoShootingRow(double subRowWidth, bool hasShootingDetails) => AnimatedSwitcher(
|
||||
duration: Durations.fullscreenOverlayChangeAnimation,
|
||||
switchInCurve: Curves.easeInOutCubic,
|
||||
switchOutCurve: Curves.easeInOutCubic,
|
||||
transitionBuilder: (child, animation) => FadeTransition(
|
||||
opacity: animation,
|
||||
child: child,
|
||||
),
|
||||
child: hasShootingDetails
|
||||
? Container(
|
||||
width: subRowWidth,
|
||||
child: _ShootingRow(details),
|
||||
)
|
||||
: SizedBox.shrink(),
|
||||
);
|
||||
|
||||
static Widget _soloTransition(Widget child, Animation<double> animation) => FadeTransition(
|
||||
opacity: animation,
|
||||
child: SizeTransition(
|
||||
axisAlignment: 1,
|
||||
sizeFactor: animation,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _LocationRow extends AnimatedWidget {
|
||||
final ImageEntry entry;
|
||||
|
||||
_LocationRow({
|
||||
Key key,
|
||||
this.entry,
|
||||
}) : super(key: key, listenable: entry.addressChangeNotifier);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
String location;
|
||||
if (entry.isLocated) {
|
||||
location = entry.shortAddress;
|
||||
} else if (entry.hasGps) {
|
||||
location = settings.coordinateFormat.format(entry.latLng);
|
||||
}
|
||||
return Row(
|
||||
children: [
|
||||
DecoratedIcon(AIcons.location, shadows: [Constants.embossShadow], size: _iconSize),
|
||||
SizedBox(width: _iconPadding),
|
||||
Expanded(child: Text(location, strutStyle: Constants.overflowStrutStyle)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DateRow extends StatelessWidget {
|
||||
final ImageEntry entry;
|
||||
|
||||
const _DateRow(this.entry);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final date = entry.bestDate;
|
||||
final dateText = date != null ? '${DateFormat.yMMMd().format(date)} • ${DateFormat.Hm().format(date)}' : Constants.overlayUnknown;
|
||||
return Row(
|
||||
children: [
|
||||
DecoratedIcon(AIcons.date, shadows: [Constants.embossShadow], size: _iconSize),
|
||||
SizedBox(width: _iconPadding),
|
||||
Expanded(flex: 3, child: Text(dateText, strutStyle: Constants.overflowStrutStyle)),
|
||||
Expanded(flex: 2, child: Text(entry.isSvg ? entry.aspectRatioText : entry.resolutionText, strutStyle: Constants.overflowStrutStyle)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ShootingRow extends StatelessWidget {
|
||||
final OverlayMetadata details;
|
||||
|
||||
const _ShootingRow(this.details);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
DecoratedIcon(AIcons.shooting, shadows: [Constants.embossShadow], size: _iconSize),
|
||||
SizedBox(width: _iconPadding),
|
||||
Expanded(child: Text(details.aperture ?? Constants.overlayUnknown, strutStyle: Constants.overflowStrutStyle)),
|
||||
Expanded(child: Text(details.exposureTime ?? Constants.overlayUnknown, strutStyle: Constants.overflowStrutStyle)),
|
||||
Expanded(child: Text(details.focalLength ?? Constants.overlayUnknown, strutStyle: Constants.overflowStrutStyle)),
|
||||
Expanded(child: Text(details.iso ?? Constants.overlayUnknown, strutStyle: Constants.overflowStrutStyle)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ExtraBottomOverlay extends StatelessWidget {
|
||||
final EdgeInsets viewInsets, viewPadding;
|
||||
final Widget child;
|
||||
|
||||
const ExtraBottomOverlay({
|
||||
Key key,
|
||||
this.viewInsets,
|
||||
this.viewPadding,
|
||||
@required this.child,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final mq = context.select<MediaQueryData, Tuple3<double, EdgeInsets, EdgeInsets>>((mq) => Tuple3(mq.size.width, mq.viewInsets, mq.viewPadding));
|
||||
final mqWidth = mq.item1;
|
||||
final mqViewInsets = mq.item2;
|
||||
final mqViewPadding = mq.item3;
|
||||
|
||||
final viewInsets = this.viewInsets ?? mqViewInsets;
|
||||
final viewPadding = this.viewPadding ?? mqViewPadding;
|
||||
final safePadding = (viewInsets + viewPadding).copyWith(bottom: 8) + EdgeInsets.symmetric(horizontal: 8.0);
|
||||
|
||||
return Padding(
|
||||
padding: safePadding,
|
||||
child: SizedBox(
|
||||
width: mqWidth - safePadding.horizontal,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,32 +0,0 @@
|
|||
import 'package:aves/image_providers/uri_image_provider.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:panorama/panorama.dart';
|
||||
|
||||
class PanoramaPage extends StatelessWidget {
|
||||
static const routeName = '/fullscreen/panorama';
|
||||
|
||||
final ImageEntry entry;
|
||||
|
||||
const PanoramaPage({@required this.entry});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Panorama(
|
||||
child: Image(
|
||||
image: UriImage(
|
||||
uri: entry.uri,
|
||||
mimeType: entry.mimeType,
|
||||
rotationDegrees: entry.rotationDegrees,
|
||||
isFlipped: entry.isFlipped,
|
||||
expectedContentLength: entry.sizeBytes,
|
||||
),
|
||||
),
|
||||
// TODO TLAD toggle sensor control
|
||||
sensorControl: SensorControl.None,
|
||||
),
|
||||
resizeToAvoidBottomInset: false,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -12,7 +12,7 @@ import 'package:aves/utils/android_file_utils.dart';
|
|||
import 'package:aves/widgets/collection/collection_page.dart';
|
||||
import 'package:aves/widgets/common/behaviour/routes.dart';
|
||||
import 'package:aves/widgets/filter_grids/albums_page.dart';
|
||||
import 'package:aves/widgets/fullscreen/fullscreen_page.dart';
|
||||
import 'package:aves/widgets/viewer/entry_viewer_page.dart';
|
||||
import 'package:aves/widgets/search/search_delegate.dart';
|
||||
import 'package:aves/widgets/search/search_page.dart';
|
||||
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
|
||||
|
@ -121,8 +121,8 @@ class _HomePageState extends State<HomePage> {
|
|||
Route _getRedirectRoute() {
|
||||
if (AvesApp.mode == AppMode.view) {
|
||||
return DirectMaterialPageRoute(
|
||||
settings: RouteSettings(name: SingleFullscreenPage.routeName),
|
||||
builder: (_) => SingleFullscreenPage(entry: _viewerEntry),
|
||||
settings: RouteSettings(name: SingleEntryViewerPage.routeName),
|
||||
builder: (_) => SingleEntryViewerPage(entry: _viewerEntry),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -105,7 +105,7 @@ class ExpandableFilterRow extends StatelessWidget {
|
|||
|
||||
Widget _buildFilterChip(CollectionFilter filter) {
|
||||
return AvesFilterChip(
|
||||
key: Key(filter.key),
|
||||
key: ValueKey(filter),
|
||||
filter: filter,
|
||||
heroType: heroTypeBuilder?.call(filter) ?? HeroType.onTap,
|
||||
onTap: onTap,
|
||||
|
|
|
@ -52,7 +52,7 @@ class _SearchPageState extends State<SearchPage> {
|
|||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(SearchPage oldWidget) {
|
||||
void didUpdateWidget(covariant SearchPage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.delegate != oldWidget.delegate) {
|
||||
oldWidget.delegate.queryTextController.removeListener(_onQueryChanged);
|
||||
|
|
|
@ -25,33 +25,42 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return MediaQueryDataProvider(
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Settings'),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Consumer<Settings>(
|
||||
builder: (context, settings, child) => AnimationLimiter(
|
||||
child: ListView(
|
||||
padding: EdgeInsets.all(8),
|
||||
children: AnimationConfiguration.toStaggeredList(
|
||||
duration: Durations.staggeredAnimation,
|
||||
delay: Durations.staggeredAnimationDelay,
|
||||
childAnimationBuilder: (child) => SlideAnimation(
|
||||
verticalOffset: 50.0,
|
||||
child: FadeInAnimation(
|
||||
child: child,
|
||||
body: Theme(
|
||||
data: theme.copyWith(
|
||||
textTheme: theme.textTheme.copyWith(
|
||||
// dense style font for tile subtitles, without modifying title font
|
||||
bodyText2: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Consumer<Settings>(
|
||||
builder: (context, settings, child) => AnimationLimiter(
|
||||
child: ListView(
|
||||
padding: EdgeInsets.all(8),
|
||||
children: AnimationConfiguration.toStaggeredList(
|
||||
duration: Durations.staggeredAnimation,
|
||||
delay: Durations.staggeredAnimationDelay,
|
||||
childAnimationBuilder: (child) => SlideAnimation(
|
||||
verticalOffset: 50.0,
|
||||
child: FadeInAnimation(
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
children: [
|
||||
_buildNavigationSection(context),
|
||||
_buildDisplaySection(context),
|
||||
_buildThumbnailsSection(context),
|
||||
_buildViewerSection(context),
|
||||
_buildSearchSection(context),
|
||||
_buildPrivacySection(context),
|
||||
],
|
||||
),
|
||||
children: [
|
||||
_buildNavigationSection(context),
|
||||
_buildDisplaySection(context),
|
||||
_buildThumbnailsSection(context),
|
||||
_buildViewerSection(context),
|
||||
_buildSearchSection(context),
|
||||
_buildPrivacySection(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -188,9 +197,15 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||
onChanged: (v) => settings.showOverlayMinimap = v,
|
||||
title: Text('Show minimap'),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: settings.showOverlayInfo,
|
||||
onChanged: (v) => settings.showOverlayInfo = v,
|
||||
title: Text('Show information'),
|
||||
subtitle: Text('Show title, date, location, etc.'),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: settings.showOverlayShootingDetails,
|
||||
onChanged: (v) => settings.showOverlayShootingDetails = v,
|
||||
onChanged: settings.showOverlayInfo ? (v) => settings.showOverlayShootingDetails = v : null,
|
||||
title: Text('Show shooting details'),
|
||||
),
|
||||
],
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/image_metadata.dart';
|
||||
import 'package:aves/model/metadata_db.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/common.dart';
|
||||
import 'package:aves/widgets/viewer/info/common.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class DbTab extends StatefulWidget {
|
|
@ -6,7 +6,7 @@ import 'package:aves/ref/mime_types.dart';
|
|||
import 'package:aves/services/android_debug_service.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/viewer/info/common.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class MetadataTab extends StatefulWidget {
|
|
@ -3,19 +3,19 @@ import 'package:aves/image_providers/uri_picture_provider.dart';
|
|||
import 'package:aves/main.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/widgets/fullscreen/debug/db.dart';
|
||||
import 'package:aves/widgets/fullscreen/debug/metadata.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/common.dart';
|
||||
import 'package:aves/widgets/viewer/debug/db.dart';
|
||||
import 'package:aves/widgets/viewer/debug/metadata.dart';
|
||||
import 'package:aves/widgets/viewer/info/common.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class FullscreenDebugPage extends StatelessWidget {
|
||||
static const routeName = '/fullscreen/debug';
|
||||
class ViewerDebugPage extends StatelessWidget {
|
||||
static const routeName = '/viewer/debug';
|
||||
|
||||
final ImageEntry entry;
|
||||
|
||||
const FullscreenDebugPage({@required this.entry});
|
||||
const ViewerDebugPage({@required this.entry});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
@ -80,7 +80,7 @@ class FullscreenDebugPage extends StatelessWidget {
|
|||
'isFlipped': '${entry.isFlipped}',
|
||||
'portrait': '${entry.isPortrait}',
|
||||
'displayAspectRatio': '${entry.displayAspectRatio}',
|
||||
'displaySize': '${entry.displaySize}',
|
||||
'displaySize': '${entry.getDisplaySize()}',
|
||||
}),
|
||||
Divider(),
|
||||
InfoRowGroup({
|
|
@ -1,6 +1,5 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:aves/image_providers/uri_image_provider.dart';
|
||||
import 'package:aves/model/actions/entry_actions.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
|
@ -10,14 +9,13 @@ import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
|||
import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/rename_entry_dialog.dart';
|
||||
import 'package:aves/widgets/fullscreen/fullscreen_debug_page.dart';
|
||||
import 'package:aves/widgets/fullscreen/source_viewer_page.dart';
|
||||
import 'package:aves/widgets/viewer/debug_page.dart';
|
||||
import 'package:aves/widgets/viewer/printer.dart';
|
||||
import 'package:aves/widgets/viewer/source_viewer_page.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:pdf/widgets.dart' as pdf;
|
||||
import 'package:pedantic/pedantic.dart';
|
||||
import 'package:printing/printing.dart';
|
||||
|
||||
class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
||||
final CollectionLens collection;
|
||||
|
@ -45,7 +43,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
|||
_showRenameDialog(context, entry);
|
||||
break;
|
||||
case EntryAction.print:
|
||||
_print(entry);
|
||||
EntryPrinter(entry).print();
|
||||
break;
|
||||
case EntryAction.rotateCCW:
|
||||
_rotate(context, entry, clockwise: false);
|
||||
|
@ -90,47 +88,6 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _print(ImageEntry entry) async {
|
||||
final uri = entry.uri;
|
||||
final mimeType = entry.mimeType;
|
||||
final rotationDegrees = entry.rotationDegrees;
|
||||
final isFlipped = entry.isFlipped;
|
||||
final documentName = entry.bestTitle ?? 'Aves';
|
||||
final doc = pdf.Document(title: documentName);
|
||||
|
||||
pdf.Widget pdfChild;
|
||||
if (entry.isSvg) {
|
||||
final bytes = await ImageFileService.getImage(uri, mimeType, entry.rotationDegrees, entry.isFlipped);
|
||||
if (bytes != null && bytes.isNotEmpty) {
|
||||
pdfChild = pdf.SvgImage(svg: utf8.decode(bytes));
|
||||
}
|
||||
} else {
|
||||
pdfChild = pdf.Image.provider(await flutterImageProvider(
|
||||
UriImage(
|
||||
uri: uri,
|
||||
mimeType: mimeType,
|
||||
rotationDegrees: rotationDegrees,
|
||||
isFlipped: isFlipped,
|
||||
),
|
||||
));
|
||||
}
|
||||
if (pdfChild != null) {
|
||||
doc.addPage(pdf.Page(
|
||||
orientation: entry.isPortrait ? pdf.PageOrientation.portrait : pdf.PageOrientation.landscape,
|
||||
build: (context) => pdf.FullPage(
|
||||
ignoreMargins: true,
|
||||
child: pdf.Center(
|
||||
child: pdfChild,
|
||||
),
|
||||
),
|
||||
)); // Page
|
||||
unawaited(Printing.layoutPdf(
|
||||
onLayout: (format) => doc.save(),
|
||||
name: documentName,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _flip(BuildContext context, ImageEntry entry) async {
|
||||
if (!await checkStoragePermission(context, {entry})) return;
|
||||
|
||||
|
@ -211,8 +168,8 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
|||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
settings: RouteSettings(name: FullscreenDebugPage.routeName),
|
||||
builder: (context) => FullscreenDebugPage(entry: entry),
|
||||
settings: RouteSettings(name: ViewerDebugPage.routeName),
|
||||
builder: (context) => ViewerDebugPage(entry: entry),
|
||||
),
|
||||
);
|
||||
}
|
169
lib/widgets/viewer/entry_scroller.dart
Normal file
169
lib/widgets/viewer/entry_scroller.dart
Normal file
|
@ -0,0 +1,169 @@
|
|||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/multipage.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/widgets/common/magnifier/pan/gesture_detector_scope.dart';
|
||||
import 'package:aves/widgets/common/magnifier/pan/scroll_physics.dart';
|
||||
import 'package:aves/widgets/viewer/multipage.dart';
|
||||
import 'package:aves/widgets/viewer/visual/entry_page_view.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class MultiEntryScroller extends StatefulWidget {
|
||||
final CollectionLens collection;
|
||||
final PageController pageController;
|
||||
final ValueChanged<int> onPageChanged;
|
||||
final VoidCallback onTap;
|
||||
final List<Tuple2<String, IjkMediaController>> videoControllers;
|
||||
final List<Tuple2<String, MultiPageController>> multiPageControllers;
|
||||
final void Function(String uri) onViewDisposed;
|
||||
|
||||
const MultiEntryScroller({
|
||||
this.collection,
|
||||
this.pageController,
|
||||
this.onPageChanged,
|
||||
this.onTap,
|
||||
this.videoControllers,
|
||||
this.multiPageControllers,
|
||||
this.onViewDisposed,
|
||||
});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _MultiEntryScrollerState();
|
||||
}
|
||||
|
||||
class _MultiEntryScrollerState extends State<MultiEntryScroller> with AutomaticKeepAliveClientMixin {
|
||||
List<ImageEntry> get entries => widget.collection.sortedEntries;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
|
||||
return MagnifierGestureDetectorScope(
|
||||
axis: [Axis.horizontal, Axis.vertical],
|
||||
child: PageView.builder(
|
||||
key: Key('horizontal-pageview'),
|
||||
scrollDirection: Axis.horizontal,
|
||||
controller: widget.pageController,
|
||||
physics: MagnifierScrollerPhysics(parent: BouncingScrollPhysics()),
|
||||
onPageChanged: widget.onPageChanged,
|
||||
itemBuilder: (context, index) {
|
||||
final entry = entries[index];
|
||||
|
||||
Widget child;
|
||||
if (entry.isMultipage) {
|
||||
final multiPageController = _getMultiPageController(entry);
|
||||
if (multiPageController != null) {
|
||||
child = FutureBuilder<MultiPageInfo>(
|
||||
future: multiPageController.info,
|
||||
builder: (context, snapshot) {
|
||||
final multiPageInfo = snapshot.data;
|
||||
return ValueListenableBuilder<int>(
|
||||
valueListenable: multiPageController.pageNotifier,
|
||||
builder: (context, page, child) {
|
||||
return _buildViewer(entry, multiPageInfo: multiPageInfo, page: page);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
child ??= _buildViewer(entry);
|
||||
|
||||
return ClipRect(
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
itemCount: entries.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
EntryPageView _buildViewer(ImageEntry entry, {MultiPageInfo multiPageInfo, int page = 0}) {
|
||||
return EntryPageView(
|
||||
key: Key('imageview'),
|
||||
entry: entry,
|
||||
multiPageInfo: multiPageInfo,
|
||||
page: page,
|
||||
heroTag: widget.collection.heroTag(entry),
|
||||
onTap: (_) => widget.onTap?.call(),
|
||||
videoControllers: widget.videoControllers,
|
||||
onDisposed: () => widget.onViewDisposed?.call(entry.uri),
|
||||
);
|
||||
}
|
||||
|
||||
MultiPageController _getMultiPageController(ImageEntry entry) {
|
||||
return widget.multiPageControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2;
|
||||
}
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
}
|
||||
|
||||
class SingleEntryScroller extends StatefulWidget {
|
||||
final ImageEntry entry;
|
||||
final VoidCallback onTap;
|
||||
final List<Tuple2<String, IjkMediaController>> videoControllers;
|
||||
final List<Tuple2<String, MultiPageController>> multiPageControllers;
|
||||
|
||||
const SingleEntryScroller({
|
||||
this.entry,
|
||||
this.onTap,
|
||||
this.videoControllers,
|
||||
this.multiPageControllers,
|
||||
});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _SingleEntryScrollerState();
|
||||
}
|
||||
|
||||
class _SingleEntryScrollerState extends State<SingleEntryScroller> with AutomaticKeepAliveClientMixin {
|
||||
ImageEntry get entry => widget.entry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
|
||||
Widget child;
|
||||
if (entry.isMultipage) {
|
||||
final multiPageController = _getMultiPageController(entry);
|
||||
if (multiPageController != null) {
|
||||
child = FutureBuilder<MultiPageInfo>(
|
||||
future: multiPageController.info,
|
||||
builder: (context, snapshot) {
|
||||
final multiPageInfo = snapshot.data;
|
||||
return ValueListenableBuilder<int>(
|
||||
valueListenable: multiPageController.pageNotifier,
|
||||
builder: (context, page, child) {
|
||||
return _buildViewer(multiPageInfo: multiPageInfo, page: page);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
child ??= _buildViewer();
|
||||
|
||||
return MagnifierGestureDetectorScope(
|
||||
axis: [Axis.vertical],
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
EntryPageView _buildViewer({MultiPageInfo multiPageInfo, int page = 0}) {
|
||||
return EntryPageView(
|
||||
entry: entry,
|
||||
multiPageInfo: multiPageInfo,
|
||||
page: page,
|
||||
onTap: (_) => widget.onTap?.call(),
|
||||
videoControllers: widget.videoControllers,
|
||||
);
|
||||
}
|
||||
|
||||
MultiPageController _getMultiPageController(ImageEntry entry) {
|
||||
return widget.multiPageControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2;
|
||||
}
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
}
|
|
@ -1,16 +1,16 @@
|
|||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||
import 'package:aves/widgets/fullscreen/fullscreen_body.dart';
|
||||
import 'package:aves/widgets/viewer/entry_viewer_stack.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class MultiFullscreenPage extends AnimatedWidget {
|
||||
static const routeName = '/fullscreen';
|
||||
class MultiEntryViewerPage extends AnimatedWidget {
|
||||
static const routeName = '/viewer';
|
||||
|
||||
final CollectionLens collection;
|
||||
final ImageEntry initialEntry;
|
||||
|
||||
const MultiFullscreenPage({
|
||||
const MultiEntryViewerPage({
|
||||
Key key,
|
||||
this.collection,
|
||||
this.initialEntry,
|
||||
|
@ -20,7 +20,7 @@ class MultiFullscreenPage extends AnimatedWidget {
|
|||
Widget build(BuildContext context) {
|
||||
return MediaQueryDataProvider(
|
||||
child: Scaffold(
|
||||
body: FullscreenBody(
|
||||
body: EntryViewerStack(
|
||||
collection: collection,
|
||||
initialEntry: initialEntry,
|
||||
),
|
||||
|
@ -31,12 +31,12 @@ class MultiFullscreenPage extends AnimatedWidget {
|
|||
}
|
||||
}
|
||||
|
||||
class SingleFullscreenPage extends StatelessWidget {
|
||||
static const routeName = '/fullscreen';
|
||||
class SingleEntryViewerPage extends StatelessWidget {
|
||||
static const routeName = '/viewer';
|
||||
|
||||
final ImageEntry entry;
|
||||
|
||||
const SingleFullscreenPage({
|
||||
const SingleEntryViewerPage({
|
||||
Key key,
|
||||
this.entry,
|
||||
}) : super(key: key);
|
||||
|
@ -45,7 +45,7 @@ class SingleFullscreenPage extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
return MediaQueryDataProvider(
|
||||
child: Scaffold(
|
||||
body: FullscreenBody(
|
||||
body: EntryViewerStack(
|
||||
initialEntry: entry,
|
||||
),
|
||||
backgroundColor: Navigator.canPop(context) ? Colors.transparent : Colors.black,
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue