Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2021-01-17 15:50:16 +09:00
commit 11d436ff5e
150 changed files with 3471 additions and 1840 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,6 +2,7 @@ import 'package:aves/theme/icons.dart';
import 'package:flutter/widgets.dart';
enum ChipSetAction {
group,
sort,
refresh,
stats,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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'),
),
],

View file

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

View file

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

View file

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

View file

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

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

View file

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